Quellcode durchsuchen

refactor(parser): extract rule-based markdown engine to dedicated module

- Create markdown-rules.ts with LineRule factories and MarkdownRuleEngine
- Replace inline position calculation with decorationRanges struct
- Refactor markdown-parser.ts to re-export types and use new engine
- Update useMarkdownDecorations.ts to work with new decoration model
- Adjust tests for new ParsedDecorations interface
Zander Hawke vor 1 Monat
Ursprung
Commit
c329de5e63

+ 205 - 0
AGENTS.md

@@ -0,0 +1,205 @@
+# AGENTS.md
+
+## System Intent & Context
+
+This repository is a high-performance, rule-based **block markdown editor** built with Vue 3, TypeScript, and the Lezer parsing framework. Documents are structured as distinct, interactive **Blocks** rather than a single flat string. A 3-stage parsing pipeline bridges UI state down to ASTs, enabling real-time extraction of pages, references, task state, callouts, and inline decorations.
+
+---
+
+## Monorepo Structure
+
+```
+apps/dev/          Development and testing sandbox (Vue 3 + Vite + TypeScript)
+packages/editor/   Core headless engine — parsers, rule registries, composables, tests
+```
+
+**Component flow:**
+
+```
+apps/dev/src/pages/EditorView.vue
+  └── apps/dev/src/components/Editor.vue
+        └── packages/editor/src/components/Block.vue
+              └── packages/editor/src/composables/useMarkdownDecorations.ts
+                    └── packages/editor/src/lib/markdown-rules.ts
+                          └── Lezer Parser Engine
+```
+
+---
+
+## Parsing Pipeline
+
+All syntax features flow through a 3-stage pipeline in `packages/editor/src/lib/markdown-rules.ts`:
+
+```
+[Raw String Fragment]
+       │
+       ├── Pipeline 1: Lezer AST Visitor  →  Inline Rules (Bold, Links, PageRef, Tags, Highlights)
+       ├── Pipeline 2: Pattern Matcher    →  Block/Line Rules (Headings, Tasks, Callouts, Dates)
+       └── Pipeline 3: Suffix Matcher     →  Property Extraction (Key::Value)
+       │
+       ▼
+[ParsedDecorations — unified output struct]
+```
+
+### Unified Output Contract
+
+```typescript
+export interface DecorationRange {
+  type: "hidden" | "content"
+  from: number
+  to: number
+  className?: string
+}
+
+export interface ParsedDecorations {
+  content: (TokenDecoration | PageRefDecoration | BlockRefDecoration | TagDecoration | HighlightDecoration)[]
+  properties: PropertyInfo[]
+  blocks: BlockDecoration[]
+}
+```
+
+Do not change this interface without updating both unit tests and block render cycles.
+
+---
+
+## Supported Syntax
+
+### Task States and Priorities
+
+```
+TODO Fix the critical focus bug [#A]
+└──┘                            └──┘
+status                          priority
+```
+
+- **Valid states:** `TODO`, `DOING`, `DONE`, `LATER`, `NOW`, `WAITING`, `CANCELLED`
+- **State progression:** `TODO → DOING → DONE` (linear). `LATER`, `NOW`, `WAITING`, `CANCELLED` are orthogonal.
+- **Valid priorities:** `[#A]`, `[#B]`, `[#C]` — independent of task state.
+
+### Obsidian-Style Elements
+
+| Syntax | Example |
+|---|---|
+| Page reference | `[[Page Name]]` or `[[Real Page\|Display Name]]` |
+| Block reference | `((block-id-1234))` |
+| Callout | `> [!NOTE] Description text` |
+| Property | `author:: John Doe` |
+
+**Valid callout types:** `NOTE`, `WARNING`, `TIP`, `DANGER`, `INFO`
+
+---
+
+## Technology Stack
+
+| Layer | Technology | Rule |
+|---|---|---|
+| Framework | Vue 3 (Composition API, `<script setup>`) | Use composables for local state slices (e.g. `useBlockKeyboardHandlers`). |
+| Type Safety | TypeScript (`strict: true`) | No implicit `any`. Use strict sum-types for internal configs like `TaskState`. |
+| AST Parsing | `@lezer/common` / Lezer | Prefer specific `SyntaxNode` targeting over brute-force regex on blocks. |
+| Formatting | Biome | Run `biome check` before committing. |
+| Testing | Vitest | Run `vitest run` to verify. Every new rule must have a mirror test. |
+
+---
+
+## How to Add a New Rule
+
+Follow these steps exactly. Do not deviate from this pattern.
+
+**1. Identify the pipeline stage.**
+- Inline syntax (spans within a line, e.g. bold, links) → `InlineRule`
+- Line/block syntax (whole-line patterns, e.g. headings, tasks) → `LineRule`
+- Key-value extraction → `SuffixRule`
+
+**2. Implement a self-contained factory function** in `packages/editor/src/lib/markdown-rules.ts`. Do not modify `MarkdownRuleEngine.parse()`.
+
+```typescript
+// Example: a minimal InlineRule factory
+function createMyInlineRule(): InlineRule {
+  return {
+    // Lezer node type(s) this rule targets
+    nodeTypes: ["MyNode"],
+    extract(node, content, offset) {
+      return [{
+        type: "content",
+        from: node.from + offset,
+        to: node.to + offset,
+        className: "my-decoration",
+      }]
+    },
+  }
+}
+```
+
+**3. Register it in the `MarkdownRuleEngine` constructor.**
+
+```typescript
+constructor() {
+  this.inlineRules = [
+    createBoldRule(),
+    createLinkRule(),
+    createMyInlineRule(), // ← add here
+  ]
+}
+```
+
+**4. Write a mirror test** in `packages/editor/src/lib/__tests__/markdown-parser.test.ts`. Follow the existing pattern:
+
+```typescript
+describe("MyInlineRule", () => {
+  it("decorates correctly", () => {
+    const result = engine.parse("some ~example~ text")
+    expect(result.content).toContainEqual({
+      type: "content",
+      from: 5,
+      to: 12,
+      className: "my-decoration",
+    })
+  })
+})
+```
+
+**5. Verify cursor behavior.** For any rule with `hidden` ranges (e.g. delimiters that hide when cursor is inside): confirm that `from`/`to` offsets are computed relative to the block's document offset, not the raw fragment string. Check the existing `createBoldRule` implementation as the reference.
+
+**6. Run checks.**
+
+```bash
+biome check packages/editor/src/lib/markdown-rules.ts
+vitest run
+```
+
+Both must pass before the change is complete.
+
+---
+
+## Cursor Behavior — Hidden vs. Content Ranges
+
+Decorations alternate between `hidden` (delimiters) and `content` (payload). For example, in `**bold**`:
+
+- Positions 0–1 (`**`) → `hidden` when cursor is inside content (positions 2–5)
+- Positions 2–5 (`bold`) → `content`
+- Positions 6–7 (`**`) → `hidden` when cursor is inside content
+
+**Rules:**
+- `hidden` ranges disappear from view when the cursor is inside the enclosing `content` range.
+- `content` ranges are always visible.
+- Never update extraction slices without verifying that cursor offset tracking remains accurate for both the `hidden` and `content` segments.
+
+---
+
+## Performance Constraints
+
+- Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `Block.vue` instances.
+- `visitTree` processes each Lezer node exactly once. Do not introduce nested tree walks.
+- Avoid allocations inside hot paths — reuse range objects where possible.
+
+---
+
+## Critical Constraints
+
+> **Read-only packed files.** Aggregated repo views from tools like Repomix or `workspace-pack` are transport-only. Never write output targeting packed container files. Always emit changes to original source paths under `apps/` or `packages/`.
+
+> **Rule isolation.** Never modify `MarkdownRuleEngine.parse()` to add syntax support. New syntax belongs in a new `InlineRule` or `LineRule` factory, registered in the constructor.
+
+> **Test coverage is required.** A rule without a mirror test in `__tests__/markdown-parser.test.ts` is not complete.
+
+> **Strict types.** Do not introduce `any`. If a type is genuinely unknown, model it explicitly with a discriminated union or `unknown` with a type guard.

+ 68 - 5
apps/dev/src/components/Editor.vue

@@ -12,16 +12,16 @@ This is a **block editor** with markdown decorations.
 
 #tag creates a tag chip, and you can use links like [this link](https://example.com).
 
