Explorar o código

feat: add CM6 code block node view, auto-close plugin, and debug logger

Zander Hawke hai 1 semana
pai
achega
cf55f59496

+ 61 - 0
AGENTS.md

@@ -196,6 +196,67 @@ Decorations alternate between `hidden` (delimiters) and `content` (payload). For
 
 ---
 
+## Debug Logging
+
+A namespace-based logger system in `packages/editor/src/lib/logger.ts`.
+
+### Enabling logs
+
+| Method | Example |
+|---|---|
+| **Global** (all blocks) | `localStorage.setItem("editor:debug", "*")` |
+| **By namespace** | `localStorage.setItem("editor:debug", "CodeBlockView,Block.Enter")` |
+| **Per component instance** | `<EditorBlock debug="CodeBlockView,Block.Enter" />` |
+
+After setting, refresh and open DevTools console. Namespaces set via the `debug` prop are scoped to that component instance and its descendants. The `localStorage` setting applies globally.
+
+### Available namespaces
+
+| Namespace | Source File | What it logs | Level |
+|---|---|---|---|
+| `Block` | `Block.vue` | Content changes, paste classification, external syncs | `debug` |
+| `CodeBlockView` | `useCodeBlockView.ts` | Constructor, language detection, CM6↔PM sync, fence deletion conversion | `info`/`debug` |
+| `Block.Enter` | `useBlockKeyboardHandlers.ts` | Enter keystroke detection, fence match results | `debug` |
+| `ContentModel` | `content-model.ts` | `contentToDoc`/`docToContent` input/output sizes, node type breakdown | `debug` |
+| `AutoClose` | `auto-close-plugin.ts` | Triple-backtick, bold, and italic auto-close rule triggers | `info` |
+| `Decorations` | `useMarkdownDecorations.ts` | Cache hit/miss ratio, decoration count per build | `debug` |
+| `PatternPlugin` | `usePatternPlugin.ts` | Pattern detection session open/update/close transitions | `info` |
+| `ParseEngine` | `engine.ts` | Parse duration, rule match counts | `debug` |
+
+### Adding logs to new code
+
+```typescript
+import { createLogger } from "@/lib/logger"
+const log = createLogger("MyFeature")
+log.debug("only visible with filter enabled")
+log.info("visible by default")
+log.warn("always visible")
+log.error("always visible")
+```
+
+For component-aware filtering (respects the `debug` prop), pass the filter string from the component:
+
+```typescript
+// In a composable factory:
+export function createMyThing(componentFilter?: string) {
+  const log = createLogger("MyFeature", componentFilter)
+  // ...
+}
+
+// In Block.vue:
+createMyThing(props.debug)
+```
+
+### Log levels
+
+- `info` — notable events (auto-close triggered, code block created, pattern opened)
+- `debug` — high-frequency events (every keystroke sync, decoration rebuild, paste classification)
+- `warn` / `error` — unexpected states, always visible
+
+If console output is too noisy, filter to `info`+: `localStorage.setItem("editor:debug", "*")` and set min level via `configureDebug({ minLevel: "info" })` in DevTools.
+
+---
+
 ## Performance Constraints
 
 - Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `Block.vue` instances.

+ 2 - 1
apps/dev/tsconfig.app.json

@@ -12,7 +12,8 @@
 
     "paths": {
       "#build/ui/*": ["./node_modules/.nuxt-ui/ui/*"],
-      "~/*": ["./src/*"]
+      "~/*": ["./src/*"],
+      "@/*": ["../../packages/editor/src/*"]
     }
   },
   "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]

+ 12 - 0
packages/editor/package.json

@@ -42,6 +42,18 @@
     "vue": "^3.5.34"
   },
   "dependencies": {
+    "@codemirror/commands": "^6.10.3",
+    "@codemirror/lang-css": "^6.3.1",
+    "@codemirror/lang-html": "^6.4.11",
+    "@codemirror/lang-javascript": "^6.2.5",
+    "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/lang-markdown": "^6.5.0",
+    "@codemirror/lang-python": "^6.2.1",
+    "@codemirror/lang-xml": "^6.1.0",
+    "@codemirror/language": "^6.12.3",
+    "@codemirror/language-data": "^6.5.2",
+    "@codemirror/state": "^6.6.0",
+    "@codemirror/view": "^6.43.1",
     "@lezer/common": "^1.2.2",
     "@lezer/markdown": "^1.6.3",
     "@nuxt/ui": "^4.7.1",

+ 46 - 0
packages/editor/src/assets/style.css

@@ -210,3 +210,49 @@
 .md-date-content {
   @apply text-(--ui-text-muted) font-mono text-[0.9em] bg-(--ui-bg-elevated) px-1.5 py-px rounded-md border border-(--ui-border);
 }
+
+/* ── Code Blocks (CM6 Node View) ──────────────────────────────────── */
+
+.cm-code-block {
+  @apply my-2 rounded-lg overflow-hidden border border-(--ui-border) bg-(--ui-bg-elevated);
+  font-size: 0.875em;
+}
+
+.cm-code-block {
+  @apply my-2 rounded-lg overflow-hidden border border-(--ui-border) bg-(--ui-bg-elevated);
+  font-size: 0.875em;
+}
+
+.cm-code-block .cm-editor {
+  outline: none;
+}
+
+.cm-code-block .cm-editor.cm-focused {
+  outline: none;
+}
+
+.cm-code-block .cm-scroller {
+  @apply font-mono;
+  line-height: 1.5;
+}
+
+.cm-code-block .cm-gutters {
+  @apply bg-(--ui-bg-elevated) border-r border-(--ui-border) text-(--ui-text-muted);
+}
+
+.cm-code-block .cm-activeLineGutter {
+  @apply bg-(--ui-bg-elevated)/80;
+}
+
+.cm-code-block .cm-activeLine {
+  background: transparent;
+}
+
+.cm-code-block .cm-content {
+  @apply px-4 py-3;
+  caret-color: currentColor;
+}
+
+.cm-code-block .cm-fence-marker {
+  @apply text-(--ui-text-muted);
+}

+ 53 - 38
packages/editor/src/components/Block.vue

@@ -1,22 +1,30 @@
 <script setup lang="ts">
 import { watchDebounced } from "@vueuse/core"
 import { EditorState, Selection, type Transaction } from "prosemirror-state"
-import { schema } from "@/lib/schema"
 import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
 import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
+import { CodeBlockView } from "@/composables/useCodeBlockView"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
 } from "@/composables/useMarkdownDecorations"