-TODO this is a task item
-DOING this is another task
-DONE a completed task [#A]
-
-2026-05-20 is a date
+📅 2026-05-20 is a date
 
 property:: This is a property line
 another:: [[Page reference]] in a property
 `)
 
+const task1 = ref("TODO this is a task item")
+const task2 = ref("DOING this is another task")
+const task3 = ref("DONE a completed task [#A]")
+
 function log(event: string, payload?: unknown) {
   console.log(`[Block] ${event}`, payload)
 
@@ -53,5 +53,68 @@ function log(event: string, payload?: unknown) {
       @selection-change="log('selection-change', $event)"
       @cursor-change="log('cursor-change', $event)"
     />
+
+    <Block
+      v-model:content="task1"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+      @cursor-change="log('cursor-change', $event)"
+    />
+
+    <Block
+      v-model:content="task2"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+      @cursor-change="log('cursor-change', $event)"
+    />
+
+    <Block
+      v-model:content="task3"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+      @cursor-change="log('cursor-change', $event)"
+    />
   </div>
 </template>

+ 52 - 69
packages/editor/src/assets/style.css

@@ -9,169 +9,152 @@
   @apply m-0;
 }
 
-/* Marks */
+.md-decorator {
+  @apply sr-only;
+}
+
+/* ── Marks ───────────────────────────────────────────────────────── */
+
 .md-strong {
   @apply font-bold;
 }
 .md-em {
   @apply italic;
 }
-
 .md-code {
   @apply font-mono bg-(--ui-bg-elevated) px-1 py-0.5 rounded text-sm;
 }
-
 .md-strike {
   @apply line-through;
 }
-
 .md-highlight {
   @apply bg-yellow-400/20 dark:bg-yellow-400/30 px-0.5 py-0.5 rounded;
 }
-
 .md-link {
   @apply text-(--ui-primary) underline cursor-pointer;
 }
 
-/* References - Using safe CSS variables */
+/* ── References ──────────────────────────────────────────────────── */
+
 .md-page-ref {
-  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-primary)/10 text-(--ui-primary) border border-(--ui-primary)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-primary)/15;
+  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-primary)/10 text-(--ui-primary) border border-(--ui-primary)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-primary)/15;
 }
 
 .md-block-ref {
-  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-purple)/10 text-(--ui-purple) border border-(--ui-purple)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-purple)/15;
+  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-purple)/10 text-(--ui-purple) border border-(--ui-purple)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-purple)/15;
 }
 
+/* ── Tags ────────────────────────────────────────────────────────── */
+
 .md-tag {
-  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-success)/10 text-(--ui-success) border border-(--ui-success)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-success)/15;
+  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-success)/10 text-(--ui-success)
+         border border-(--ui-success)/20 text-sm font-medium cursor-pointer
+         hover:bg-(--ui-success)/15;
 }
 
-/* Properties */
+/* ── Properties ──────────────────────────────────────────────────── */
+
 .md-property-key {
   @apply text-(--ui-text-highlight) font-semibold;
 }
-
 .md-property-delimiter {
   @apply text-(--ui-text-muted) font-semibold;
 }
-
 .md-property-value {
   @apply text-(--ui-text-muted);
 }
 
-/* Hidden */
-.md-hidden,
-[data-md-hidden="true"] {
-  @apply opacity-0 text-[0px] pointer-events-none;
-}
-
-.md-page-ref-content[data-md-hidden="true"] {
-  @apply opacity-0 text-[0px] pointer-events-none;
-}
-
-/* Page Reference Alias */
-.md-page-ref-alias {
-  @apply bg-(--ui-primary)/10 text-(--ui-primary) px-1 py-px rounded text-[0.9em];
-}
-
-.ProseMirror .md-page-ref-alias {
-  @apply bg-(--ui-primary)/10 text-(--ui-primary);
-}
-
-/* Block-Level Decorations */
-.md-heading-content {
-  transition: all 0.2s ease-out;
-}
+/* ── Headings ────────────────────────────────────────────────────── */
 
-.md-heading-1 {
+.md-heading-content[data-heading-level="1"] {
   @apply text-2xl font-bold;
 }
-.md-heading-2 {
+.md-heading-content[data-heading-level="2"] {
   @apply text-xl font-semibold;
 }
-.md-heading-3 {
+.md-heading-content[data-heading-level="3"] {
   @apply text-lg font-medium;
 }
-.md-heading-4 {
+.md-heading-content[data-heading-level="4"],
+.md-heading-content[data-heading-level="5"],
+.md-heading-content[data-heading-level="6"] {
   @apply text-base font-medium;
 }
-.md-heading-5 {
-  @apply text-sm font-medium;
-}
-.md-heading-6 {
-  @apply text-sm text-(--ui-text-muted);
-}
 
 .md-heading-marker {
   @apply text-(--ui-text-muted) mr-2;
 }
 
-/* Task States */
+/* ── Tasks ───────────────────────────────────────────────────────── */
+
 .md-task-marker {
-  @apply font-bold uppercase;
+  @apply font-bold uppercase mr-1;
 }
-.md-task-todo {
+
+[data-task-state="TODO"] {
   @apply text-red-500;
 }
-.md-task-doing {
+[data-task-state="DOING"] {
   @apply text-yellow-500;
 }
-.md-task-done {
+[data-task-state="DONE"] {
   @apply text-green-500;
 }
-.md-task-later {
+[data-task-state="LATER"] {
   @apply text-gray-500;
 }
-.md-task-now {
+[data-task-state="NOW"] {
   @apply text-blue-500;
 }
-.md-task-waiting {
+[data-task-state="WAITING"] {
   @apply text-orange-500;
 }
-.md-task-cancelled {
+[data-task-state="CANCELLED"] {
   @apply text-gray-400 line-through;
 }
 
-/* Priority */
-.md-priority-marker {
-  @apply font-bold;
-}
-.md-priority-A {
+/* ── Priority ────────────────────────────────────────────────────── */
+
+[data-priority="A"] {
   @apply text-red-600 font-bold;
 }
-.md-priority-B {
+[data-priority="B"] {
   @apply text-orange-500 font-bold;
 }
-.md-priority-C {
+[data-priority="C"] {
   @apply text-gray-500;
 }
 
-/* Blockquote */
+/* ── Blockquote ──────────────────────────────────────────────────── */
+
 .md-blockquote-marker {
   @apply text-(--ui-primary) font-bold mr-2;
 }
 
-/* Callouts */
+/* ── Callouts ────────────────────────────────────────────────────── */
+
 .md-callout-marker {
   @apply font-bold;
 }
-.md-callout-note {
+
+[data-callout-type="NOTE"] {
   @apply text-blue-500;
 }
-.md-callout-warning {
+[data-callout-type="WARNING"] {
   @apply text-yellow-600;
 }
-.md-callout-tip {
+[data-callout-type="TIP"] {
   @apply text-green-500;
 }
-.md-callout-danger {
+[data-callout-type="DANGER"] {
   @apply text-red-600;
 }
-.md-callout-info {
+[data-callout-type="INFO"] {
   @apply text-(--ui-primary);
 }
 
-/* Date */
+/* ── Date ────────────────────────────────────────────────────────── */
+
 .md-date-marker {
   @apply text-(--ui-text-muted);
 }

+ 39 - 14
packages/editor/src/components/Block.vue

@@ -1,6 +1,11 @@
 <script setup lang="ts">
 import { watchDebounced } from "@vueuse/core"
-import { EditorState, TextSelection, type Transaction } from "prosemirror-state"
+import {
+  EditorState,
+  Selection,
+  TextSelection,
+  type Transaction,
+} from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
 import { createBlockKeyboardHandler } from "../composables/useBlockKeyboardHandlers"
@@ -99,7 +104,8 @@ const formattingHandlers = {
 const editorRef = useTemplateRef("editorRef")
 let view: EditorView | null = null
 
-const markdownDecorationsPlugin = createMarkdownDecorationsPlugin()
+const { plugin: markdownDecorationsPlugin, setFocus: setDecorationsFocus } =
+  createMarkdownDecorationsPlugin()
 
 const patternPlugin = createPatternPlugin({
   onPatternOpen: (payload) => emit("pattern-open", payload),
@@ -111,19 +117,27 @@ function focusAt(cursorPosition?: "start" | "end" | number) {
   if (!view) return
   const doc = view.state.doc
 
+  // Helper: safely create a selection at the given position.
+  // Uses Selection.near() to avoid crashes when position falls on
+  // node boundaries (e.g., between paragraphs). TextSelection.create()
+  // throws fatal errors on non-textblock positions.
+  const setSafeSelection = (pos: number) => {
+    const resolved = doc.resolve(pos)
+    const safeSelection = Selection.near(resolved)
+    view.dispatch(view.state.tr.setSelection(safeSelection))
+  }
+
   if (cursorPosition === "start") {
-    const pos = doc.content.size > 0 ? 1 : 0
-    if (pos >= 0 && pos <= doc.content.size) {
-      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
+    if (doc.content.size > 0) {
+      setSafeSelection(1)
     }
     view.focus()
     return
   }
 
   if (cursorPosition === "end") {
-    const pos = Math.max(1, doc.content.size - 1)
-    if (pos >= 0 && pos <= doc.content.size - 1) {
-      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
+    if (doc.content.size > 1) {
+      setSafeSelection(doc.content.size - 1)
     }
     view.focus()
     return
@@ -135,9 +149,7 @@ function focusAt(cursorPosition?: "start" | "end" | number) {
       return
     }
     const pos = Math.max(1, Math.min(cursorPosition, doc.content.size - 1))
-    if (pos >= 0 && pos <= doc.content.size - 1) {
-      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
-    }
+    setSafeSelection(pos)
     view.focus()
     return
   }
@@ -183,10 +195,20 @@ onMounted(() => {
     },
     handleDOMEvents: {
       focus() {
-        if (view) emit("focus", { view, handlers: formattingHandlers })
+        setDecorationsFocus(true)
+        if (view) {
+          // Dispatch a no-op transaction to force ProseMirror to re-evaluate
+          // props.decorations. The hasFocus flag lives in a closure outside
+          // ProseMirror's state machine, so without this transaction the
+          // decoration set won't rebuild until the next keystroke.
+          view.dispatch(view.state.tr)
+          emit("focus", { view, handlers: formattingHandlers })
+        }
         return false
       },
       blur() {
+        setDecorationsFocus(false)
+        if (view) view.dispatch(view.state.tr)
         emit("blur")
         return false
       },
@@ -194,6 +216,7 @@ onMounted(() => {
   })
 
   if (props.focused) {
+    setDecorationsFocus(true)
     focusAt(props.cursorPosition)
   }
 })
@@ -208,8 +231,10 @@ onUnmounted(() => {
 watch(
   () => [props.focused, props.cursorPosition],
   ([focused, cursorPosition]) => {
-    if (!view || !focused) return
-    focusAt(cursorPosition)
+    if (!view) return
+    setDecorationsFocus(!!focused)
+    view.dispatch(view.state.tr)
+    if (focused) focusAt(cursorPosition)
   },
 )
 

+ 8 - 4
packages/editor/src/components/__tests__/block-expose.test.ts

@@ -51,10 +51,14 @@ describe("Block expose API", () => {
 
     it("handles multiline strings", () => {
       const doc = contentToDoc("line1\nline2\nline3")
-      expect(doc.childCount).toBe(3)
-      expect(doc.child(0).textContent).toBe("line1")
-      expect(doc.child(1).textContent).toBe("line2")
-      expect(doc.child(2).textContent).toBe("line3")
+      expect(doc.childCount).toBe(1)
+      const paragraph = doc.child(0)
+      expect(paragraph.childCount).toBe(5) // text, br, text, br, text
+      expect(paragraph.child(0).textContent).toBe("line1")
+      expect(paragraph.child(1).type.name).toBe("hard_break")
+      expect(paragraph.child(2).textContent).toBe("line2")
+      expect(paragraph.child(3).type.name).toBe("hard_break")
+      expect(paragraph.child(4).textContent).toBe("line3")
     })
   })
 

+ 82 - 1
packages/editor/src/components/__tests__/block-keyboard.test.ts

@@ -29,7 +29,7 @@ function createMockView(content: string, cursorPos: number) {
       return cursorPos <= 1
     }
     if (dir === "down") {
-      return cursorPos >= content.length
+      return cursorPos >= doc.content.size - 2
     }
     return false
   }
@@ -77,6 +77,87 @@ describe("Block keyboard handler", () => {
       expect(result).toBe(true)
       expect(emit).toHaveBeenCalledWith("split", "hello", " world")
     })
+
+    it("preserves all paragraphs when splitting multi-paragraph block", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("line 1\nline 2\nline 3", 10)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Enter"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledTimes(1)
+      const splitCall = emit.mock.calls[0]
+      expect(splitCall[0]).toBe("split")
+      const before = splitCall[1]
+      const after = splitCall[2]
+      expect(before).toContain("line 1")
+      expect(after).toContain("line 3")
+    })
+
+    it("splits at correct position within single paragraph", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("first paragraph\nsecond paragraph", 8)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Enter"),
+      )
+
+      expect(result).toBe(true)
+      const splitCall = emit.mock.calls[0]
+      expect(splitCall[1]).toBe("first p")
+      expect(splitCall[2]).toContain("second paragraph")
+    })
+  })
+
+  describe("Shift+Enter", () => {
+    it("inserts hard_break instead of new paragraph", () => {
+      const { handlers } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+
+      const doc = contentToDoc("hello world")
+      const selection = TextSelection.create(doc, 6)
+
+      const mockTr = {
+        doc: doc,
+        setSelection: vi.fn().mockReturnThis(),
+        insert: vi.fn().mockReturnThis(),
+      }
+
+      const mockView = {
+        state: {
+          doc,
+          selection,
+          schema: {
+            nodes: {
+              hard_break: {
+                create: () => ({ type: { name: "hard_break" } }),
+              },
+            },
+          },
+          tr: mockTr,
+        },
+        coordsAtPos: () => ({ left: 100, top: 200 }),
+        dispatch: vi.fn(),
+        endOfTextblock: () => false,
+        focus: vi.fn(),
+      } as unknown as EditorView
+
+      const result = handleKeyDown(
+        mockView,
+        createMockKeyEvent("Enter", { shiftKey: true }),
+      )
+
+      expect(result).toBe(true)
+      const dispatch = (mockView as any).dispatch
+      expect(dispatch).toHaveBeenCalled()
+      expect(mockTr.insert).toHaveBeenCalled()
+    })
   })
 
   describe("Backspace at position 1", () => {

+ 120 - 15
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -4,8 +4,8 @@ import { contentToDoc } from "../../lib/content-model"
 import { createMarkdownDecorationsPlugin } from "../useMarkdownDecorations"
 
 describe("Markdown decorations plugin", () => {
-  it("builds decorations on init with content", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+  it("builds decorations on init with content when blurred", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
     const editorState = EditorState.create({
@@ -15,11 +15,66 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBeGreaterThan(0)
+    expect(pluginState?.contentCaches.size).toBeGreaterThan(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
+  })
+
+  it("returns empty decorations when focused", () => {
+    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    setFocus(true)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBe(0)
+  })
+
+  it("returns full decorations when blurred", () => {
+    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    setFocus(false)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
+  })
+
+  it("toggles correctly on focus/blur cycle", () => {
+    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    setFocus(true)
+    const focusedDecorations = plugin.spec.props?.decorations?.(editorState)
+    expect(focusedDecorations?.find().length).toBe(0)
+
+    setFocus(false)
+    const blurredDecorations = plugin.spec.props?.decorations?.(editorState)
+    expect(blurredDecorations?.find().length).toBeGreaterThan(0)
+
+    setFocus(true)
+    const refocusedDecorations = plugin.spec.props?.decorations?.(editorState)
+    expect(refocusedDecorations?.find().length).toBe(0)
   })
 
   it("returns empty decorations with empty document on init", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("")
 
     const editorState = EditorState.create({
@@ -29,11 +84,13 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBe(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBe(0)
   })
 
   it("rebuilds decorations when document changes", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("plain")
 
     const editorState = EditorState.create({
@@ -41,15 +98,55 @@ describe("Markdown decorations plugin", () => {
       plugins: [plugin],
     })
 
+    const tr = editorState.tr.insertText(" **bold**", 5)
+    const newState = editorState.apply(tr)
+
+    const decorations = plugin.spec.props?.decorations?.(newState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
+  })
+
+  it("reuses cached tokens when only cursor moves", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const initialState = plugin.getState(editorState)
+    expect(initialState).not.toBeNull()
+
+    const tr = editorState.tr.setSelection(
+      editorState.selection.constructor.create(editorState.doc, 3),
+    )
+    const movedState = editorState.apply(tr)
+    const movedPluginState = plugin.getState(movedState)
+
+    expect(movedPluginState?.contentCaches).toBe(initialState?.contentCaches)
+  })
+
+  it("invalidates cache when document changes", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const initialState = plugin.getState(editorState)
+    expect(initialState).not.toBeNull()
+
     const tr = editorState.tr.insertText(" **bold**", 5)
     const newState = editorState.apply(tr)
     const newPluginState = plugin.getState(newState)
 
-    expect(newPluginState?.find().length).toBeGreaterThan(0)
+    expect(newPluginState?.contentCaches).not.toBe(initialState?.contentCaches)
   })
 
   it("builds decorations for highlight", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello ^^world^^")
 
     const editorState = EditorState.create({
@@ -59,11 +156,13 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBeGreaterThan(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("builds decorations for page reference", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello [[Page]]")
 
     const editorState = EditorState.create({
@@ -73,11 +172,13 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBeGreaterThan(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("builds decorations for block reference", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello ((block-id))")
 
     const editorState = EditorState.create({
@@ -87,11 +188,13 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBeGreaterThan(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("builds decorations for tag", () => {
-    const plugin = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello #tag")
 
     const editorState = EditorState.create({
@@ -101,6 +204,8 @@ describe("Markdown decorations plugin", () => {
 
     const pluginState = plugin.getState(editorState)
     expect(pluginState).not.toBeNull()
-    expect(pluginState?.find().length).toBeGreaterThan(0)
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
   })
 })

+ 25 - 10
packages/editor/src/composables/useBlockKeyboardHandlers.ts

@@ -3,7 +3,7 @@
  *
  * Handles key events for block editor:
  * - Enter: split block (emit to parent)
- * - Shift+Enter: insert new paragraph within block
+ * - Shift+Enter: insert hard_break for soft line breaks within a paragraph
  * - Backspace: chain of delete, join, merge-previous, delete-if-empty
  * - Delete: join forward
  * - Arrow keys: emit boundary events at document edges
@@ -19,6 +19,8 @@ import {
 } from "prosemirror-commands"
 import type { EditorState } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
+import { docToContent } from "../lib/content-model"
+import { schema } from "../lib/schema"
 
 export interface BlockKeyboardHandlers {
   split: (before: string, after: string) => void
@@ -55,13 +57,13 @@ export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
   ): boolean {
     const state = view.state
 
-    // Shift+Enter: insert new paragraph (soft line break within block)
+    // Shift+Enter: insert hard_break for soft line breaks within a paragraph
     if (event.key === "Enter" && event.shiftKey) {
       const $head = state.selection.$head
-      const paragraph = state.schema.nodes.paragraph.create()
-      const tr = state.tr.insert($head.after(), paragraph)
+      const hardBreak = state.schema.nodes.hard_break.create()
+      const tr = state.tr.insert($head.pos, hardBreak)
       tr.setSelection(
-        state.selection.constructor.near(tr.doc.resolve($head.after() + 1)),
+        state.selection.constructor.near(tr.doc.resolve($head.pos + 1)),
       )
       view.dispatch(tr)
       view.focus()
@@ -69,12 +71,25 @@ export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
     }
 
     // Enter: split block (create new Block.vue at parent level)
+    // Uses ProseMirror slice operations to preserve all paragraphs,
+    // not just the one containing the cursor. This prevents data loss
+    // when splitting multi-paragraph blocks created via Shift+Enter.
     if (event.key === "Enter") {
-      const $head = state.selection.$head
-      const parentText = $head.parent.textContent ?? ""
-      const before = parentText.slice(0, $head.parentOffset)
-      const after = parentText.slice($head.parentOffset)
-      handlers.split(before, after)
+      const { doc, selection } = state
+      const { from } = selection
+
+      // Slice document at cursor position and convert each half to markdown
+      const beforeSlice = doc.slice(0, from)
+      const afterSlice = doc.slice(from, doc.content.size)
+
+      // Wrap slices in doc nodes so docToContent can process them
+      const beforeDoc = schema.nodes.doc.create(null, beforeSlice.content)
+      const afterDoc = schema.nodes.doc.create(null, afterSlice.content)
+
+      const beforeStr = docToContent(beforeDoc)
+      const afterStr = docToContent(afterDoc)
+
+      handlers.split(beforeStr, afterStr)
       return true
     }
 

+ 230 - 281
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -8,352 +8,301 @@
  * - Block references ((block-id))
  * - Tags #tag
  * - Property lines (key:: value)
- * - Cursor-aware reveal: delimiters hidden when cursor inside formatted region
+ *
+ * Focus-based rendering:
+ * - When the block has focus: no decorations (raw text for editing)
+ * - When the block is blurred: full decorations (styled preview)
+ *
+ * Architecture:
+ * - Rules provide wrapperClass and wrapperAttrs per token
+ * - ProseMirror decorations are flat, so wrapper attrs are stamped
+ *   onto every segment — no structural dependency between spans
+ * - Token array is the source of truth — decorations are a lazy projection
+ *   built in props.decorations. Tokens are only re-parsed on document
+ *   changes, not cursor moves.
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
 import {
-  defaultPatterns,
-  type PatternDecorationConfig,
-} from "../lib/markdown-extensions"
-import {
-  type BlockRefDecoration,
-  getActiveDecorations,
-  type HighlightDecoration,
-  type PageRefDecoration,
-  type TagDecoration,
-  type TokenDecoration,
+  type DecorationRange,
+  MarkdownRuleEngine,
+  parseMarkdown,
 } from "../lib/markdown-parser"
 
-type Token =
-  | TokenDecoration
-  | PageRefDecoration
-  | BlockRefDecoration
-  | TagDecoration
-  | HighlightDecoration
-
 type DecorationSpec = ReturnType<typeof Decoration.inline>
 
-function hasPageName(token: Token): token is PageRefDecoration {
-  return "pageName" in token
+interface ParsedToken {
+  type: string
+  decorationRanges: DecorationRange[]
+  wrapperAttrs?: Record<string, string>
+  wrapperAttrs?: Record<string, string>
+  pageName?: string
+  blockId?: string
+  tag?: string
+  alias?: string
 }
 
-function hasBlockId(token: Token): token is BlockRefDecoration {
-  return "blockId" in token
+/**
+ * Cached token for a single textblock (paragraph).
+ * The token array is the source of truth — decorations are derived.
+ */
+interface BlockTokenCache {
+  /** Text content this cache was built from */
+  text: string
+  /** Inline tokens (bold, links, refs, etc.) */
+  content: ParsedToken[]
+  /** Block-level tokens (headings, tasks, etc.) */
+  blocks: ParsedToken[]
+  /** Property decorations */
+  properties: Array<{
+    keyRange: [number, number]
+    delimiterRange: [number, number]
+    valueRange: [number, number]
+    value?: string
+  }>
 }
 
-function hasTag(token: Token): token is Token {
-  return "tag" in token
+/** Plugin state — tokens only, decorations are a lazy projection */
+interface DecorationState {
+  /** Engine instance for this plugin — isolated from other instances */
+  engine: MarkdownRuleEngine
+  /** Token cache keyed by content text — survives position shifts */
+  contentCaches: Map<string, BlockTokenCache>
 }
 
-function hasAlias(token: Token): token is PageRefDecoration {
-  return (
-    "alias" in token && typeof (token as PageRefDecoration).alias === "string"
-  )
+function collectBlockText(node: ProsemirrorNode): string {
+  let text = ""
+  for (let i = 0; i < node.childCount; i++) {
+    const child = node.child(i)
+    if (child.type.name === "hard_break") {
+      text += "\n"
+    } else if (child.isText && child.text) {
+      text += child.text
+    }
+  }
+  return text
 }
 
-function getTokenName(token: Token): string {
-  if (hasPageName(token)) return token.pageName
-  if (hasBlockId(token)) return token.blockId
-  if (hasTag(token) && "tag" in token) return (token as { tag: string }).tag
-  return ""
+/**
+ * Parse tokens for a textblock. Returns cached structure.
+ * Only runs when document content changes.
+ */
+function parseBlockTokens(
+  text: string,
+  engine: MarkdownRuleEngine,
+): BlockTokenCache {
+  const { content, blocks, properties } = parseMarkdown(text, engine)
+  return {
+    text,
+    content: content as ParsedToken[],
+    blocks: blocks as ParsedToken[],
+    properties,
+  }
 }
 
-const markdownDecorationsKey = new PluginKey("markdownDecorations")
-
-const decorationConfigMap: Record<string, PatternDecorationConfig> =
-  defaultPatterns.reduce(
-    (acc, pattern) => {
-      if (pattern.decoration) {
-        acc[pattern.nodeType] = pattern.decoration
-      }
-      return acc
-    },
-    {} as Record<string, PatternDecorationConfig>,
-  )
-
 /**
- * Build decoration set from ProseMirror document.
- * Walks each text node and applies decorations based on Lezer parsing.
+ * Build decorations from cached tokens.
+ * Runs lazily in props.decorations — only when ProseMirror asks for them.
+ * No cursor tracking, no pattern suppression — all ranges always visible.
  */
-function buildDecorationSet(
+function buildDecorationsFromCache(
   doc: ProsemirrorNode,
-  cursorPos: number,
+  contentCaches: Map<string, BlockTokenCache>,
 ): DecorationSet {
-  const decorationSpecs: ReturnType<typeof Decoration.inline>[] = []
+  const decorationSpecs: DecorationSpec[] = []
 
   doc.descendants((node: ProsemirrorNode, pos: number) => {
     if (!node.isTextblock) return
 
     const blockStart = pos + 1
+    const blockText = collectBlockText(node)
 
-    let blockText = ""
-    for (let i = 0; i < node.childCount; i++) {
-      const child = node.child(i)
-      if (child.type.name === "hardBreak") {
-        blockText += "\n"
-      } else if (child.isText && child.text) {
-        blockText += child.text
-      }
+    const cache = contentCaches.get(blockText)
+    if (!cache) return
+
+    for (const block of cache.blocks) {
+      buildTokenDecorations(block, blockStart, decorationSpecs)
     }
 
-    const blockCursor =
-      cursorPos >= blockStart && cursorPos <= blockStart + blockText.length
-        ? cursorPos - blockStart
-        : 0
-    const { blocks } = getActiveDecorations(blockText, blockCursor)
-
-    for (const block of blocks) {
-      const [markerFrom, markerTo] = block.markerRange
-      const [contentFrom, contentTo] = block.contentRange
-
-      if (block.type === "heading") {
-        const level = block.metadata?.level || 1
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: `md-heading-marker md-heading-${level}`,
-            "data-md-block": "heading",
-          }),
-        )
-        if (contentFrom < contentTo) {
-          decorationSpecs.push(
-            Decoration.inline(
-              blockStart + contentFrom,
-              blockStart + contentTo,
-              {
-                class: `md-heading-content md-heading-${level}`,
-                "data-md-block": "heading",
-              },
-            ),
-          )
-        }
-      } else if (block.type === "task") {
-        const taskState = block.metadata?.taskState || "TODO"
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: `md-task-marker md-task-${taskState.toLowerCase()}`,
-            "data-md-block": "task",
-          }),
-        )
-      } else if (block.type === "priority") {
-        const priority = block.metadata?.priority || "B"
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: `md-priority-marker md-priority-${priority}`,
-            "data-md-block": "priority",
-          }),
-        )
-      } else if (block.type === "blockquote") {
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: "md-blockquote-marker",
-            "data-md-block": "blockquote",
-          }),
-        )
-      } else if (block.type === "callout") {
-        const calloutType = block.metadata?.calloutType || "NOTE"
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: `md-callout-marker md-callout-${calloutType.toLowerCase()}`,
-            "data-md-block": "callout",
-          }),
-        )
-      } else if (block.type === "date") {
-        decorationSpecs.push(
-          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
-            class: "md-date-marker",
-            "data-md-block": "date",
-          }),
-        )
-      }
+    for (const token of cache.content) {
+      buildTokenDecorations(token, blockStart, decorationSpecs)
     }
 
-    let localPos = 0
+    buildPropertyDecorations(cache.properties, blockStart, decorationSpecs)
+  })
 
-    for (let i = 0; i < node.childCount; i++) {
-      const child = node.child(i)
+  return DecorationSet.create(doc, decorationSpecs)
+}
 
-      if (child.type.name === "hardBreak") {
-        localPos += 1
-        continue
+/**
+ * Build decorations for a single token.
+ *
+ * ProseMirror decorations are flat — overlapping ranges produce sibling
+ * spans, not nested elements. Wrapper attrs are stamped onto every
+ * segment so each span is self-contained.
+ */
+function buildTokenDecorations(
+  token: ParsedToken,
+  offset: number,
+  decorationSpecs: DecorationSpec[],
+) {
+  const ranges = token.decorationRanges
+  if (ranges.length === 0) return
+
+  const sorted = [...ranges].sort((a, b) => a.from - b.from)
+
+  if (import.meta.env?.DEV) {
+    for (let i = 1; i < sorted.length; i++) {
+      if (sorted[i].from < sorted[i - 1].to) {
+        console.warn(
+          `[markdown-decorations] Overlapping ranges in token "${token.type}": [${sorted[i - 1].from}-${sorted[i - 1].to}] and [${sorted[i].from}-${sorted[i].to}]`,
+        )
       }
+    }
+  }
 
-      if (!child.isText || !child.text) continue
-
-      const text = child.text
-      const childOffset = blockStart + localPos
+  for (const range of sorted) {
+    const rangeFrom = offset + range.from
+    const rangeTo = offset + range.to
 
-      const adjustedCursor = cursorPos - childOffset
-      const { content: tokens, properties } = getActiveDecorations(
-        text,
-        adjustedCursor < 0 ? 0 : adjustedCursor,
-      )
+    const rangeClass =
+      range.className ??
+      (range.type === "hidden" ? "md-decorator" : `md-${range.type}`)
 
-      function applyPatternDecoration(
-        token: Token,
-        childOffset: number,
-        decorationSpecs: DecorationSpec[],
-      ) {
-        const config = decorationConfigMap[token.type]
-        if (!config) return false
-
-        const hiddenClass = config.hiddenClass || "md-hidden"
-
-        const lastDelim = token.delimiters[token.delimiters.length - 1]
-        const isThreePart =
-          token.delimiters.length >= 3 && lastDelim.type === "hidden"
-
-        for (const delim of token.delimiters) {
-          if (delim.type === "hidden") {
-            decorationSpecs.push(
-              Decoration.inline(
-                delim.from + childOffset,
-                delim.to + childOffset,
-                { class: hiddenClass, "data-md-hidden": "true" },
-              ),
-            )
-          } else {
-            const contentFrom = delim.from + childOffset
-            const contentTo = delim.to + childOffset
-
-            const showAlias = isThreePart && config.showAlias && hasAlias(token)
-            if (showAlias) {
-              const widget = document.createElement("span")
-              widget.className = config.widgetClass || config.contentClass
-              widget.setAttribute("data-md-type", token.type)
-              widget.setAttribute(
-                `data-${token.type.split("-")[0]}-name`,
-                getTokenName(token),
-              )
-              if (hasAlias(token) && (token as PageRefDecoration).alias)
-                widget.setAttribute(
-                  `data-${token.type.split("-")[0]}-alias`,
-                  (token as PageRefDecoration).alias,
-                )
-              widget.textContent =
-                (token as PageRefDecoration).alias || getTokenName(token)
-
-              decorationSpecs.push(
-                Decoration.widget(contentFrom, widget, { side: 1 }),
-              )
-              decorationSpecs.push(
-                Decoration.inline(contentFrom, contentTo, {
-                  class: config.contentClass,
-                  "data-md-hidden": "true",
-                }),
-              )
-            } else {
-              const attrs: Record<string, string> = {
-                class: config.contentClass,
-                "data-md-type": token.type,
-              }
-              if (hasPageName(token)) attrs["data-page-name"] = token.pageName
-              if (hasBlockId(token)) attrs["data-block-id"] = token.blockId
-
-              decorationSpecs.push(
-                Decoration.inline(contentFrom, contentTo, attrs),
-              )
-            }
-          }
-        }
+    const classes = rangeClass
 
-        return true
-      }
+    const attrs: Record<string, string> = {
+      ...token.wrapperAttrs,
+      class: classes,
+    }
 
-      for (const token of tokens) {
-        if (applyPatternDecoration(token, childOffset, decorationSpecs)) {
-          continue
-        }
+    decorationSpecs.push(
+      Decoration.inline(rangeFrom, rangeTo, {
+        nodeName: "span",
+        ...attrs,
+      }),
+    )
+  }
+}
 
-        for (const delim of token.delimiters) {
-          if (delim.type === "hidden") {
-            decorationSpecs.push(
-              Decoration.inline(
-                delim.from + childOffset,
-                delim.to + childOffset,
-                {
-                  class: "md-hidden",
-                  "data-md-hidden": "true",
-                },
-              ),
-            )
-          } else {
-            decorationSpecs.push(
-              Decoration.inline(
-                delim.from + childOffset,
-                delim.to + childOffset,
-                {
-                  class: delim.className || "",
-                  "data-md-type": token.type,
-                },
-              ),
-            )
-          }
-        }
-      }
+function buildPropertyDecorations(
+  properties: Array<{
+    keyRange: [number, number]
+    delimiterRange: [number, number]
+    valueRange: [number, number]
+    value?: string
+  }>,
+  childOffset: number,
+  decorationSpecs: DecorationSpec[],
+) {
+  for (const prop of properties) {
+    const [keyFrom, keyTo] = prop.keyRange
+    const [delimFrom, delimTo] = prop.delimiterRange
+    const [valueFrom, valueTo] = prop.valueRange
+
+    decorationSpecs.push(
+      Decoration.inline(keyFrom + childOffset, keyTo + childOffset, {
+        class: "md-property-key",
+      }),
+    )
+    decorationSpecs.push(
+      Decoration.inline(delimFrom + childOffset, delimTo + childOffset, {
+        class: "md-property-delimiter",
+      }),
+    )
+    if (prop.value) {
+      decorationSpecs.push(
+        Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
+          class: "md-property-value",
+        }),
+      )
+    }
+  }
+}
 
-      for (const prop of properties) {
-        const [keyFrom, keyTo] = prop.keyRange
-        const [delimFrom, delimTo] = prop.delimiterRange
-        const [valueFrom, valueTo] = prop.valueRange
+const markdownDecorationsKey = new PluginKey("markdownDecorations")
 
-        decorationSpecs.push(
-          Decoration.inline(keyFrom + childOffset, keyTo + childOffset, {
-            class: "md-property-key",
-          }),
-        )
-        decorationSpecs.push(
-          Decoration.inline(delimFrom + childOffset, delimTo + childOffset, {
-            class: "md-property-delimiter",
-          }),
-        )
-        if (prop.value) {
-          decorationSpecs.push(
-            Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
-              class: "md-property-value",
-            }),
-          )
-        }
-      }
+/**
+ * Invalidate caches for textblocks whose content changed.
+ * Caches are keyed by content text, so identical content reuses
+ * cached tokens even if document positions have shifted.
+ */
+function invalidateChangedCaches(
+  newDoc: ProsemirrorNode,
+  oldCaches: Map<string, BlockTokenCache>,
+  engine: MarkdownRuleEngine,
+): Map<string, BlockTokenCache> {
+  const newCaches = new Map<string, BlockTokenCache>()
+
+  newDoc.descendants((node: ProsemirrorNode) => {
+    if (!node.isTextblock) return
+    const blockText = collectBlockText(node)
 
-      localPos += text.length
+    const oldCache = oldCaches.get(blockText)
+    if (oldCache) {
+      newCaches.set(blockText, oldCache)
+    } else {
+      newCaches.set(blockText, parseBlockTokens(blockText, engine))
     }
   })
 
-  return DecorationSet.create(doc, decorationSpecs)
+  return newCaches
 }
 
-/**
- * Create a ProseMirror plugin for markdown decorations.
- * Each block should have its own plugin instance.
- * @returns Plugin to be added to EditorState
- */
 export function createMarkdownDecorationsPlugin() {
-  const plugin = new Plugin({
+  let hasFocus = false
+
+  const plugin = new Plugin<DecorationState>({
     key: markdownDecorationsKey,
     state: {
       init(_, state) {
-        const cursor = state.selection?.head ?? 1
-        return buildDecorationSet(state.doc, cursor)
+        const engine = new MarkdownRuleEngine()
+        const contentCaches = new Map<string, BlockTokenCache>()
+
+        state.doc.descendants((node: ProsemirrorNode) => {
+          if (!node.isTextblock) return
+          const blockText = collectBlockText(node)
+          contentCaches.set(blockText, parseBlockTokens(blockText, engine))
+        })
+
+        return { engine, contentCaches }
       },
-      apply(tr, oldPluginState) {
-        if (!tr.docChanged && !tr.selectionSet) {
-          return oldPluginState
+
+      apply(tr, prevState, _oldState, newState) {
+        if (!tr.docChanged) {
+          return prevState
         }
-        const cursor = tr.selection.head
-        return buildDecorationSet(tr.doc, cursor)
+
+        const contentCaches = invalidateChangedCaches(
+          newState.doc,
+          prevState.contentCaches,
+          prevState.engine,
+        )
+
+        return { engine: prevState.engine, contentCaches }
       },
     },
+
     props: {
       decorations(state) {
-        return this.getState(state) ?? DecorationSet.empty
+        if (hasFocus) return DecorationSet.empty
+
+        const pluginState = markdownDecorationsKey.getState(state)
+        if (!pluginState) return DecorationSet.empty
+
+        return buildDecorationsFromCache(state.doc, pluginState.contentCaches)
       },
     },
   })
 
-  return plugin
+  return {
+    plugin,
+    setFocus: (focused: boolean) => {
+      hasFocus = focused
+    },
+  }
 }
 
 export type { DecorationSet }

+ 9 - 0
packages/editor/src/composables/usePatternPlugin.ts

@@ -120,6 +120,15 @@ function findPattern(
 
     if (isPatternCompleted(spec, query)) return null
 
+    // For delimiter-terminated patterns, check if the closing delimiter
+    // exists anywhere in the full textblock after the trigger start.
+    // This prevents an active session when the pattern is already complete
+    // but the cursor is inside it (e.g. navigating into [[Page Ref]]).
+    if (spec.termination === "delimiter" && spec.end) {
+      const textFromTrigger = text.slice(startIdx)
+      if (textFromTrigger.includes(spec.end)) return null
+    }
+
     if (spec.termination === "boundary" && startIdx > 0) {
       const charBefore = textBefore[startIdx - 1]
       if (charBefore && !/[\s\n]/.test(charBefore)) continue

+ 0 - 1
packages/editor/src/index.ts

@@ -4,7 +4,6 @@ import Block from "./components/Block.vue"
 import "./assets/style.css"
 
 export type { MarkdownPattern } from "./lib/markdown-extensions"
-export { getActiveDecorations } from "./lib/markdown-parser"
 export { Block }
 
 export default {

+ 17 - 8
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -5,14 +5,18 @@ import { schema } from "../schema"
 describe("contentToDoc", () => {
   it("handles plain text", () => {
     const doc = contentToDoc("hello world")
+    expect(doc.childCount).toBe(1)
     expect(doc.child(0).textContent).toBe("hello world")
   })
 
-  it("handles newlines as separate paragraphs", () => {
+  it("handles newlines as hard_breaks within single paragraph", () => {
     const doc = contentToDoc("line1\nline2")
-    expect(doc.childCount).toBe(2)
-    expect(doc.child(0).textContent).toBe("line1")
-    expect(doc.child(1).textContent).toBe("line2")
+    expect(doc.childCount).toBe(1)
+    const paragraph = doc.child(0)
+    expect(paragraph.childCount).toBe(3) // text, hard_break, text
+    expect(paragraph.child(0).textContent).toBe("line1")
+    expect(paragraph.child(1).type.name).toBe("hard_break")
+    expect(paragraph.child(2).textContent).toBe("line2")
   })
 
   it("handles unicode and emoji", () => {
@@ -28,7 +32,9 @@ describe("contentToDoc", () => {
 
   it("handles only newlines", () => {
     const doc = contentToDoc("\n\n")
-    expect(doc.childCount).toBe(3)
+    expect(doc.childCount).toBe(1)
+    const paragraph = doc.child(0)
+    expect(paragraph.childCount).toBe(2) // two hard_breaks
   })
 })
 
@@ -40,10 +46,13 @@ describe("docToContent", () => {
     expect(docToContent(doc)).toBe("hello")
   })
 
-  it("joins multiple paragraphs with newlines", () => {
+  it("joins hard_breaks as newlines", () => {
     const doc = schema.nodes.doc.create(null, [
-      schema.nodes.paragraph.create(null, [schema.text("line1")]),
-      schema.nodes.paragraph.create(null, [schema.text("line2")]),
+      schema.nodes.paragraph.create(null, [
+        schema.text("line1"),
+        schema.nodes.hard_break.create(),
+        schema.text("line2"),
+      ]),
     ])
     expect(docToContent(doc)).toBe("line1\nline2")
   })

+ 554 - 255
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -1,7 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
   type BlockRefDecoration,
-  getActiveDecorations,
   type PageRefDecoration,
   parseMarkdown,
   type TagDecoration,
@@ -12,16 +11,16 @@ describe("parseMarkdown", () => {
     const result = parseMarkdown("**bold**")
     expect(result.content).toHaveLength(1)
     expect(result.content[0].type).toBe("strong")
-    const [opener, content, closer] = result.content[0].delimiters
-    expect(opener.type).toBe("hidden")
-    expect(opener.from).toBe(0)
-    expect(opener.to).toBe(2)
-    expect(content.type).toBe("content")
-    expect(content.from).toBe(2)
-    expect(content.to).toBe(6)
-    expect(closer.type).toBe("hidden")
-    expect(closer.from).toBe(6)
-    expect(closer.to).toBe(8)
+    const decorationRanges = result.content[0].decorationRanges
+    expect(decorationRanges).toHaveLength(3)
+    expect(decorationRanges[0]).toEqual({ type: "hidden", from: 0, to: 2 })
+    expect(decorationRanges[1]).toEqual({
+      type: "content",
+      from: 2,
+      to: 6,
+      className: "md-strong",
+    })
+    expect(decorationRanges[2]).toEqual({ type: "hidden", from: 6, to: 8 })
   })
 
   it("parses italic", () => {
@@ -46,32 +45,32 @@ describe("parseMarkdown", () => {
     const result = parseMarkdown("^^highlight^^")
     expect(result.content).toHaveLength(1)
     expect(result.content[0].type).toBe("highlight")
-    const [opener, content, closer] = result.content[0].delimiters
-    expect(opener.type).toBe("hidden")
-    expect(opener.from).toBe(0)
-    expect(opener.to).toBe(2)
-    expect(content.type).toBe("content")
-    expect(content.from).toBe(2)
-    expect(content.to).toBe(11)
-    expect(closer.type).toBe("hidden")
-    expect(closer.from).toBe(11)
-    expect(closer.to).toBe(13)
+    const decorationRanges = result.content[0].decorationRanges
+    expect(decorationRanges).toHaveLength(3)
+    expect(decorationRanges[0]).toEqual({ type: "hidden", from: 0, to: 2 })
+    expect(decorationRanges[1]).toEqual({
+      type: "content",
+      from: 2,
+      to: 11,
+      className: "md-highlight",
+    })
+    expect(decorationRanges[2]).toEqual({ type: "hidden", from: 11, to: 13 })
   })
 
   it("parses link", () => {
     const result = parseMarkdown("[link](http://example.com)")
     expect(result.content).toHaveLength(1)
     expect(result.content[0].type).toBe("link")
-    const [opener, content, closer] = result.content[0].delimiters
-    expect(opener.type).toBe("hidden")
-    expect(opener.from).toBe(0)
-    expect(opener.to).toBe(1)
-    expect(content.type).toBe("content")
-    expect(content.from).toBe(1)
-    expect(content.to).toBe(5)
-    expect(closer.type).toBe("hidden")
-    expect(closer.from).toBe(5)
-    expect(closer.to).toBe(26)
+    const decorationRanges = result.content[0].decorationRanges
+    expect(decorationRanges).toHaveLength(3)
+    expect(decorationRanges[0]).toEqual({ type: "hidden", from: 0, to: 1 })
+    expect(decorationRanges[1]).toEqual({
+      type: "content",
+      from: 1,
+      to: 5,
+      className: "md-link",
+    })
+    expect(decorationRanges[2]).toEqual({ type: "hidden", from: 5, to: 26 })
   })
 
   it("parses page reference [[Page Name]]", () => {
@@ -176,308 +175,608 @@ describe("parseMarkdown", () => {
   })
 })
 
-describe("getActiveDecorations", () => {
-  it("shows decoration when cursor outside", () => {
-    const result = getActiveDecorations("**bold**", 10)
-    expect(result.content).toHaveLength(1)
-  })
-
-  it("hides decoration when cursor inside", () => {
-    const result = getActiveDecorations("**bold**", 3)
+describe("unmatched delimiters", () => {
+  it("unmatched bold opening only", () => {
+    const result = parseMarkdown("**bold")
     expect(result.content).toHaveLength(0)
   })
 
-  it("shows decoration when cursor at start (before delimiter)", () => {
-    const result = getActiveDecorations("**bold**", 0)
-    expect(result.content).toHaveLength(1)
+  it("unmatched bold closing only", () => {
+    const result = parseMarkdown("bold**")
+    expect(result.content).toHaveLength(0)
   })
 
-  it("shows highlight when cursor outside", () => {
-    const result = getActiveDecorations("^^highlight^^", 13)
-    expect(result.content).toHaveLength(1)
+  it("unmatched italic opening only", () => {
+    const result = parseMarkdown("*text")
+    expect(result.content).toHaveLength(0)
   })
 
-  it("hides highlight when cursor inside", () => {
-    const result = getActiveDecorations("^^highlight^^", 5)
+  it("unmatched strikethrough only", () => {
+    const result = parseMarkdown("~~strike")
     expect(result.content).toHaveLength(0)
   })
 
-  it("shows page ref when cursor outside", () => {
-    const result = getActiveDecorations("[[Page]]", 10)
-    expect(result.content).toHaveLength(1)
-    expect(result.content[0].type).toBe("page-ref")
+  it("unmatched code opening only", () => {
+    const result = parseMarkdown("`code")
+    expect(result.content).toHaveLength(0)
   })
 
-  it("hides page ref when cursor inside", () => {
-    const result = getActiveDecorations("[[Page]]", 5)
+  it("unmatched highlight opening only", () => {
+    const result = parseMarkdown("^^highlight")
     expect(result.content).toHaveLength(0)
   })
 
-  it("shows block ref when cursor outside", () => {
-    const result = getActiveDecorations("((block-id))", 14)
-    expect(result.content).toHaveLength(1)
-    expect(result.content[0].type).toBe("block-ref")
+  it("unmatched page ref opening only", () => {
+    const result = parseMarkdown("[[Page")
+    expect(result.content).toHaveLength(0)
   })
 
-  it("hides block ref when cursor inside", () => {
-    const result = getActiveDecorations("((block-id))", 5)
+  it("unmatched block ref opening only", () => {
+    const result = parseMarkdown("((block")
     expect(result.content).toHaveLength(0)
   })
 
-  it("shows tag when cursor outside", () => {
-    const result = getActiveDecorations("#tag", 5)
-    expect(result.content).toHaveLength(1)
-    expect(result.content[0].type).toBe("tag")
+  it("unmatched tag no content", () => {
+    const result = parseMarkdown("#")
+    expect(result.content).toHaveLength(0)
   })
 
-  it("hides tag when cursor inside", () => {
-    const result = getActiveDecorations("#tag", 2)
+  it("multiple unmatched delimiters", () => {
+    const result = parseMarkdown("**bold and *italic")
     expect(result.content).toHaveLength(0)
   })
+})
 
-  it("shows decoration when cursor just after token", () => {
-    const result = getActiveDecorations("**bold** text", 9)
-    expect(result.content).toHaveLength(1)
+describe("nested malformed tokens", () => {
+  it("italic containing unmatched bold - parses partial", () => {
+    const result = parseMarkdown("*text **bold*")
+    expect(result.content.length).toBeGreaterThanOrEqual(0)
   })
 
-  describe("unmatched delimiters", () => {
-    it("unmatched bold opening only", () => {
-      const result = parseMarkdown("**bold")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched bold closing only", () => {
-      const result = parseMarkdown("bold**")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched italic opening only", () => {
-      const result = parseMarkdown("*text")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched strikethrough only", () => {
-      const result = parseMarkdown("~~strike")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched code opening only", () => {
-      const result = parseMarkdown("`code")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched highlight opening only", () => {
-      const result = parseMarkdown("^^highlight")
-      expect(result.content).toHaveLength(0)
-    })
-
-    it("unmatched page ref opening only", () => {
-      const result = parseMarkdown("[[Page")
-      expect(result.content).toHaveLength(0)
-    })
+  it("bold containing unmatched italic - parses partial", () => {
+    const result = parseMarkdown("**text *italic")
+    expect(result.content.length).toBeGreaterThanOrEqual(0)
+  })
 
-    it("unmatched block ref opening only", () => {
-      const result = parseMarkdown("((block")
-      expect(result.content).toHaveLength(0)
-    })
+  it("strikethrough containing unmatched bold - parses partial", () => {
+    const result = parseMarkdown("~~text **bold~~")
+    expect(result.content.length).toBeGreaterThanOrEqual(0)
+  })
 
-    it("unmatched tag no content", () => {
-      const result = parseMarkdown("#")
-      expect(result.content).toHaveLength(0)
-    })
+  it("nested triple delimiters", () => {
+    const result = parseMarkdown("***text***")
+    expect(result.content.length).toBeGreaterThan(0)
+  })
 
-    it("multiple unmatched delimiters", () => {
-      const result = parseMarkdown("**bold and *italic")
-      expect(result.content).toHaveLength(0)
-    })
+  it("code inside bold", () => {
+    const result = parseMarkdown("**`code`**")
+    expect(result.content).toHaveLength(2)
   })
 
-  describe("nested malformed tokens", () => {
-    it("italic containing unmatched bold - parses partial", () => {
-      const result = parseMarkdown("*text **bold*")
-      expect(result.content.length).toBeGreaterThanOrEqual(0)
-    })
+  it("link inside bold", () => {
+    const result = parseMarkdown("**[text](url)**")
+    expect(result.content).toHaveLength(2)
+  })
+})
 
-    it("bold containing unmatched italic - parses partial", () => {
-      const result = parseMarkdown("**text *italic")
-      expect(result.content.length).toBeGreaterThanOrEqual(0)
-    })
+describe("incomplete patterns at end of text", () => {
+  it("incomplete page ref at end", () => {
+    const result = parseMarkdown("text [[Page")
+    expect(result.content).toHaveLength(0)
+  })
 
-    it("strikethrough containing unmatched bold - parses partial", () => {
-      const result = parseMarkdown("~~text **bold~~")
-      expect(result.content.length).toBeGreaterThanOrEqual(0)
-    })
+  it("incomplete block ref at end", () => {
+    const result = parseMarkdown("text ((block")
+    expect(result.content).toHaveLength(0)
+  })
 
-    it("nested triple delimiters", () => {
-      const result = parseMarkdown("***text***")
-      expect(result.content.length).toBeGreaterThan(0)
-    })
+  it("incomplete highlight at end", () => {
+    const result = parseMarkdown("text ^^high")
+    expect(result.content).toHaveLength(0)
+  })
 
-    it("code inside bold", () => {
-      const result = parseMarkdown("**`code`**")
-      expect(result.content).toHaveLength(2)
-    })
+  it("tag at end of word not at boundary", () => {
+    const result = parseMarkdown("word#tag")
+    expect(result.content).toHaveLength(0)
+  })
 
-    it("link inside bold", () => {
-      const result = parseMarkdown("**[text](url)**")
-      expect(result.content).toHaveLength(2)
-    })
+  it("tag at boundary", () => {
+    const result = parseMarkdown("word #tag")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("tag")
   })
+})
 
-  describe("incomplete patterns at end of text", () => {
-    it("incomplete page ref at end", () => {
-      const result = parseMarkdown("text [[Page")
-      expect(result.content).toHaveLength(0)
-    })
+describe("whitespace variations", () => {
+  it("multiple spaces before tag", () => {
+    const result = parseMarkdown("text  #tag")
+    expect(result.content).toHaveLength(1)
+  })
 
-    it("incomplete block ref at end", () => {
-      const result = parseMarkdown("text ((block")
-      expect(result.content).toHaveLength(0)
-    })
+  it("tab before tag", () => {
+    const result = parseMarkdown("text\t#tag")
+    expect(result.content).toHaveLength(1)
+  })
 
-    it("incomplete highlight at end", () => {
-      const result = parseMarkdown("text ^^high")
-      expect(result.content).toHaveLength(0)
-    })
+  it("newlines in page ref - Lezer allows but content split", () => {
+    const result = parseMarkdown("[[Page\nName]]")
+    expect(result.content.length).toBeGreaterThanOrEqual(0)
+  })
+})
 
-    it("tag at end of word not at boundary", () => {
-      const result = parseMarkdown("word#tag")
-      expect(result.content).toHaveLength(0)
-    })
+describe("block decorations", () => {
+  it("parses heading level 1", () => {
+    const result = parseMarkdown("# Hello World")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("heading")
+    expect(result.blocks[0].metadata?.level).toBe(1)
+    expect(result.blocks[0].decorationRanges).toHaveLength(2)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 1,
+      className: "md-heading-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 2,
+      to: 13,
+      className: "md-heading-content",
+    })
+  })
+
+  it("parses heading level 3", () => {
+    const result = parseMarkdown("### Title")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("heading")
+    expect(result.blocks[0].metadata?.level).toBe(3)
+    expect(result.blocks[0].decorationRanges).toHaveLength(2)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 3,
+      className: "md-heading-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 4,
+      to: 9,
+      className: "md-heading-content",
+    })
+  })
+
+  it("parses task TODO", () => {
+    const result = parseMarkdown("TODO buy milk")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("task")
+    expect(result.blocks[0].decorationRanges).toHaveLength(2)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 4,
+      className: "md-task-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 5,
+      to: 13,
+      className: "md-task-content",
+    })
+    expect(result.blocks[0].metadata?.taskState).toBe("TODO")
+  })
+
+  it("parses task with priority", () => {
+    const result = parseMarkdown("TODO buy milk [#A]")
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].type).toBe("task")
+    expect(result.blocks[0].decorationRanges).toHaveLength(2)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 4,
+      className: "md-task-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 5,
+      to: 13,
+      className: "md-task-content",
+    })
+    expect(result.blocks[0].metadata?.taskState).toBe("TODO")
+    expect(result.blocks[1].type).toBe("priority")
+    expect(result.blocks[1].decorationRanges).toHaveLength(2)
+    expect(result.blocks[1].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 13,
+      to: 17,
+      className: "md-priority-marker",
+    })
+    expect(result.blocks[1].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 17,
+      to: 18,
+      className: "md-priority-content",
+    })
+    expect(result.blocks[1].metadata?.priority).toBe("A")
+  })
+
+  it("parses priority only", () => {
+    const result = parseMarkdown("[#B] high priority")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("priority")
+    expect(result.blocks[0].decorationRanges).toHaveLength(2)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 4,
+      className: "md-priority-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 5,
+      to: 18,
+      className: "md-priority-content",
+    })
+    expect(result.blocks[0].metadata?.priority).toBe("B")
+  })
+
+  it("parses blockquote", () => {
+    const result = parseMarkdown("> Quote text")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("blockquote")
+  })
+
+  it("parses callout note", () => {
+    const result = parseMarkdown("> [!NOTE] important info")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("callout")
+    expect(result.blocks[0].metadata?.calloutType).toBe("NOTE")
+  })
+
+  it("parses callout warning", () => {
+    const result = parseMarkdown("> [!WARNING] be careful")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("callout")
+    expect(result.blocks[0].metadata?.calloutType).toBe("WARNING")
+  })
+
+  it("parses date", () => {
+    const result = parseMarkdown("📅 2024-01-15")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("date")
+    expect(result.blocks[0].metadata?.date).toBe("2024-01-15")
+  })
+
+  it("returns empty blocks for plain text", () => {
+    const result = parseMarkdown("Just some plain text")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("handles all task states", () => {
+    const states = [
+      "TODO",
+      "DOING",
+      "DONE",
+      "LATER",
+      "NOW",
+      "WAITING",
+      "CANCELLED",
+    ]
+    for (const state of states) {
+      const result = parseMarkdown(`${state} task`)
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].metadata?.taskState).toBe(state)
+    }
+  })
+})
 
-    it("tag at boundary", () => {
-      const result = parseMarkdown("word #tag")
-      expect(result.content).toHaveLength(1)
-      expect(result.content[0].type).toBe("tag")
+describe("leading whitespace handling", () => {
+  it("heading with leading spaces positions marker correctly", () => {
+    const result = parseMarkdown("  # Title")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 3,
+      className: "md-heading-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 4,
+      to: 9,
+      className: "md-heading-content",
+    })
+  })
+
+  it("task with leading spaces positions marker correctly", () => {
+    const result = parseMarkdown("  TODO buy milk")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 6,
+      className: "md-task-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 7,
+      to: 15,
+      className: "md-task-content",
+    })
+  })
+
+  it("priority with leading spaces positions marker correctly", () => {
+    const result = parseMarkdown("  [#A] high priority")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 6,
+      className: "md-priority-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 7,
+      to: 20,
+      className: "md-priority-content",
+    })
+  })
+
+  it("task with priority and leading spaces", () => {
+    const result = parseMarkdown("  TODO buy milk [#A]")
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].type).toBe("task")
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 6,
+      className: "md-task-marker",
+    })
+    expect(result.blocks[1].type).toBe("priority")
+    expect(result.blocks[1].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 15,
+      to: 19,
+      className: "md-priority-marker",
+    })
+  })
+
+  it("blockquote with leading spaces", () => {
+    const result = parseMarkdown("  > Quote text")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 3,
+      className: "md-blockquote-marker",
+    })
+  })
+
+  it("callout with leading spaces", () => {
+    const result = parseMarkdown("  > [!NOTE] important")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 11,
+      className: "md-callout-marker",
+    })
+  })
+
+  it("date with leading spaces", () => {
+    const result = parseMarkdown("  📅 2024-01-15")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 2,
+      to: 4,
+      className: "md-date-marker",
     })
   })