-import { createPatternPlugin } from "@/composables/usePatternPlugin"
 import type {
   PatternClosePayload,
   PatternOpenPayload,
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
+import { createPatternPlugin } from "@/composables/usePatternPlugin"
+import {
+  autoClosePlugin,
+  doubleAsteriskRule,
+  singleUnderscoreRule,
+  tripleBacktickRule,
+} from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
+import { createLogger } from "@/lib/logger"
 import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
+import { schema } from "@/lib/schema"
 
 const content = defineModel<string>("content")
 
@@ -24,6 +32,7 @@ const props = defineProps<{
   focused?: boolean
   cursorPosition?: "start" | "end" | number
   markerMode?: MarkerVisibilityMode
+  debug?: string
 }>()
 
 const emit = defineEmits<{
@@ -43,15 +52,20 @@ const emit = defineEmits<{
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
 }>()
 
-const handleKeyDown = createBlockKeyboardHandler({
-  split: (before, after) => emit("split", before, after),
-  "merge-previous": () => emit("merge-previous"),
-  "delete-if-empty": () => emit("delete-if-empty"),
-  indent: () => emit("indent"),
-  outdent: () => emit("outdent"),
-  "arrow-up-from-start": () => emit("arrow-up-from-start"),
-  "arrow-down-from-end": () => emit("arrow-down-from-end"),
-})
+const log = createLogger("Block", props.debug)
+
+const handleKeyDown = createBlockKeyboardHandler(
+  {
+    split: (before, after) => emit("split", before, after),
+    "merge-previous": () => emit("merge-previous"),
+    "delete-if-empty": () => emit("delete-if-empty"),
+    indent: () => emit("indent"),
+    outdent: () => emit("outdent"),
+    "arrow-up-from-start": () => emit("arrow-up-from-start"),
+    "arrow-down-from-end": () => emit("arrow-down-from-end"),
+  },
+  props.debug,
+)
 
 const formattingHandlers = {
   toggleBold: () => {
@@ -165,7 +179,15 @@ onMounted(() => {
 
   const editorState = EditorState.create({
     doc: initialDoc,
-    plugins: [markdownDecorationsPlugin, patternPlugin],
+    plugins: [
+      markdownDecorationsPlugin,
+      patternPlugin,
+      autoClosePlugin([
+        tripleBacktickRule,
+        doubleAsteriskRule,
+        singleUnderscoreRule,
+      ]),
+    ],
   })
 
   const mountEl = editorRef.value
@@ -173,6 +195,10 @@ onMounted(() => {
 
   view = new EditorView(mountEl, {
     state: editorState,
+    nodeViews: {
+      code_block: (node, view, getPos) =>
+        new CodeBlockView(node, view, () => getPos() ?? 0, props.debug),
+    },
     handleKeyDown,
     handlePaste(view, event) {
       const text = event.clipboardData?.getData("text/plain")
@@ -196,6 +222,12 @@ onMounted(() => {
             !isContinuationBlock(c.kind) &&
             c.kind !== firstType,
         )
+      log.debug("paste", {
+        lines: lines.length,
+        firstType,
+        hasMixedTypes,
+        kinds: classifications.map((c) => c.kind),
+      })
       if (!hasMixedTypes) return false
 
       event.preventDefault()
@@ -220,10 +252,7 @@ onMounted(() => {
       for (let i = 1; i < lines.length; i++) {
         const c = classifications[i]!
         const line = lines[i]!
-        if (
-          !foundBreak &&
-          (c.kind === firstType || c.kind === "paragraph")
-        ) {
+        if (!foundBreak && (c.kind === firstType || c.kind === "paragraph")) {
           firstGroupLines.push(line)
         } else {
           foundBreak = true
@@ -261,28 +290,10 @@ onMounted(() => {
 
       if (transaction.docChanged && !transaction.getMeta("externalUpdate")) {
         const newContent = docToContent(newState.doc)
-        // Block-type enforcement: if a non-first paragraph triggers a
-        // different block type, auto-promote it by firing a split.
-        const lines = newContent.split("\n")
-        if (lines.length > 1) {
-          const firstBlock = classifyBlock(lines[0]!)
-          const isContinuationBlock = (kind: string) =>
-            kind === "blockquote" &&
-            (firstBlock.kind === "callout" || firstBlock.kind === "blockquote")
-          for (let i = 1; i < lines.length; i++) {
-            const block = classifyBlock(lines[i]!)
-            if (
-              block.kind !== "paragraph" &&
-              !isContinuationBlock(block.kind) &&
-              block.kind !== firstBlock.kind
-            ) {
-              const before = lines.slice(0, i).join("\n")
-              const after = lines.slice(i).join("\n")
-              emit("split", before, after)
-              return
-            }
-          }
-        }
+        log.debug("content change", {
+          length: newContent.length,
+          docChanged: true,
+        })
         content.value = newContent
         emit("change", newContent)
       }
@@ -354,6 +365,10 @@ watchDebounced(
     if (!view || newContent === undefined) return
     const currentContent = docToContent(view.state.doc)
     if (currentContent === newContent) return
+    log.debug("external update", {
+      oldLen: currentContent.length,
+      newLen: newContent.length,
+    })
 
     const newDoc = contentToDoc(newContent)
     const tr = view.state.tr.replaceWith(

+ 44 - 2
packages/editor/src/composables/useBlockKeyboardHandlers.ts

@@ -18,9 +18,10 @@ import {
   joinBackward,
   joinForward,
 } from "prosemirror-commands"
-import type { EditorState } from "prosemirror-state"
+import { type EditorState, TextSelection } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
 import { docToContent } from "@/lib/content-model"
+import { createLogger } from "@/lib/logger"
 import { schema } from "@/lib/schema"
 
 export interface BlockKeyboardHandlers {
@@ -51,7 +52,11 @@ function isSingleEmptyParagraph(state: EditorState): boolean {
   return doc.childCount === 1 && doc.child(0).content.size === 0
 }
 
-export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
+export function createBlockKeyboardHandler(
+  handlers: BlockKeyboardHandlers,
+  componentFilter?: string,
+) {
+  const log = createLogger("Block.Enter", componentFilter)
   return function handleKeyDown(
     view: EditorView,
     event: KeyboardEvent,
@@ -79,6 +84,43 @@ export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
       const { doc, selection } = state
       const { from } = selection
 
+      // Check if cursor is at end of a fenced code line (``` / ~~~)
+      const $head = state.selection.$head
+      const parent = $head.parent
+      log.debug("Enter pressed", {
+        parentType: parent.type.name,
+        textContent: `"${parent.textContent}"`,
+        cursorPos: $head.pos,
+        paraEnd: $head.end(),
+        atEnd: atParagraphEnd(state, view),
+      })
+      if (parent.type.name === "paragraph" && atParagraphEnd(state, view)) {
+        const fenceMatch = parent.textContent.match(/^(```|~~~)\s*(\w*)\s*$/)
+        log.debug("fence check", {
+          match: fenceMatch?.[0],
+          language: fenceMatch?.[2],
+        })
+        if (fenceMatch) {
+          const language = fenceMatch[2] ?? ""
+          const pos = $head.before()
+          const fence = `\`\`\`${language}`
+          const init = `${fence}\n\n\`\`\``
+          const codeBlock = schema.nodes.code_block.create({ language }, [
+            schema.text(init),
+          ])
+          const pmTr = state.tr.replaceWith(
+            pos,
+            pos + parent.nodeSize,
+            codeBlock,
+          )
+          const resolved = pmTr.doc.resolve(pos + 1 + fence.length + 1)
+          pmTr.setSelection(new TextSelection(resolved))
+          view.dispatch(pmTr)
+          view.focus()
+          return true
+        }
+      }
+
       // 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)

+ 300 - 0
packages/editor/src/composables/useCodeBlockView.ts

@@ -0,0 +1,300 @@
+import { defaultKeymap, indentWithTab } from "@codemirror/commands"
+import { css } from "@codemirror/lang-css"
+import { html } from "@codemirror/lang-html"
+import { javascript } from "@codemirror/lang-javascript"
+import { json } from "@codemirror/lang-json"
+import { markdown as cmMarkdown } from "@codemirror/lang-markdown"
+import { python } from "@codemirror/lang-python"
+import { xml } from "@codemirror/lang-xml"
+import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"
+import {
+  Compartment,
+  EditorState,
+  type Range,
+  StateField,
+} from "@codemirror/state"
+import type { DecorationSet } from "@codemirror/view"
+import {
+  EditorView as CodeMirrorView,
+  Decoration,
+  keymap,
+} from "@codemirror/view"
+import type { Node as ProsemirrorNode } from "prosemirror-model"
+import { TextSelection } from "prosemirror-state"
+import type { EditorView, NodeView } from "prosemirror-view"
+import { createLogger } from "@/lib/logger"
+
+function instanceLogger(componentFilter?: string) {
+  return createLogger("CodeBlockView", componentFilter)
+}
+
+const FENCE_RE = /^(```|~~~)\s*(\w*)/
+
+function fenceMarkerExtension() {
+  const fenceLineDeco = Decoration.line({ class: "cm-fence-marker" })
+  const CLOSE_FENCE_RE = /^(```|~~~)\s*$/
+  function buildDecos(state: EditorState) {
+    const decos: Range<Decoration>[] = []
+    const doc = state.doc
+    if (doc.lines >= 2) {
+      const first = doc.line(1)
+      if (FENCE_RE.test(first.text)) decos.push(fenceLineDeco.range(first.from))
+      const last = doc.line(doc.lines)
+      if (CLOSE_FENCE_RE.test(last.text))
+        decos.push(fenceLineDeco.range(last.from))
+    }
+    return Decoration.set(decos)
+  }
+  return StateField.define<DecorationSet>({
+    create(state) {
+      return buildDecos(state)
+    },
+    update(decos, tr) {
+      if (!tr.docChanged) return decos
+      return buildDecos(tr.state)
+    },
+    provide: (f) => CodeMirrorView.decorations.from(f),
+  })
+}
+
+function resolveLanguage(lang: string) {
+  switch (lang.toLowerCase()) {
+    case "js":
+    case "javascript":
+    case "jsx":
+    case "mjs":
+    case "cjs":
+      return javascript()
+    case "ts":
+    case "typescript":
+    case "tsx":
+    case "mts":
+    case "cts":
+      return javascript({ typescript: true })
+    case "python":
+    case "py":
+      return python()
+    case "html":
+    case "htm":
+      return html()
+    case "css":
+      return css()
+    case "json":
+      return json()
+    case "xml":
+    case "svg":
+      return xml()
+    case "md":
+    case "markdown":
+      return cmMarkdown()
+    default:
+      return []
+  }
+}
+
+export class CodeBlockView implements NodeView {
+  dom: HTMLElement
+  cmView: CodeMirrorView
+  private pmView: EditorView
+  private getPos: () => number
+  private languageCompartment = new Compartment()
+  private lastLanguage = ""
+  private log: ReturnType<typeof createLogger>
+
+  constructor(
+    node: ProsemirrorNode,
+    view: EditorView,
+    getPos: () => number,
+    componentFilter?: string,
+  ) {
+    this.pmView = view
+    this.getPos = getPos
+    this.log = instanceLogger(componentFilter)
+
+    this.dom = document.createElement("div")
+    this.dom.className = "cm-code-block"
+
+    const language = (node.attrs.language as string) || ""
+    this.lastLanguage = language
+    const langExt = resolveLanguage(language)
+    this.log.info("constructor", {
+      language,
+      langExt: langExt?.constructor?.name ?? typeof langExt,
+    })
+
+    const exitKeymap = keymap.of([
+      {
+        key: "ArrowUp",
+        run: (cm6View) => {
+          const sel = cm6View.state.selection.main
+          if (sel.from === 0 && sel.empty) {
+            return this.exitBlock("up")
+          }
+          return false
+        },
+      },
+      {
+        key: "ArrowDown",
+        run: (cm6View) => {
+          const sel = cm6View.state.selection.main
+          if (sel.to === cm6View.state.doc.length && sel.empty) {
+            return this.exitBlock("down")
+          }
+          return false
+        },
+      },
+      {
+        key: "Enter",
+        run: (cm6View) => {
+          const sel = cm6View.state.selection.main
+          if (!sel.empty) return false
+          const line = cm6View.state.doc.lineAt(sel.head)
+          if (line.to === sel.head && /^```\s*$/.test(line.text)) {
+            return this.exitBlock("down")
+          }
+          return false
+        },
+      },
+    ])
+
+    const cmState = EditorState.create({
+      doc: node.textContent,
+      extensions: [
+        this.languageCompartment.of(langExt),
+        exitKeymap,
+        keymap.of([indentWithTab, ...defaultKeymap]),
+        CodeMirrorView.lineWrapping,
+        syntaxHighlighting(defaultHighlightStyle),
+        fenceMarkerExtension(),
+      ],
+    })
+
+    this.cmView = new CodeMirrorView({
+      state: cmState,
+      parent: this.dom,
+      dispatch: (tr) => {
+        this.cmView.update([tr])
+        if (!tr.docChanged) return
+        this.syncToPM()
+      },
+    })
+
+    // Place CM6 cursor at the end of the first line (after the opening fence
+    // marker), and focus CM6 so keyboard input lands in the code block.
+    const content = node.textContent
+    const firstLine = content.split("\n")[0] ?? ""
+    if (FENCE_RE.test(firstLine)) {
+      const cursorPos = firstLine.length
+      this.cmView.dispatch({
+        selection: { anchor: cursorPos, head: cursorPos },
+      })
+      requestAnimationFrame(() => this.cmView.focus())
+    }
+  }
+
+  private convertToParagraph(content: string) {
+    this.log.info("convertToParagraph", { content: content.slice(0, 40) })
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node || node.type.name !== "code_block") return
+    const para = this.pmView.state.schema.nodes.paragraph!.create(
+      null,
+      this.pmView.state.schema.text(content),
+    )
+    const tr = this.pmView.state.tr.replaceWith(pos, pos + node.nodeSize, para)
+    this.pmView.dispatch(tr)
+  }
+
+  private exitBlock(dir: "up" | "down"): boolean {
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node) return false
+    const targetPos = dir === "up" ? pos - 1 : pos + node.nodeSize
+    if (targetPos < 0 || targetPos > this.pmView.state.doc.content.size) {
+      return false
+    }
+    const $pos = this.pmView.state.doc.resolve(targetPos)
+    const sel = TextSelection.near($pos, dir === "up" ? -1 : 1)
+    if (!sel) return false
+    this.pmView.dispatch(
+      this.pmView.state.tr.setSelection(sel).scrollIntoView(),
+    )
+    this.pmView.focus()
+    return true
+  }
+
+  private syncToPM() {
+    const content = this.cmView.state.doc.toString()
+    const firstLine = content.split("\n")[0] ?? ""
+
+    // If the first line no longer starts with a fence marker,
+    // revert the code block back to a regular paragraph.
+    if (!FENCE_RE.test(firstLine)) {
+      this.convertToParagraph(content)
+      return
+    }
+
+    const language = firstLine.match(FENCE_RE)?.[2] ?? ""
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node) return
+    const pmText = node.textContent
+    if (pmText === content && node.attrs.language === language) return
+    const textNode = this.pmView.state.schema.text(content)
+    let tr = this.pmView.state.tr.replaceWith(
+      pos + 1,
+      pos + node.nodeSize - 1,
+      textNode,
+    )
+    if (node.attrs.language !== language) {
+      this.log.info("syncToPM language change", {
+        from: node.attrs.language,
+        to: language,
+      })
+      tr = tr.setNodeAttribute(pos, "language", language)
+    } else {
+      this.log.debug("syncToPM content sync", {
+        pmLen: pmText.length,
+        cmLen: content.length,
+      })
+    }
+    this.pmView.dispatch(tr)
+  }
+
+  update(node: ProsemirrorNode) {
+    if (node.type.name !== "code_block") return false
+
+    const language = (node.attrs.language as string) || ""
+    const langExt = resolveLanguage(language)
+    this.log.debug("update", {
+      language,
+      langExt: langExt?.constructor?.name ?? typeof langExt,
+      contentMatch: this.cmView.state.doc.toString() === node.textContent,
+    })
+
+    // Only reconfigure the language compartment when the language
+    // actually changed — avoids re-entering the CM6 dispatch callback
+    // and breaking the syncToPM → update → syncToPM cycle.
+    if (language !== this.lastLanguage) {
+      this.lastLanguage = language
+      this.cmView.dispatch({
+        effects: this.languageCompartment.reconfigure(langExt),
+      })
+    }
+
+    const content = node.textContent
+    if (this.cmView.state.doc.toString() === content) return true
+    this.cmView.dispatch({
+      changes: { from: 0, to: this.cmView.state.doc.length, insert: content },
+    })
+    return true
+  }
+
+  destroy() {
+    this.cmView.destroy()
+  }
+
+  stopEvent() {
+    return true
+  }
+}

+ 18 - 0
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -17,6 +17,7 @@
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
+import { createLogger } from "@/lib/logger"
 import {
   type DecorationRange,
   MarkdownRuleEngine,
@@ -24,6 +25,8 @@ import {
 } from "@/lib/markdown-parser"
 import type { BlockDecoration } from "@/lib/markdown-rules"
 
+const log = createLogger("Decorations")
+
 export type MarkerVisibilityMode = "live-preview" | "always-visible"
 
 const defaultMarkerMode: MarkerVisibilityMode = "live-preview"
@@ -103,6 +106,7 @@ function buildDecorationsFromCache(
 
   doc.descendants((node: ProsemirrorNode, pos: number) => {
     if (!node.isTextblock) return
+    if (node.type.spec.code) return
     const cache = contentCaches.get(node.textContent)
     if (!cache) return
     paragraphs.push({ node, pos, cache })
@@ -194,6 +198,10 @@ function buildDecorationsFromCache(
     )
   }
 
+  log.debug("build decorations", {
+    count: decorationSpecs.length,
+    paragraphs: paragraphs.length,
+  })
   return DecorationSet.create(doc, decorationSpecs)
 }
 
@@ -320,19 +328,29 @@ function invalidateChangedCaches(
   engine: MarkdownRuleEngine,
 ): Map<string, BlockTokenCache> {
   const newCaches = new Map<string, BlockTokenCache>()
+  let hitCount = 0
+  let missCount = 0
 
   newDoc.descendants((node: ProsemirrorNode) => {
     if (!node.isTextblock) return
+    if (node.type.spec.code) return
     const blockText = node.textContent
 
     const oldCache = oldCaches.get(blockText)
     if (oldCache) {
+      hitCount++
       newCaches.set(blockText, oldCache)
     } else {
+      missCount++
       newCaches.set(blockText, parseBlockTokens(blockText, engine))
     }
   })
 
+  log.debug("cache invalidate", {
+    hitCount,
+    missCount,
+    total: hitCount + missCount,
+  })
   return newCaches
 }
 

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

@@ -18,6 +18,7 @@ import {
   type Transaction,
 } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
+import { createLogger } from "@/lib/logger"
 
 export type PatternKind = "reference" | "embed" | "command" | "tag"
 export type CloseReason = "completed" | "cancelled"
@@ -251,6 +252,7 @@ class PatternView {
   private callbacks: PatternCallbacks
   private currentSession: PatternSession | null = null
   private manualClose = false
+  private log = createLogger("PatternPlugin")
 
   constructor(view: EditorView, callbacks: PatternCallbacks) {
     this.view = view
@@ -278,6 +280,11 @@ class PatternView {
   }
 
   private open(session: PatternSession) {
+    this.log.info("open", {
+      kind: session.kind,
+      trigger: session.trigger,
+      query: session.query,
+    })
     const pos = this.view.coordsAtPos(session.range.from)
     const payload: PatternOpenPayload = {
       id: session.id,
@@ -293,6 +300,7 @@ class PatternView {
   }
 
   private updateQuery(session: PatternSession) {
+    this.log.debug("update", { kind: session.kind, query: session.query })
     const pos = this.view.coordsAtPos(session.range.from)
     this.callbacks.onPatternUpdate({
       id: session.id,
@@ -305,6 +313,10 @@ class PatternView {
   }
 
   private close(session: PatternSession) {
+    this.log.info("close", {
+      kind: session.kind,
+      reason: this.manualClose ? "completed" : "checking",
+    })
     if (this.manualClose) {
       this.callbacks.onPatternClose({ id: session.id, reason: "completed" })
       this.manualClose = false

+ 112 - 0
packages/editor/src/lib/auto-close-plugin.ts

@@ -0,0 +1,112 @@
+import type { EditorState } from "prosemirror-state"
+import { Plugin, TextSelection } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
+import { createLogger } from "@/lib/logger"
+import { schema } from "@/lib/schema"
+
+const log = createLogger("AutoClose")
+
+export interface AutoCloseRule {
+  name: string
+  handler: (
+    state: EditorState,
+    view: EditorView,
+    from: number,
+    to: number,
+    text: string,
+  ) => boolean
+}
+
+export function autoClosePlugin(rules: AutoCloseRule[]) {
+  return new Plugin<unknown>({
+    props: {
+      handleTextInput(view, from, to, text) {
+        if (text.length !== 1) return false
+        const state = view.state
+        for (const rule of rules) {
+          if (rule.handler(state, view, from, to, text)) {
+            return true
+          }
+        }
+        return false
+      },
+    },
+  })
+}
+
+// ── Built-in rules ──────────────────────────────────────────
+
+/** Auto-close ``` (triple backtick) into a code block */
+export const tripleBacktickRule: AutoCloseRule = {
+  name: "triple-backtick",
+  handler(state, view, from, _to, text) {
+    if (text !== "`") return false
+    const $pos = state.doc.resolve(from)
+    if ($pos.parent.type.name !== "paragraph") return false
+
+    const before = state.doc.textBetween(Math.max(0, from - 2), from)
+    if (before !== "``") return false
+
+    // Ensure ``` is at paragraph start (only whitespace before)
+    const posInPara = from - $pos.before() - 1
+    const prefix = $pos.parent.textContent.slice(0, posInPara - 2)
+    if (prefix.trim() !== "") return false
+
+    log.info("triple backtick → code block")
+    const paraStart = $pos.before()
+    const paraEnd = paraStart + $pos.parent.nodeSize
+    const init = "```\n\n```"
+    const codeBlock = schema.nodes.code_block.create({ language: "" }, [
+      schema.text(init),
+    ])
+    const tr = state.tr.replaceWith(paraStart, paraEnd, codeBlock)
+    const cursorPos = paraStart + 1 + "```".length
+    tr.setSelection(new TextSelection(tr.doc.resolve(cursorPos)))
+    view.dispatch(tr)
+    return true
+  },
+}
+
+/** Auto-close ** (double asterisk → bold) */
+export const doubleAsteriskRule: AutoCloseRule = {
+  name: "double-asterisk",
+  handler(state, view, from, _to, text) {
+    if (text !== "*") return false
+    const $pos = state.doc.resolve(from)
+    if ($pos.parent.type.name !== "paragraph") return false
+
+    const prev = state.doc.textBetween(Math.max(0, from - 1), from)
+    const prevPrev = state.doc.textBetween(Math.max(0, from - 2), from - 1)
+    if (prev !== "*" || prevPrev === "*") return false
+
+    log.info("double asterisk → bold")
+    const tr = state.tr.replaceWith(from - 1, from, state.schema.text("****"))
+    const resolved = tr.doc.resolve(from - 1 + 2)
+    tr.setSelection(new TextSelection(resolved))
+    view.dispatch(tr)
+    view.focus()
+    return true
+  },
+}
+
+/** Auto-close _ (single underscore → italic/emphasis) */
+export const singleUnderscoreRule: AutoCloseRule = {
+  name: "single-underscore",
+  handler(state, view, from, _to, text) {
+    if (text !== "_") return false
+    const $pos = state.doc.resolve(from)
+    if ($pos.parent.type.name !== "paragraph") return false
+
+    // Word-boundary check: don't auto-close mid-word
+    const prev = state.doc.textBetween(Math.max(0, from - 1), from)
+    if (/\w/.test(prev)) return false
+
+    log.info("single underscore → italic")
+    const tr = state.tr.replaceWith(from, from, state.schema.text("__"))
+    const resolved = tr.doc.resolve(from + 1)
+    tr.setSelection(new TextSelection(resolved))
+    view.dispatch(tr)
+    view.focus()
+    return true
+  },
+}

+ 86 - 0
packages/editor/src/lib/logger.ts

@@ -0,0 +1,86 @@
+const DEBUG_KEY = "editor:debug"
+
+type LogLevel = "debug" | "info" | "warn" | "error"
+
+const LEVEL_RANK: Record<LogLevel, number> = {
+  debug: 0,
+  info: 1,
+  warn: 2,
+  error: 3,
+}
+
+interface DebugConfig {
+  namespaces: Set<string> | "*"
+  minLevel: LogLevel
+}
+
+const config: DebugConfig = loadConfig()
+
+function loadConfig(): DebugConfig {
+  try {
+    const stored = localStorage.getItem(DEBUG_KEY)
+    if (stored === "*") return { namespaces: "*", minLevel: "debug" }
+    if (stored) {
+      return {
+        namespaces: new Set(stored.split(",").map((s) => s.trim())),
+        minLevel: "debug",
+      }
+    }
+  } catch {
+    // localStorage unavailable (SSR, tests)
+  }
+  return { namespaces: "*", minLevel: "info" }
+}
+
+export function configureDebug(
+  opts: Partial<{ namespaces: string; minLevel: LogLevel }>,
+) {
+  if (opts.namespaces !== undefined) {
+    config.namespaces =
+      opts.namespaces === "*"
+        ? "*"
+        : new Set(opts.namespaces.split(",").map((s) => s.trim()))
+  }
+  if (opts.minLevel !== undefined) {
+    config.minLevel = opts.minLevel
+  }
+}
+
+function parseFilter(filter: string): Set<string> | "*" {
+  if (filter === "*") return "*"
+  return new Set(filter.split(",").map((s) => s.trim()))
+}
+
+export function createLogger(namespace: string, componentFilter?: string) {
+  const filter =
+    componentFilter !== undefined ? parseFilter(componentFilter) : null
+
+  const shouldLog = (level: LogLevel): boolean => {
+    if (LEVEL_RANK[level] < LEVEL_RANK[config.minLevel]) return false
+
+    // Component-level filter takes priority
+    if (filter !== null) {
+      if (filter === "*") return true
+      return filter.has(namespace)
+    }
+
+    // Fall back to global config
+    if (config.namespaces === "*") return true
+    return config.namespaces.has(namespace) || config.namespaces.has("*")
+  }
+
+  return {
+    debug: (...args: unknown[]) => {
+      if (shouldLog("debug")) console.debug(`[${namespace}]`, ...args)
+    },
+    info: (...args: unknown[]) => {
+      if (shouldLog("info")) console.info(`[${namespace}]`, ...args)
+    },
+    warn: (...args: unknown[]) => {
+      if (shouldLog("warn")) console.warn(`[${namespace}]`, ...args)
+    },
+    error: (...args: unknown[]) => {
+      if (shouldLog("error")) console.error(`[${namespace}]`, ...args)
+    },
+  }
+}

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

@@ -11,6 +11,7 @@
  */
 
 import type { Parser, Tree } from "@lezer/common"
+import { createLogger } from "@/lib/logger"
 import { createBlockRules } from "@/lib/markdown-rules/block-rules"
 import { createInlineRules } from "@/lib/markdown-rules/inline-rules"
 import type {
@@ -22,6 +23,8 @@ import type {
   PropertyInfo,
 } from "@/lib/markdown-rules/types"
 
+const log = createLogger("ParseEngine")
+
 // ── Rule index builders ───────────────────────────────────────────────
 
 function buildRuleIndex(
@@ -105,7 +108,9 @@ export class MarkdownRuleEngine {
 
     // ── Phase 1: Lezer AST walk ─────────────────────────────────────
     const ctx: ParseContext = { doc: docText }
+    const startTime = performance.now()
     const tree: Tree = markdownParser.parse(docText)
+    const parseTime = performance.now() - startTime
 
     tree.iterate({
       enter: (ref) => {
@@ -147,6 +152,15 @@ export class MarkdownRuleEngine {
     // ── Phase 2: Property extraction ────────────────────────────────
     properties.push(...parseProperties(docText))
 
+    const totalTime = performance.now() - startTime
+    log.debug("parse", {
+      docLen: docText.length,
+      parseTimeMs: parseTime.toFixed(2),
+      totalTimeMs: totalTime.toFixed(2),
+      blocks: blocks.length,
+      inlineTokens: content.length,
+      properties: properties.length,
+    })
     return { content, properties, blocks }
   }
 }

+ 507 - 0
pnpm-lock.yaml

@@ -63,6 +63,42 @@ importers:
 
   packages/editor:
     dependencies:
+      '@codemirror/commands':
+        specifier: ^6.10.3
+        version: 6.10.3
+      '@codemirror/lang-css':
+        specifier: ^6.3.1
+        version: 6.3.1
+      '@codemirror/lang-html':
+        specifier: ^6.4.11
+        version: 6.4.11
+      '@codemirror/lang-javascript':
+        specifier: ^6.2.5
+        version: 6.2.5
+      '@codemirror/lang-json':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-markdown':
+        specifier: ^6.5.0
+        version: 6.5.0
+      '@codemirror/lang-python':
+        specifier: ^6.2.1
+        version: 6.2.1
+      '@codemirror/lang-xml':
+        specifier: ^6.1.0
+        version: 6.1.0
+      '@codemirror/language':
+        specifier: ^6.12.3
+        version: 6.12.3
+      '@codemirror/language-data':
+        specifier: ^6.5.2
+        version: 6.5.2
+      '@codemirror/state':
+        specifier: ^6.6.0
+        version: 6.6.0
+      '@codemirror/view':
+        specifier: ^6.43.1
+        version: 6.43.1
       '@lezer/common':
         specifier: ^1.2.2
         version: 1.5.2
@@ -264,6 +300,93 @@ packages:
     resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
     engines: {node: '>=18'}
 
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
+
+  '@codemirror/[email protected]':
+    resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==}
+
   '@csstools/[email protected]':
     resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
     engines: {node: '>=20.19.0'}
@@ -535,12 +658,57 @@ packages:
   '@lezer/[email protected]':
     resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
 
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
+
   '@lezer/[email protected]':
     resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
 
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
+
   '@lezer/[email protected]':
     resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
 
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
+
+  '@lezer/[email protected]':
+    resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
+
+  '@marijn/[email protected]':
+    resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+
   '@napi-rs/[email protected]':
     resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
     peerDependencies:
@@ -1401,6 +1569,9 @@ packages:
     resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
     engines: {node: '>=18'}
 
+  [email protected]:
+    resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
   [email protected]:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
@@ -2111,6 +2282,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
 
+  [email protected]:
+    resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
   [email protected]:
     resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
     engines: {node: '>=16'}
@@ -2624,6 +2798,251 @@ snapshots:
     dependencies:
       fontkitten: 1.0.3
 
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.3
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@lezer/cpp': 1.1.6
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/css': 1.3.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/go': 1.0.1
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/css': 1.3.3
+      '@lezer/html': 1.3.13
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@lezer/java': 1.1.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/lint': 6.9.7
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/javascript': 1.5.4
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@lezer/json': 1.0.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/language': 6.12.3
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/markdown': 1.6.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/php': 1.0.5
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/python': 1.1.19
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@lezer/rust': 1.0.2
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/sass': 1.1.0
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.3
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/xml': 1.0.6
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/state': 6.6.0
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+      '@lezer/yaml': 1.0.4
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/lang-angular': 0.1.4
+      '@codemirror/lang-cpp': 6.0.3
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/lang-go': 6.0.1
+      '@codemirror/lang-html': 6.4.11
+      '@codemirror/lang-java': 6.0.2
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/lang-jinja': 6.0.1
+      '@codemirror/lang-json': 6.0.2
+      '@codemirror/lang-less': 6.0.2
+      '@codemirror/lang-liquid': 6.3.2
+      '@codemirror/lang-markdown': 6.5.0
+      '@codemirror/lang-php': 6.0.2
+      '@codemirror/lang-python': 6.2.1
+      '@codemirror/lang-rust': 6.0.2
+      '@codemirror/lang-sass': 6.0.2
+      '@codemirror/lang-sql': 6.10.0
+      '@codemirror/lang-vue': 0.1.3
+      '@codemirror/lang-wast': 6.0.2
+      '@codemirror/lang-xml': 6.1.0
+      '@codemirror/lang-yaml': 6.1.3
+      '@codemirror/language': 6.12.3
+      '@codemirror/legacy-modes': 6.5.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+      style-mod: 4.1.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/language': 6.12.3
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/state': 6.6.0
+      '@codemirror/view': 6.43.1
+      crelt: 1.0.6
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@marijn/find-cluster-break': 1.0.2
+
+  '@codemirror/[email protected]':
+    dependencies:
+      '@codemirror/state': 6.6.0
+      crelt: 1.0.6
+      style-mod: 4.1.3
+      w3c-keyname: 2.2.8
+
   '@csstools/[email protected]': {}
 
   '@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
@@ -2827,15 +3246,99 @@ snapshots:
 
   '@lezer/[email protected]': {}
 
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
   '@lezer/[email protected]':
     dependencies:
       '@lezer/common': 1.5.2
 
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+
   '@lezer/[email protected]':
     dependencies:
       '@lezer/common': 1.5.2
       '@lezer/highlight': 1.2.3
 
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/[email protected]':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@marijn/[email protected]': {}
+
   '@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
     dependencies:
       '@emnapi/core': 1.10.0
@@ -4023,6 +4526,8 @@ snapshots:
     dependencies:
       is-what: 5.5.0
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       path-key: 3.1.1
@@ -4804,6 +5309,8 @@ snapshots:
     dependencies:
       js-tokens: 9.0.1
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       copy-anything: 4.0.5