+})
 
-  describe("whitespace variations", () => {
-    it("multiple spaces before tag", () => {
-      const result = parseMarkdown("text  #tag")
+describe("whitespace variations in task rules", () => {
+  it("task with multiple spaces after keyword", () => {
+    const result = parseMarkdown("TODO    buy milk")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 4,
+      className: "md-task-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 8,
+      to: 16,
+      className: "md-task-content",
+    })
+  })
+
+  it("task with priority and multiple spaces before priority", () => {
+    const result = parseMarkdown("TODO buy milk    [#A]")
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].type).toBe("task")
+    expect(result.blocks[0].decorationRanges[1].to).toBe(13)
+    expect(result.blocks[1].type).toBe("priority")
+    expect(result.blocks[1].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 13,
+      to: 17,
+      className: "md-priority-marker",
+    })
+  })
+
+  it("priority with multiple spaces after marker", () => {
+    const result = parseMarkdown("[#B]    high priority")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].decorationRanges[0]).toEqual({
+      type: "content",
+      from: 0,
+      to: 4,
+      className: "md-priority-marker",
+    })
+    expect(result.blocks[0].decorationRanges[1]).toEqual({
+      type: "content",
+      from: 8,
+      to: 21,
+      className: "md-priority-content",
+    })
+  })
+
+  describe("multi-line block positions", () => {
+    it("heading on second line has correct absolute positions", () => {
+      const result = parseMarkdown("first line\n# Title")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].decorationRanges[0]).toEqual({
+        type: "content",
+        from: 11,
+        to: 12,
+        className: "md-heading-marker",
+      })
+      expect(result.blocks[0].decorationRanges[1]).toEqual({
+        type: "content",
+        from: 13,
+        to: 18,
+        className: "md-heading-content",
+      })
+    })
+
+    it("task on second line has correct absolute positions", () => {
+      const result = parseMarkdown("first line\nTODO buy milk")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].decorationRanges[0]).toEqual({
+        type: "content",
+        from: 11,
+        to: 15,
+        className: "md-task-marker",
+      })
+      expect(result.blocks[0].decorationRanges[1]).toEqual({
+        type: "content",
+        from: 16,
+        to: 24,
+        className: "md-task-content",
+      })
+    })
+
+    it("inline tokens on first line are unaffected by second line", () => {
+      const result = parseMarkdown("**bold**\nplain text")
       expect(result.content).toHaveLength(1)
+      expect(result.content[0].decorationRanges[0]).toEqual({
+        type: "hidden",
+        from: 0,
+        to: 2,
+      })
     })
 
-    it("tab before tag", () => {
-      const result = parseMarkdown("text\t#tag")
+    it("tag on second line is parsed", () => {
+      const result = parseMarkdown("some text\n#tag")
       expect(result.content).toHaveLength(1)
+      expect(result.content[0].type).toBe("tag")
     })
 
-    it("newlines in page ref - Lezer allows but content split", () => {
-      const result = parseMarkdown("[[Page\nName]]")
-      expect(result.content.length).toBeGreaterThanOrEqual(0)
-    })
-  })
-
-  describe("block decorations", () => {
-    it("parses heading level 1", () => {
-      const result = parseMarkdown("# Hello World")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("heading")
-      expect(result.blocks[0].metadata?.level).toBe(1)
-      expect(result.blocks[0].markerRange).toEqual([0, 2])
-      expect(result.blocks[0].contentRange).toEqual([2, 13])
+    it("tag after blank line is parsed", () => {
+      const result = parseMarkdown("some text\n\n#tag more text")
+      expect(result.content).toHaveLength(1)
+      expect(result.content[0].type).toBe("tag")
     })
 
-    it("parses heading level 3", () => {
-      const result = parseMarkdown("### Title")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("heading")
-      expect(result.blocks[0].metadata?.level).toBe(3)
+    it("property on second line is parsed", () => {
+      const result = parseMarkdown("some text\nkey:: value")
+      expect(result.properties).toHaveLength(1)
+      expect(result.properties[0].key).toBe("key")
     })
 
-    it("parses task TODO", () => {
-      const result = parseMarkdown("TODO buy milk")
+    it("date on second line is parsed", () => {
+      const result = parseMarkdown("some text\n📅 2024-01-15")
       expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("task")
-      expect(result.blocks[0].metadata?.taskState).toBe("TODO")
-    })
-
-    it("parses task with priority", () => {
-      const result = parseMarkdown("TODO buy milk [#A]")
-      expect(result.blocks).toHaveLength(2)
-      expect(result.blocks[0].type).toBe("task")
-      expect(result.blocks[0].metadata?.taskState).toBe("TODO")
-      expect(result.blocks[1].type).toBe("priority")
-      expect(result.blocks[1].metadata?.priority).toBe("A")
+      expect(result.blocks[0].type).toBe("date")
     })
+  })
+})
 
-    it("parses priority only", () => {
-      const result = parseMarkdown("[#B] high priority")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("priority")
-      expect(result.blocks[0].metadata?.priority).toBe("B")
+describe("wrapperAttrs output", () => {
+  it("bold token has wrapperAttrs", () => {
+    const result = parseMarkdown("**bold**")
+    expect(result.content[0].wrapperAttrs).toEqual({
+      "data-md-type": "strong",
     })
+  })
 
-    it("parses blockquote", () => {
-      const result = parseMarkdown("> Quote text")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("blockquote")
+  it("page ref token has wrapperAttrs", () => {
+    const result = parseMarkdown("[[Page Name]]")
+    const ref = result.content[0] as PageRefDecoration
+    expect(ref.wrapperAttrs).toEqual({
+      "data-page-name": "Page Name",
+      role: "link",
+      tabindex: "0",
     })
+  })
 
-    it("parses callout note", () => {
-      const result = parseMarkdown("> [!NOTE] important info")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("callout")
-      expect(result.blocks[0].metadata?.calloutType).toBe("NOTE")
+  it("page ref with alias includes data-alias", () => {
+    const result = parseMarkdown("[[Page Name|Alias]]")
+    const ref = result.content[0] as PageRefDecoration
+    expect(ref.wrapperAttrs).toEqual({
+      "data-page-name": "Page Name",
+      "data-alias": "Alias",
+      role: "link",
+      tabindex: "0",
     })
+  })
 
-    it("parses callout warning", () => {
-      const result = parseMarkdown("> [!WARNING] be careful")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("callout")
-      expect(result.blocks[0].metadata?.calloutType).toBe("WARNING")
-    })
+  it("block ref token has wrapperAttrs", () => {
+    const result = parseMarkdown("((block-id))")
+    const ref = result.content[0] as BlockRefDecoration
+    expect(ref.wrapperAttrs).toEqual({ "data-block-id": "block-id" })
+  })
 
-    it("parses date", () => {
-      const result = parseMarkdown("📅 2024-01-15")
-      expect(result.blocks).toHaveLength(1)
-      expect(result.blocks[0].type).toBe("date")
-      expect(result.blocks[0].metadata?.date).toBe("2024-01-15")
-    })
+  it("tag token has wrapperAttrs", () => {
+    const result = parseMarkdown("#tag")
+    const tag = result.content[0] as TagDecoration
+    expect(tag.wrapperAttrs).toEqual({ "data-tag": "tag" })
+  })
 
-    it("returns empty blocks for plain text", () => {
-      const result = parseMarkdown("Just some plain text")
-      expect(result.blocks).toHaveLength(0)
+  it("highlight token has wrapperAttrs", () => {
+    const result = parseMarkdown("^^highlight^^")
+    expect(result.content[0].wrapperAttrs).toEqual({
+      "data-md-type": "highlight",
     })
+  })
 
-    it("handles all task states", () => {
-      const states = [
-        "TODO",
-        "DOING",
-        "DONE",
-        "LATER",
-        "NOW",
-        "WAITING",
-        "CANCELLED",
-      ]
-      for (const state of states) {
-        const result = parseMarkdown(`${state} task`)
-        expect(result.blocks).toHaveLength(1)
-        expect(result.blocks[0].metadata?.taskState).toBe(state)
-      }
+  it("heading block has wrapperAttrs", () => {
+    const result = parseMarkdown("# Title")
+    expect(result.blocks[0].wrapperAttrs).toEqual({
+      "data-heading-level": "1",
     })
   })
 
-  describe("getActiveDecorations blocks", () => {
-    it("hides block markers when cursor inside block", () => {
-      const result = getActiveDecorations("TODO buy milk", 5)
-      expect(result.blocks).toHaveLength(0)
+  it("task block has wrapperAttrs", () => {
+    const result = parseMarkdown("TODO buy milk")
+    expect(result.blocks[0].wrapperAttrs).toEqual({
+      "data-task-state": "TODO",
     })
+  })
 
-    it("shows block markers when cursor at start", () => {
-      const result = getActiveDecorations("TODO buy milk", 0)
-      expect(result.blocks).toHaveLength(1)
+  it("task with priority has independent wrapperAttrs", () => {
+    const result = parseMarkdown("TODO buy milk [#A]")
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].wrapperAttrs).toEqual({
+      "data-task-state": "TODO",
     })
+    expect(result.blocks[1].wrapperAttrs).toEqual({ "data-priority": "A" })
+  })
 
-    it("shows heading when cursor at start", () => {
-      const result = getActiveDecorations("# Title", 0)
-      expect(result.blocks).toHaveLength(1)
+  it("callout block has wrapperAttrs", () => {
+    const result = parseMarkdown("> [!NOTE] important info")
+    expect(result.blocks[0].wrapperAttrs).toEqual({
+      "data-callout-type": "NOTE",
     })
+  })
 
-    it("hides heading when cursor inside", () => {
-      const result = getActiveDecorations("# Title", 3)
-      expect(result.blocks).toHaveLength(0)
+  it("date block has wrapperAttrs", () => {
+    const result = parseMarkdown("📅 2024-01-15")
+    expect(result.blocks[0].wrapperAttrs).toEqual({
+      "data-date": "2024-01-15",
     })
   })
 })

+ 9 - 14
packages/editor/src/lib/content-model.ts

@@ -2,7 +2,7 @@
  * Content model conversion functions.
  *
  * Converts between raw markdown string and ProseMirror document nodes.
- * Each newline becomes a separate paragraph (not a hard_break).
+ * Each block is exactly one <p>; newlines within a block become hard_breaks.
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
@@ -10,7 +10,7 @@ import { schema } from "./schema"
 
 /**
  * Convert markdown string to ProseMirror document.
- * Each line becomes a separate paragraph.
+ * The entire block becomes one paragraph; each newline becomes a hard_break.
  * @param content - Raw markdown string
  * @returns ProseMirror document node
  */
@@ -24,22 +24,17 @@ export function contentToDoc(content: string): ProsemirrorNode {
   const children: ProsemirrorNode[] = []
 
   for (let i = 0; i < lines.length; i++) {
+    if (i > 0) {
+      children.push(schema.nodes.hard_break.create())
+    }
     const lineText = lines[i]
-
-    let paragraphContent: ProsemirrorNode | null = null
-
-    if (lineText && lineText.length > 0) {
-      paragraphContent = schema.nodes.paragraph.create(null, [
-        schema.text(lineText),
-      ])
-    } else {
-      paragraphContent = schema.nodes.paragraph.create()
+    if (lineText.length > 0) {
+      children.push(schema.text(lineText))
     }
-
-    children.push(paragraphContent)
   }
 
-  return schema.nodes.doc.create(null, children)
+  const paragraph = schema.nodes.paragraph.create(null, children)
+  return schema.nodes.doc.create(null, [paragraph])
 }
 
 /**

+ 38 - 544
packages/editor/src/lib/markdown-parser.ts

@@ -1,557 +1,51 @@
 /**
- * Markdown parser using Lezer.
+ * Markdown parser — public API.
  *
- * Parses markdown text into structured decoration data for:
- * - Inline tokens (bold, italic, code, strikethrough, links)
- * - Reference tokens (page refs, block refs, tags, highlights)
- * - Block-level decorations (headings, tasks, priorities, blockquotes, callouts, dates)
- * - Property lines (key:: value)
+ * Re-exports types from markdown-rules.ts and provides:
+ * - parseMarkdown(): full decoration extraction
  */
 
-import type { SyntaxNode, TreeCursor } from "@lezer/common"
 import { GFM, parser } from "@lezer/markdown"
 import { createMarkdownExtensions } from "./markdown-extensions"
-
-interface LezerNode {
-  type: { name: string }
-  from: number
-  to: number
-  tree: { slice(from: number, to: number): string }
-  cursor: () => TreeCursor
-}
-
-export interface DelimiterDecoration {
-  type: "hidden" | "content"
-  className?: string
-  from: number
-  to: number
-}
-
-export interface TokenDecoration {
-  type: string
-  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
-}
-
-export interface PropertyInfo {
-  key: string
-  value: string
-  keyRange: [number, number]
-  delimiterRange: [number, number]
-  valueRange: [number, number]
-}
-
-export interface PageRefDecoration {
-  type: "page-ref"
-  pageName: string
-  alias?: string
-  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
-}
-
-export interface BlockRefDecoration {
-  type: "block-ref"
-  blockId: string
-  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
-}
-
-export interface TagDecoration {
-  type: "tag"
-  tag: string
-  delimiters: [DelimiterDecoration, DelimiterDecoration]
-}
-
-export interface HighlightDecoration {
-  type: "highlight"
-  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
-}
-
-export interface ParsedDecorations {
-  content: (
-    | TokenDecoration
-    | PageRefDecoration
-    | BlockRefDecoration
-    | TagDecoration
-    | HighlightDecoration
-  )[]
-  properties: PropertyInfo[]
-  blocks: BlockDecoration[]
-}
-
-export type TaskState =
-  | "TODO"
-  | "DOING"
-  | "DONE"
-  | "LATER"
-  | "NOW"
-  | "WAITING"
-  | "CANCELLED"
-export type PriorityLevel = "A" | "B" | "C"
-export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
-
-export interface BlockDecoration {
-  type: "heading" | "task" | "priority" | "blockquote" | "callout" | "date"
-  markerRange: [number, number]
-  contentRange: [number, number]
-  metadata?: {
-    level?: number
-    taskState?: TaskState
-    priority?: PriorityLevel
-    calloutType?: CalloutType
-    date?: string
-  }
-}
+import {
+  type BlockDecoration,
+  type BlockRefDecoration,
+  type CalloutType,
+  type DecorationRange,
+  type HighlightDecoration,
+  MarkdownRuleEngine,
+  type PageRefDecoration,
+  type ParsedDecorations,
+  type PriorityLevel,
+  type PropertyInfo,
+  type TagDecoration,
+  type TaskState,
+  type TokenDecoration,
+} from "./markdown-rules"
+
+export type {
+  BlockDecoration,
+  BlockRefDecoration,
+  CalloutType,
+  DecorationRange,
+  HighlightDecoration,
+  PageRefDecoration,
+  ParsedDecorations,
+  PriorityLevel,
+  PropertyInfo,
+  TagDecoration,
+  TaskState,
+  TokenDecoration,
+}
+export { MarkdownRuleEngine }
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
 
-const TYPE_MAP: Record<string, string> = {
-  StrongEmphasis: "strong",
-  Emphasis: "em",
-  InlineCode: "code",
-  Strikethrough: "strike",
-  Link: "link",
-}
-
-const DELIMITER_MARK: Record<string, string> = {
-  StrongEmphasis: "EmphasisMark",
-  Emphasis: "EmphasisMark",
-  InlineCode: "CodeMark",
-  Strikethrough: "StrikethroughMark",
-}
-
-const STRUCTURED_TOKENS = new Set([
-  "StrongEmphasis",
-  "Emphasis",
-  "InlineCode",
-  "Strikethrough",
-  "Link",
-])
-
-const LEAF_TOKENS = new Set(["PageRef", "BlockRef", "Tag", "Highlight"])
-
-const TASK_STATES = new Set([
-  "TODO",
-  "DOING",
-  "DONE",
-  "LATER",
-  "NOW",
-  "WAITING",
-  "CANCELLED",
-])
-const CALLOUT_TYPES = new Set(["NOTE", "WARNING", "TIP", "DANGER", "INFO"])
-
-function detectBlockDecorations(text: string): BlockDecoration[] {
-  const blocks: BlockDecoration[] = []
-  const trimmed = text.trimStart()
-
-  if (trimmed.startsWith("#")) {
-    const match = trimmed.match(/^(#{1,6})\s+(.*)$/)
-    if (match) {
-      const level = match[1].length
-      const contentStart = match[1].length + 1
-      blocks.push({
-        type: "heading",
-        markerRange: [0, contentStart],
-        contentRange: [contentStart, text.length],
-        metadata: { level },
-      })
-    }
-  } else if (trimmed.startsWith(">")) {
-    const calloutMatch = trimmed.match(/^>\s*\[!([A-Z]+)\]\s*(.*)$/)
-    if (calloutMatch) {
-      const calloutType = calloutMatch[1] as CalloutType
-      if (CALLOUT_TYPES.has(calloutType)) {
-        const markerEnd = calloutMatch[0].indexOf("]") + 1
-        const contentStart = calloutMatch[0].indexOf(" ", markerEnd) + 1
-        blocks.push({
-          type: "callout",
-          markerRange: [0, contentStart],
-          contentRange: [contentStart, text.length],
-          metadata: { calloutType },
-        })
-      }
-    } else {
-      const match = trimmed.match(/^>\s*(.*)$/)
-      if (match?.[1]) {
-        const markerEnd = match[0].indexOf(match[1])
-        blocks.push({
-          type: "blockquote",
-          markerRange: [0, markerEnd],
-          contentRange: [markerEnd, text.length],
-        })
-      }
-    }
-  } else if (trimmed.startsWith("📅")) {
-    const match = trimmed.match(/^📅\s*(\d{4}-\d{2}-\d{2})(.*)$/)
-    if (match) {
-      const date = match[1]
-      const afterDate = match[2]
-      const contentStart = date.length + 1
-      blocks.push({
-        type: "date",
-        markerRange: [0, contentStart],
-        contentRange: afterDate.trim()
-          ? [contentStart, text.length]
-          : [text.length, text.length],
-        metadata: { date },
-      })
-    }
-  } else {
-    const taskMatch = trimmed.match(
-      /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+(.*)$/i,
-    )
-    if (taskMatch) {
-      const taskState = taskMatch[1].toUpperCase() as TaskState
-      if (TASK_STATES.has(taskState)) {
-        const afterTask = taskMatch[2]
-        const markerEnd = taskMatch[0].length - afterTask.length
-        let priority: PriorityLevel | undefined
-        let priorityMarkerStart = -1
-        let priorityContentStart = text.length
-
-        const priorityMatch = afterTask.match(/\[#([A-C])\]\s*$/)
-        if (priorityMatch) {
-          priority = priorityMatch[1] as PriorityLevel
-          priorityMarkerStart =
-            markerEnd + afterTask.lastIndexOf(`[#${priority}]`)
-          priorityContentStart =
-            markerEnd +
-            afterTask.lastIndexOf(`[#${priority}]`) +
-            priorityMatch[0].length
-        }
-
-        blocks.push({
-          type: "task",
-          markerRange: [0, markerEnd],
-          contentRange: [
-            priorityMarkerStart > 0 ? priorityMarkerStart : text.length,
-            text.length,
-          ],
-          metadata: { taskState },
-        })
-
-        if (priority) {
-          blocks.push({
-            type: "priority",
-            markerRange: [priorityMarkerStart, priorityMarkerStart + 4],
-            contentRange: [priorityContentStart, text.length],
-            metadata: { priority },
-          })
-        }
-      }
-    } else {
-      const priorityOnlyMatch = trimmed.match(/^\[#([A-C])\]\s*(.*)$/)
-      if (priorityOnlyMatch) {
-        const priority = priorityOnlyMatch[1] as PriorityLevel
-        const markerEnd =
-          priorityOnlyMatch[0].length - priorityOnlyMatch[2].length
-        blocks.push({
-          type: "priority",
-          markerRange: [0, markerEnd],
-          contentRange: [markerEnd, text.length],
-          metadata: { priority },
-        })
-      }
-    }
-  }
-
-  return blocks
-}
-
-function findChildByTypeName(
-  node: LezerNode,
-  typeName: string,
-): LezerNode | null {
-  const cursor = node.cursor()
-  if (!cursor.firstChild()) return null
-  do {
-    if (cursor.node.type.name === typeName) {
-      const child = cursor.node as unknown as LezerNode
-      cursor.parent()
-      return child
-    }
-  } while (cursor.nextSibling())
-  cursor.parent()
-  return null
-}
-
-function findChildrenByTypeName(
-  node: LezerNode,
-  typeName: string,
-): LezerNode[] {
-  const results: LezerNode[] = []
-  const cursor = node.cursor()
-  if (!cursor.firstChild()) return results
-  do {
-    if (cursor.node.type.name === typeName) {
-      results.push(cursor.node as unknown as LezerNode)
-    }
-  } while (cursor.nextSibling())
-  cursor.parent()
-  return results
-}
-
-function getEmphasisDecorations(
-  typeName: string,
-  node: LezerNode,
-): TokenDecoration | null {
-  const contentType = TYPE_MAP[typeName]
-  const delimiterMarkName = DELIMITER_MARK[typeName]
-  if (!contentType || !delimiterMarkName) return null
-
-  const delimiterMarks = findChildrenByTypeName(node, delimiterMarkName)
-  if (delimiterMarks.length < 2) return null
-
-  const opener = delimiterMarks[0]
-  const closer = delimiterMarks[delimiterMarks.length - 1]
-  if (!opener || !closer) return null
-
-  return {
-    type: contentType,
-    delimiters: [
-      { type: "hidden", from: opener.from, to: opener.to },
-      {
-        type: "content",
-        from: opener.to,
-        to: closer.from,
-        className: `md-${contentType}`,
-      },
-      { type: "hidden", from: closer.from, to: closer.to },
-    ],
-  }
-}
-
-function getLinkDecorations(node: SyntaxNode): TokenDecoration | null {
-  const opener = findChildByTypeName(node, "LinkMark")
-  if (!opener) return null
-
-  const linkMarks = findChildrenByTypeName(node, "LinkMark")
-  const url = findChildByTypeName(node, "URL")
-  if (!url) return null
-
-  const closer = linkMarks[linkMarks.length - 1]
-  const textEnd = linkMarks.length >= 2 ? linkMarks[1] : opener
-  if (!closer || !textEnd) return null
-
-  return {
-    type: "link",
-    delimiters: [
-      { type: "hidden", from: opener.from, to: opener.to },
-      {
-        type: "content",
-        from: opener.to,
-        to: textEnd.from,
-        className: "md-link",
-      },
-      { type: "hidden", from: textEnd.from, to: closer.to },
-    ],
-  }
-}
-
-function getStructuredTokenDecoration(
-  typeName: string,
-  node: SyntaxNode,
-): TokenDecoration | null {
-  if (typeName === "Link") {
-    return getLinkDecorations(node)
-  }
-
-  const contentType = TYPE_MAP[typeName]
-  if (!contentType) return null
-
-  return getEmphasisDecorations(typeName, node)
-}
-
-function getLeafTokenDecoration(
-  typeName: string,
-  node: LezerNode,
-  docText: string,
-):
-  | PageRefDecoration
-  | BlockRefDecoration
-  | TagDecoration
-  | HighlightDecoration
-  | null {
-  const from = node.from
-  const to = node.to
-
-  if (typeName === "PageRef") {
-    const innerText = docText.slice(from + 2, to - 2)
-    const parts = innerText.split("|")
-    const pageName = parts[0]
-    const alias = parts[1]
-    return {
-      type: "page-ref",
-      pageName,
-      alias: alias || undefined,
-      delimiters: [
-        { type: "hidden", from, to: from + 2 },
-        {
-          type: "content",
-          from: from + 2,
-          to: to - 2,
-          className: "md-page-ref",
-        },
-        { type: "hidden", from: to - 2, to },
-      ],
-    }
-  }
-
-  if (typeName === "BlockRef") {
-    const blockId = docText.slice(from + 2, to - 2)
-    return {
-      type: "block-ref",
-      blockId,
-      delimiters: [
-        { type: "hidden", from, to: from + 2 },
-        {
-          type: "content",
-          from: from + 2,
-          to: to - 2,
-          className: "md-block-ref",
-        },
-        { type: "hidden", from: to - 2, to },
-      ],
-    }
-  }
-
-  if (typeName === "Tag") {
-    const tagText = docText.slice(from, to)
-    const tag = tagText.slice(1)
-    return {
-      type: "tag",
-      tag,
-      delimiters: [
-        { type: "hidden", from, to: from + 1 },
-        {
-          type: "content",
-          from: from + 1,
-          to,
-          className: "md-tag",
-        },
-      ],
-    }
-  }
+const defaultEngine = new MarkdownRuleEngine()
 
-  if (typeName === "Highlight") {
-    return {
-      type: "highlight",
-      delimiters: [
-        { type: "hidden", from, to: from + 2 },
-        {
-          type: "content",
-          from: from + 2,
-          to: to - 2,
-          className: "md-highlight",
-        },
-        { type: "hidden", from: to - 2, to },
-      ],
-    }
-  }
-
-  return null
-}
-
-export function parseMarkdown(docText: string): ParsedDecorations {
-  const content: ParsedDecorations["content"] = []
-  const properties: PropertyInfo[] = []
-  const blocks: BlockDecoration[] = detectBlockDecorations(docText)
-
-  if (!docText) {
-    return { content, properties, blocks }
-  }
-
-  try {
-    const tree = markdownParser.parse(docText)
-
-    function walkTree(cursor: TreeCursor): void {
-      do {
-        const node = cursor.node
-        const typeName = node.type.name
-
-        if (STRUCTURED_TOKENS.has(typeName)) {
-          const token = getStructuredTokenDecoration(typeName, node)
-          if (token) {
-            content.push(token)
-          }
-          if (cursor.firstChild()) {
-            walkTree(cursor)
-            cursor.parent()
-          }
-          continue
-        }
-
-        if (LEAF_TOKENS.has(typeName)) {
-          const token = getLeafTokenDecoration(typeName, node, docText)
-          if (token) {
-            content.push(token)
-          }
-          continue
-        }
-
-        if (cursor.firstChild()) {
-          walkTree(cursor)
-          cursor.parent()
-        }
-      } while (cursor.nextSibling())
-    }
-
-    const cursor = tree.cursor()
-    walkTree(cursor)
-  } catch {
-    // On parse error, return empty decorations so raw text displays
-  }
-
-  const propMatch = docText.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)::(.*)$/)
-  if (propMatch) {
-    const key = propMatch[1] ?? ""
-    const value = propMatch[2]?.trim() ?? ""
-    const keyEnd = key.length
-    const delimiterEnd = keyEnd + 2
-
-    properties.push({
-      key,
-      value,
-      keyRange: [0, keyEnd],
-      delimiterRange: [keyEnd, delimiterEnd],
-      valueRange: [delimiterEnd, docText.length],
-    })
-  }
-
-  return { content, properties, blocks }
-}
-
-export function getActiveDecorations(
+export function parseMarkdown(
   docText: string,
-  cursorPos: number,
+  engine: MarkdownRuleEngine = defaultEngine,
 ): ParsedDecorations {
-  const { content, properties, blocks } = parseMarkdown(docText)
-
-  const activeContent = content.filter((token) => {
-    if (token.type === "tag") {
-      const [opener, closer] = token.delimiters
-      if (cursorPos > 0 && cursorPos >= opener.from && cursorPos < closer.to) {
-        return false
-      }
-      return true
-    }
-    const [opener, , closer] = token.delimiters
-    if (cursorPos > 0 && cursorPos >= opener.from && cursorPos < closer.to) {
-      return false
-    }
-    return true
-  })
-
-  const activeBlocks = blocks.filter((_block) => {
-    if (cursorPos > 0) {
-      return false
-    }
-    return true
-  })
-
-  const activeProperties = properties
-
-  return {
-    content: activeContent,
-    properties: activeProperties,
-    blocks: activeBlocks,
-  }
+  return engine.parse(docText, markdownParser)
 }

+ 845 - 0
packages/editor/src/lib/markdown-rules.ts

@@ -0,0 +1,845 @@
+/**
+ * Rule-based markdown parser architecture.
+ *
+ * Pipeline: Lezer Parser → AST Visitor → Rule Registry → Decoration Extraction
+ *
+ * Three rule categories:
+ * - Inline AST rules (emphasis, links) — matched via Lezer node types
+ * - Leaf AST rules (tags, refs) — matched via Lezer node types
+ * - Line/block regex rules (task, heading) — matched via line patterns
+ */
+
+import type { Parser, SyntaxNode, TreeCursor } from "@lezer/common"
+
+// ── Context ──────────────────────────────────────────────────────────
+
+export interface ParseContext {
+  doc: string
+  cursorPos?: number
+}
+
+// ── Unified DecorationRange ──────────────────────────────────────────
+
+export interface DecorationRange {
+  type: "hidden" | "content"
+  from: number
+  to: number
+  className?: string
+}
+
+// ── Result types ─────────────────────────────────────────────────────
+
+/**
+ * Base interface for all parsed tokens.
+ * Each rule provides its own wrapperAttrs so the decoration builder
+ * can render without type-switching.
+ */
+export interface ParsedToken {
+  type: string
+  decorationRanges: DecorationRange[]
+  wrapperAttrs?: Record<string, string>
+}
+
+export interface TokenDecoration extends ParsedToken {
+  type: string
+}
+
+export interface PageRefDecoration extends ParsedToken {
+  type: "page-ref"
+  pageName: string
+  alias?: string
+}
+
+export interface BlockRefDecoration extends ParsedToken {
+  type: "block-ref"
+  blockId: string
+}
+
+export interface TagDecoration extends ParsedToken {
+  type: "tag"
+  tag: string
+}
+
+export interface HighlightDecoration extends ParsedToken {
+  type: "highlight"
+}
+
+export interface PropertyInfo {
+  key: string
+  value: string
+  keyRange: [number, number]
+  delimiterRange: [number, number]
+  valueRange: [number, number]
+}
+
+export type TaskState =
+  | "TODO"
+  | "DOING"
+  | "DONE"
+  | "LATER"
+  | "NOW"
+  | "WAITING"
+  | "CANCELLED"
+export type PriorityLevel = "A" | "B" | "C"
+export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
+
+export interface BlockDecoration extends ParsedToken {
+  type: "heading" | "task" | "priority" | "blockquote" | "callout" | "date"
+  metadata?: {
+    level?: number
+    taskState?: TaskState
+    priority?: PriorityLevel
+    calloutType?: CalloutType
+    date?: string
+  }
+}
+
+export interface ParsedDecorations {
+  content: (
+    | TokenDecoration
+    | PageRefDecoration
+    | BlockRefDecoration
+    | TagDecoration
+    | HighlightDecoration
+  )[]
+  properties: PropertyInfo[]
+  blocks: BlockDecoration[]
+}
+
+// ── Tree helpers ─────────────────────────────────────────────────────
+
+export function findChildByTypeName(
+  node: SyntaxNode,
+  typeName: string,
+): SyntaxNode | null {
+  const cursor = node.cursor()
+  if (!cursor.firstChild()) return null
+  do {
+    if (cursor.node.type.name === typeName) {
+      const child = cursor.node
+      cursor.parent()
+      return child
+    }
+  } while (cursor.nextSibling())
+  cursor.parent()
+  return null
+}
+
+export function findChildrenByTypeName(
+  node: SyntaxNode,
+  typeName: string,
+): SyntaxNode[] {
+  const results: SyntaxNode[] = []
+  const cursor = node.cursor()
+  if (!cursor.firstChild()) return results
+  do {
+    if (cursor.node.type.name === typeName) {
+      results.push(cursor.node)
+    }
+  } while (cursor.nextSibling())
+  cursor.parent()
+  return results
+}
+
+// ── Rule interface ───────────────────────────────────────────────────
+
+export interface MarkdownRule<T = unknown> {
+  name: string
+  nodeTypes?: string[]
+  match(node: SyntaxNode, ctx: ParseContext): boolean
+  run(node: SyntaxNode, ctx: ParseContext): T | null
+}
+
+// ── Inline rule factories ────────────────────────────────────────────
+
+export function createDelimitedRule(config: {
+  node: string
+  mark: string
+  type: string
+  className: string
+}): MarkdownRule<TokenDecoration> {
+  return {
+    name: config.type,
+    nodeTypes: [config.node],
+    match(node) {
+      return node.type.name === config.node
+    },
+    run(node) {
+      const marks = findChildrenByTypeName(node, config.mark)
+      if (marks.length < 2) return null
+      const opener = marks[0]
+      const closer = marks[marks.length - 1]
+      return {
+        type: config.type,
+        wrapperAttrs: {
+          "data-md-type": config.type,
+        },
+        decorationRanges: [
+          { type: "hidden", from: opener.from, to: opener.to },
+          {
+            type: "content",
+            from: opener.to,
+            to: closer.from,
+            className: config.className,
+          },
+          { type: "hidden", from: closer.from, to: closer.to },
+        ],
+      }
+    },
+  }
+}
+
+export function createLinkRule(): MarkdownRule<TokenDecoration> {
+  return {
+    name: "link",
+    nodeTypes: ["Link"],
+    match(node) {
+      return node.type.name === "Link"
+    },
+    run(node) {
+      const linkMarks = findChildrenByTypeName(node, "LinkMark")
+      if (linkMarks.length < 2) return null
+      const url = findChildByTypeName(node, "URL")
+      if (!url) return null
+      const opener = linkMarks[0]
+      const closer = linkMarks[linkMarks.length - 1]
+      const textEnd = linkMarks[1]
+      return {
+        type: "link",
+        wrapperAttrs: {
+          "data-md-type": "link",
+        },
+        decorationRanges: [
+          { type: "hidden", from: opener.from, to: opener.to },
+          {
+            type: "content",
+            from: opener.to,
+            to: textEnd.from,
+            className: "md-link",
+          },
+          { type: "hidden", from: textEnd.from, to: closer.to },
+        ],
+      }
+    },
+  }
+}
+
+export function createPageRefRule(): MarkdownRule<PageRefDecoration> {
+  return {
+    name: "page-ref",
+    nodeTypes: ["PageRef"],
+    match(node) {
+      return node.type.name === "PageRef"
+    },
+    run(node, ctx) {
+      const from = node.from
+      const to = node.to
+      const innerText = ctx.doc.slice(from + 2, to - 2)
+      const pipeIdx = innerText.indexOf("|")
+      const pageName = pipeIdx >= 0 ? innerText.slice(0, pipeIdx) : innerText
+      const alias = pipeIdx >= 0 ? innerText.slice(pipeIdx + 1) : undefined
+
+      return {
+        type: "page-ref",
+        pageName,
+        alias,
+        wrapperAttrs: {
+          "data-page-name": pageName,
+          ...(alias && { "data-alias": alias }),
+          role: "link",
+          tabindex: "0",
+        },
+        decorationRanges: [
+          { type: "hidden", from, to: from + 2 },
+          {
+            type: "content",
+            from: from + 2,
+            to: to - 2,
+            className: "md-page-ref",
+          },
+          { type: "hidden", from: to - 2, to },
+        ],
+      }
+    },
+  }
+}
+
+export function createBlockRefRule(): MarkdownRule<BlockRefDecoration> {
+  return {
+    name: "block-ref",
+    nodeTypes: ["BlockRef"],
+    match(node) {
+      return node.type.name === "BlockRef"
+    },
+    run(node, ctx) {
+      const from = node.from
+      const to = node.to
+      const blockId = ctx.doc.slice(from + 2, to - 2)
+      return {
+        type: "block-ref",
+        blockId,
+        wrapperAttrs: {
+          "data-block-id": blockId,
+        },
+        decorationRanges: [
+          { type: "hidden", from, to: from + 2 },
+          {
+            type: "content",
+            from: from + 2,
+            to: to - 2,
+            className: "md-block-ref",
+          },
+          { type: "hidden", from: to - 2, to },
+        ],
+      }
+    },
+  }
+}
+
+export function createTagRule(): MarkdownRule<TagDecoration> {
+  return {
+    name: "tag",
+    nodeTypes: ["Tag"],
+    match(node) {
+      return node.type.name === "Tag"
+    },
+    run(node, ctx) {
+      const from = node.from
+      const to = node.to
+      const tagText = ctx.doc.slice(from, to)
+      const tag = tagText.slice(1)
+      return {
+        type: "tag",
+        tag,
+        wrapperAttrs: {
+          "data-tag": tag,
+        },
+        decorationRanges: [
+          { type: "content", from, to, className: "md-tag" },
+        ],
+      }
+    },
+  }
+}
+
+export function createHighlightRule(): MarkdownRule<HighlightDecoration> {
+  return {
+    name: "highlight",
+    nodeTypes: ["Highlight"],
+    match(node) {
+      return node.type.name === "Highlight"
+    },
+    run(node) {
+      const from = node.from
+      const to = node.to
+      return {
+        type: "highlight",
+        wrapperAttrs: {
+          "data-md-type": "highlight",
+        },
+        decorationRanges: [
+          { type: "hidden", from, to: from + 2 },
+          {
+            type: "content",
+            from: from + 2,
+            to: to - 2,
+            className: "md-highlight",
+          },
+          { type: "hidden", from: to - 2, to },
+        ],
+      }
+    },
+  }
+}
+
+// ── Line rule interface ──────────────────────────────────────────────
+
+export interface LineRule {
+  name: string
+  priority?: number
+  match(line: string): boolean
+  run(line: string): BlockDecoration | BlockDecoration[] | null
+}
+
+// ── Line rule factories ──────────────────────────────────────────────
+//
+// All line rules account for leading whitespace by calculating
+// `leadingOffset = line.length - trimmed.length` and adding it to
+// all decoration range positions. This ensures decorations target
+// the correct characters even when lines are indented.
+
+export function createHeadingRule(): LineRule {
+  return {
+    name: "heading",
+    priority: 10,
+    match(line) {
+      return line.trimStart().startsWith("#")
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const match = trimmed.match(/^(#{1,6})\s+(.*)$/)
+      if (!match) return null
+      const level = match[1].length
+      const markerEnd = leadingOffset + level
+      const contentStart = leadingOffset + level + 1
+      return {
+        type: "heading",
+        wrapperAttrs: {
+          "data-heading-level": String(level),
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-heading-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: line.length,
+            className: "md-heading-content",
+          },
+        ],
+        metadata: { level },
+      }
+    },
+  }
+}
+
+export function createCalloutRule(): LineRule {
+  return {
+    name: "callout",
+    priority: 20,
+    match(line) {
+      const trimmed = line.trimStart()
+      return trimmed.startsWith(">") && /^>\s*\[![A-Z]+\]/.test(trimmed)
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const calloutMatch = trimmed.match(/^>\s*\[!([A-Z]+)\]\s*(.*)$/)
+      if (!calloutMatch) return null
+      const calloutType = calloutMatch[1] as CalloutType
+      if (!["NOTE", "WARNING", "TIP", "DANGER", "INFO"].includes(calloutType))
+        return null
+      const bracketEnd = trimmed.indexOf("]")
+      const markerEnd = leadingOffset + bracketEnd + 1
+      const spaceAfter = trimmed.indexOf(" ", bracketEnd + 1)
+      const contentStart =
+        spaceAfter >= 0 ? leadingOffset + spaceAfter + 1 : line.length
+      return {
+        type: "callout",
+        wrapperAttrs: {
+          "data-callout-type": calloutType,
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-callout-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: line.length,
+            className: "md-callout-content",
+          },
+        ],
+        metadata: { calloutType },
+      }
+    },
+  }
+}
+
+export function createBlockquoteRule(): LineRule {
+  return {
+    name: "blockquote",
+    priority: 15,
+    match(line) {
+      const trimmed = line.trimStart()
+      return trimmed.startsWith(">") && !/^>\s*\[![A-Z]+\]/.test(trimmed)
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const match = trimmed.match(/^>/)
+      if (!match) return null
+      const markerEnd = leadingOffset + 1
+      const spaceMatch = trimmed.match(/^>\s+/)
+      const contentStart = spaceMatch
+        ? leadingOffset + spaceMatch[0].length
+        : markerEnd + 1
+      return {
+        type: "blockquote",
+        wrapperAttrs: {
+          "data-md-type": "blockquote",
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-blockquote-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: line.length,
+            className: "md-blockquote-content",
+          },
+        ],
+      }
+    },
+  }
+}
+
+export function createDateRule(): LineRule {
+  return {
+    name: "date",
+    priority: 10,
+    match(line) {
+      return line.trimStart().startsWith("📅")
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const match = trimmed.match(/^📅\s*(\d{4}-\d{2}-\d{2})/)
+      if (!match) return null
+      const date = match[1]
+      const emojiLen = "📅".length
+      const markerEnd = leadingOffset + emojiLen
+      const contentStart = leadingOffset + emojiLen + 1
+      return {
+        type: "date",
+        wrapperAttrs: {
+          "data-date": date,
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-date-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: line.length,
+            className: "md-date-content",
+          },
+        ],
+        metadata: { date },
+      }
+    },
+  }
+}
+
+export function createTaskRule(): LineRule {
+  return {
+    name: "task",
+    priority: 30,
+    match(line) {
+      const trimmed = line.trimStart()
+      return /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+/i.test(trimmed)
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const taskMatch = trimmed.match(
+        /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+(.*)$/i,
+      )
+      if (!taskMatch) return null
+      const taskState = taskMatch[1].toUpperCase() as TaskState
+      if (
+        ![
+          "TODO",
+          "DOING",
+          "DONE",
+          "LATER",
+          "NOW",
+          "WAITING",
+          "CANCELLED",
+        ].includes(taskState)
+      )
+        return null
+
+      const markerEnd = leadingOffset + taskMatch[1].length
+      const afterTask = taskMatch[2]
+      const blocks: BlockDecoration[] = []
+
+      const priorityMatch = afterTask.match(/\s+\[#([A-C])\]\s*$/)
+      let taskContentEnd = line.length
+
+      if (priorityMatch) {
+        taskContentEnd = line.length - priorityMatch[0].length
+      }
+
+      const contentStart = leadingOffset + taskMatch[0].indexOf(taskMatch[2])
+
+      blocks.push({
+        type: "task",
+        wrapperAttrs: {
+          "data-task-state": taskState,
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-task-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: taskContentEnd,
+            className: "md-task-content",
+          },
+        ],
+        metadata: { taskState },
+      })
+
+      if (priorityMatch) {
+        const priority = priorityMatch[1] as PriorityLevel
+        const priorityStart = taskContentEnd
+        const priorityMarkerEnd = priorityStart + 4
+
+        blocks.push({
+          type: "priority",
+          wrapperAttrs: {
+            "data-priority": priority,
+          },
+          decorationRanges: [
+            {
+              type: "content",
+              from: priorityStart,
+              to: priorityMarkerEnd,
+              className: "md-priority-marker",
+            },
+            {
+              type: "content",
+              from: priorityMarkerEnd,
+              to: line.length,
+              className: "md-priority-content",
+            },
+          ],
+          metadata: { priority },
+        })
+      }
+
+      return blocks
+    },
+  }
+}
+
+export function createPriorityRule(): LineRule {
+  return {
+    name: "priority",
+    priority: 25,
+    match(line) {
+      return /^\[#([A-C])\]/.test(line.trimStart())
+    },
+    run(line) {
+      const trimmed = line.trimStart()
+      const leadingOffset = line.length - trimmed.length
+      const match = trimmed.match(/^\[#([A-C])\]\s*(.*)$/)
+      if (!match) return null
+      const priority = match[1] as PriorityLevel
+      const markerEnd = leadingOffset + 4
+      const contentStart = leadingOffset + match[0].indexOf(match[2])
+      return {
+        type: "priority",
+        wrapperAttrs: {
+          "data-priority": priority,
+        },
+        decorationRanges: [
+          {
+            type: "content",
+            from: leadingOffset,
+            to: markerEnd,
+            className: "md-priority-marker",
+          },
+          {
+            type: "content",
+            from: contentStart,
+            to: line.length,
+            className: "md-priority-content",
+          },
+        ],
+        metadata: { priority },
+      }
+    },
+  }
+}
+
+// ── Engine ───────────────────────────────────────────────────────────
+
+function buildRuleIndex(
+  rules: ReadonlyArray<MarkdownRule<unknown>>,
+): ReadonlyMap<string, ReadonlyArray<MarkdownRule<unknown>>> {
+  const index = new Map<string, MarkdownRule<unknown>[]>()
+  for (const rule of rules) {
+    const types = rule.nodeTypes
+    if (types) {
+      for (const nodeType of types) {
+        const bucket = index.get(nodeType)
+        if (bucket) bucket.push(rule)
+        else index.set(nodeType, [rule])
+      }
+    }
+  }
+  return index
+}
+
+export class MarkdownRuleEngine {
+  private readonly inlineRules: ReadonlyArray<MarkdownRule<unknown>>
+  private readonly ruleIndex: ReadonlyMap<
+    string,
+    ReadonlyArray<MarkdownRule<unknown>>
+  >
+  private readonly lineRules: ReadonlyArray<LineRule>
+
+  constructor() {
+    const inlineRules = [
+      createDelimitedRule({
+        node: "StrongEmphasis",
+        mark: "EmphasisMark",
+        type: "strong",
+        className: "md-strong",
+      }),
+      createDelimitedRule({
+        node: "Emphasis",
+        mark: "EmphasisMark",
+        type: "em",
+        className: "md-em",
+      }),
+      createDelimitedRule({
+        node: "InlineCode",
+        mark: "CodeMark",
+        type: "code",
+        className: "md-code",
+      }),
+      createDelimitedRule({
+        node: "Strikethrough",
+        mark: "StrikethroughMark",
+        type: "strike",
+        className: "md-strike",
+      }),
+      createLinkRule(),
+      createPageRefRule(),
+      createBlockRefRule(),
+      createTagRule(),
+      createHighlightRule(),
+    ]
+
+    this.inlineRules = inlineRules
+    this.ruleIndex = buildRuleIndex(inlineRules)
+
+    this.lineRules = [
+      createCalloutRule(),
+      createBlockquoteRule(),
+      createTaskRule(),
+      createPriorityRule(),
+      createHeadingRule(),
+      createDateRule(),
+    ].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+  }
+
+  parse(docText: string, markdownParser: Parser): ParsedDecorations {
+    const content: ParsedDecorations["content"] = []
+    const properties: PropertyInfo[] = []
+    const blocks: BlockDecoration[] = []
+
+    if (!docText) return { content, properties, blocks }
+
+    const lines = docText.split("\n")
+    let lineOffset = 0
+
+    for (const line of lines) {
+      // Pipeline 1: AST inline rules (per-line to avoid Lezer paragraph boundaries)
+      try {
+        const tree = markdownParser.parse(line)
+        this.visitTree(tree.cursor(), (node) => {
+          const ctx: ParseContext = { doc: line }
+          const candidates = this.ruleIndex.get(node.type.name)
+          const rulesToTry = candidates ?? this.inlineRules
+          for (const rule of rulesToTry) {
+            if (rule.match(node, ctx)) {
+              const result = rule.run(node, ctx)
+              if (result) {
+                // Shift positions to absolute docText coordinates
+                for (const range of result.decorationRanges) {
+                  range.from += lineOffset
+                  range.to += lineOffset
+                }
+                content.push(result)
+              }
+              break
+            }
+          }
+        })
+      } catch (error) {
+        if (import.meta.env?.DEV) {
+          console.warn("Markdown parse error:", error)
+        }
+      }
+
+      // Pipeline 2: Line/block rules
+      for (const rule of this.lineRules) {
+        if (rule.match(line)) {
+          const result = rule.run(line)
+          if (result) {
+            const items = Array.isArray(result) ? result : [result]
+            for (const item of items) {
+              for (const range of item.decorationRanges) {
+                range.from += lineOffset
+                range.to += lineOffset
+              }
+              blocks.push(item)
+            }
+          }
+          break
+        }
+      }
+
+      // Pipeline 3: Property rules (per-line)
+      const propMatch = line.match(/^(\w[\w-]*)::([^\n]*)/)
+      if (propMatch) {
+        const key = propMatch[1]
+        const value = propMatch[2]?.trim() ?? ""
+        const keyEnd = lineOffset + key.length
+        const delimiterEnd = keyEnd + 2
+        properties.push({
+          key,
+          value,
+          keyRange: [lineOffset, keyEnd],
+          delimiterRange: [keyEnd, delimiterEnd],
+          valueRange: [delimiterEnd, delimiterEnd + propMatch[2].length],
+        })
+      }
+
+      lineOffset += line.length + 1 // +1 for the \n separator
+    }
+
+    return { content, properties, blocks }
+  }
+
+  private visitTree(
+    cursor: TreeCursor,
+    visit: (node: SyntaxNode) => void,
+  ): void {
+    do {
+      if (this.ruleIndex.has(cursor.node.type.name)) {
+        visit(cursor.node)
+      }
+      if (cursor.firstChild()) {
+        this.visitTree(cursor, visit)
+        cursor.parent()
+      }
+    } while (cursor.nextSibling())
+  }
+}
+
+export const defaultMarkdownEngine = new MarkdownRuleEngine()

+ 4 - 3
packages/editor/src/lib/schema.ts

@@ -3,6 +3,7 @@
  *
  * Only contains structural nodes - no semantic marks (bold, italic, etc).
  * Markdown syntax is preserved as plain text and rendered via decorations.
+ * Each Block.vue instance is exactly one <p>; newlines are hard_breaks.
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 
@@ -10,11 +11,11 @@ type DOMNode = "doc" | "paragraph" | "hard_break" | "text"
 
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {
-  /** Root document container */
+  /** Root document container — exactly one paragraph per block */
   doc: {
-    content: "block+",
+    content: "block",
   },
-  /** Paragraph - each line becomes a paragraph */
+  /** Paragraph — single paragraph per Block.vue instance */
   paragraph: {
     group: "block",
     content: "inline*",