Răsfoiți Sursa

feat(comark): wire component grammar into editor with rules and registry

Integrate @enesis/lezer-comark into the editor's Lezer parser, add
MarkdownRuleEngine rules for block/inline components, and create a
component registry for renderer lookup.

- markdown-extensions.ts: append createComarkExtensions() to parser
- block-rules.ts: add createBlockComponentRule() — matches BlockComponent
  nodes, extracts name/attrs/body, delegates to registry
- inline-rules.ts: add createInlineComponentRule() — emits typed properties
  from :priority{value="high"} or inline chips from :badge[Active]{...}
- comark-registry.ts: Map<string, ComarkRenderer> with register/get
- 8 integration tests verifying Lezer tree nodes through parseMarkdown()
- Fix TypeScript 6.0 erasableSyntaxOnly issues in block-component.ts
  (parameter properties → explicit fields)
- Document comark syntax in AGENTS.md with examples and renderer guide

607 editor + 129 tauri + 37 lezer-comark tests passing, build clean.
Zander Hawke 8 ore în urmă
părinte
comite
c0cf4dc83e

+ 52 - 0
AGENTS.md

@@ -290,6 +290,58 @@ Both must pass before the change is complete.
 
 ---
 
+## Comark Component Syntax
+
+Comark extends CommonMark with component syntax via Lezer extensions in `@enesis/lezer-comark`. The editor wires these into the parser through `markdown-extensions.ts`:
+
+```typescript
+import { createComarkExtensions } from "@enesis/lezer-comark"
+
+export function createMarkdownExtensions(customPatterns = []) {
+  // ...
+  return [merged, TaskBlock, ...createComarkExtensions()]
+}
+```
+
+### Available syntaxes
+
+| Syntax | Lezer node | Example |
+|---|---|---|
+| Block component | `BlockComponent` | `::query{type="TODO"}...::` |
+| Inline component (attrs) | `InlineComponent` | `:priority{value="high"}` |
+| Inline component (body+attrs) | `InlineComponent` | `:badge[Active]{color="green"}` |
+| Standalone inline | `InlineComponent` | `:icon-check` |
+| Span attributes | `Span` | `[text]{.highlight .yellow}` |
+
+The `@enesis/lezer-comark` package is standalone — it has no dependency on the editor core or ProseMirror. It exports `createComarkExtensions()` which returns an array of `MarkdownConfig` objects. These are appended to the existing GFM + custom extensions in the editor's Lezer parser.
+
+### Adding a component renderer
+
+```typescript
+import { registerComarkComponent } from "@/lib/comark-registry"
+import type { SyntaxNode } from "@lezer/common"
+
+registerComarkComponent("note", (node, ctx) => {
+  // Return a BlockDecoration to render ::note{title="..."}...::
+  // as a styled callout
+})
+```
+
+Component renderers are lookup-only — the `MarkdownRuleEngine` produces `BlockComponent`/`InlineComponent` nodes, extracts the component name from `ComponentName`, and delegates to the registered renderer.
+
+### Tests
+
+The editor's test suite at `packages/editor/src/lib/__tests__/markdown-parser.test.ts` includes comark tests that verify the Lezer tree contains the expected nodes after parsing:
+
+```typescript
+const result = parseMarkdown("::query{type=\"TODO\"}\nResults\n::")
+// result.tree contains BlockComponent, ComponentName, ComponentAttrs, ComponentBody
+```
+
+Tests for the grammar itself (position extraction, attribute parsing, multi-line bodies) live in `packages/lezer-comark/src/`.
+
+---
+
 ## Cursor Behavior — Hidden vs. Content Ranges
 
 Decorations alternate between `hidden` (delimiters) and `content` (payload). For example, in `**bold**`:

+ 1 - 0
packages/editor/package.json

@@ -70,6 +70,7 @@
     "@codemirror/state": "^6.6.0",
     "@codemirror/view": "^6.43.1",
     "@floating-ui/vue": "^2.0.0",
+    "@enesis/lezer-comark": "workspace:^",
     "@lezer/common": "catalog:",
     "@lezer/highlight": "catalog:",
     "@lezer/markdown": "catalog:",

+ 1 - 1
packages/editor/src/components/Editor.vue

@@ -623,7 +623,7 @@ defineExpose({
         @dragenter.prevent="onDragEnterZone(i, $event)"
         @dragover.prevent="onDragOverZone(i, $event)"
         @dragleave="onDragLeaveZone(i)"
-        @drop="ops.onDropOnZone(i, dragState.value)"
+        @drop="onDropOnZone(i)"
       />
       <div
         class="flex items-start group/block"

+ 1 - 0
packages/editor/src/components/EditorBlock.vue

@@ -15,6 +15,7 @@ import {
   onMounted,
   onUnmounted,
   ref,
+  type Ref,
   useTemplateRef,
   watch,
 } from "vue"

+ 1 - 1
packages/editor/src/lib/__tests__/formatting-handlers.test.ts

@@ -1,5 +1,5 @@
 import { expect, it } from "vitest"
-import { createFormattingHandlers } from "./formatting-handlers"
+import { createFormattingHandlers } from "@/lib/formatting-handlers"
 
 it("creates handlers that safely handle null view", () => {
   const view = null

+ 90 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -1033,3 +1033,93 @@ describe("tokenization depth exhaustion", () => {
     expect(result.content.length).toBeGreaterThan(0)
   })
 })
+
+describe("comark — block components", () => {
+  it("parses ::query{type=\"TODO\"} and produces BlockComponent in tree", () => {
+    const result = parseMarkdown("::query{type=\"TODO\"}\nResults\n::")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("BlockComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentAttrs")
+    expect(names).toContain("ComponentBody")
+  })
+
+  it("parses ::divider with no body or attrs", () => {
+    const result = parseMarkdown("::divider\n::")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("BlockComponent")
+  })
+})
+
+describe("comark — inline components", () => {
+  it("parses :priority{value=\"high\"} and produces InlineComponent in tree", () => {
+    const result = parseMarkdown(":priority{value=\"high\"}")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentAttrs")
+  })
+
+  it("parses :badge[Active]{color=\"green\"} with body and attrs", () => {
+    const result = parseMarkdown(":badge[Active]{color=\"green\"}")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentBody")
+    expect(names).toContain("ComponentAttrs")
+  })
+
+  it("parses :icon-check as standalone component", () => {
+    const result = parseMarkdown(":icon-check")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).not.toContain("ComponentBody")
+    expect(names).not.toContain("ComponentAttrs")
+  })
+
+  it("does not match emoji syntax (:smile:)", () => {
+    const result = parseMarkdown(":smile:")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).not.toContain("InlineComponent")
+  })
+})
+
+describe("comark — span attributes", () => {
+  it("parses [text]{.highlight} and produces Span in tree", () => {
+    const result = parseMarkdown("[text]{.highlight}")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).toContain("Span")
+    expect(names).toContain("SpanBody")
+    expect(names).toContain("SpanAttrs")
+  })
+
+  it("does not match [text] {.class} with space before braces", () => {
+    const result = parseMarkdown("[text] {.class}")
+    const names: string[] = []
+    result.tree.iterate({
+      enter: (n) => { names.push(n.type.name) },
+    })
+    expect(names).not.toContain("Span")
+  })
+})

+ 36 - 0
packages/editor/src/lib/comark-registry.ts

@@ -0,0 +1,36 @@
+/**
+ * Component registry for Comark block/inline components.
+ *
+ * Renderers are registered by component name (e.g., "query", "note")
+ * and looked up by the MarkdownRuleEngine when a BlockComponent or
+ * InlineComponent node is encountered during tree traversal.
+ *
+ * A renderer receives the Lezer SyntaxNode and RuleContext, and returns
+ * either a BlockDecoration (for block components) or null (to skip).
+ * Inline components are currently passthrough — they produce ParstedDecorations
+ * when they carry attrs (typed properties), or render as inline chips when
+ * they have body content.
+ */
+import type { SyntaxNode } from "@lezer/common"
+import type {
+  ParseContext,
+  ParsedDecorations,
+} from "@/lib/markdown-rules/types"
+
+export type ComarkRenderer = (
+  node: SyntaxNode,
+  ctx: ParseContext,
+) => Partial<ParsedDecorations> | null
+
+const comarkRegistry = new Map<string, ComarkRenderer>()
+
+export function registerComarkComponent(
+  name: string,
+  renderer: ComarkRenderer,
+) {
+  comarkRegistry.set(name, renderer)
+}
+
+export function getComarkRenderer(name: string): ComarkRenderer | undefined {
+  return comarkRegistry.get(name)
+}

+ 2 - 1
packages/editor/src/lib/markdown-extensions.ts

@@ -8,6 +8,7 @@
  * - Highlights: ^^text^^
  */
 
+import { createComarkExtensions } from "@enesis/lezer-comark"
 import type {
   BlockContext,
   InlineContext,
@@ -292,5 +293,5 @@ export function createMarkdownExtensions(
     ],
     parseInline: [...(patternExt.parseInline ?? []), inlineMathParser],
   }
-  return [merged, TaskBlock]
+  return [merged, TaskBlock, ...createComarkExtensions()]
 }

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

@@ -10,6 +10,7 @@
  */
 
 import type { SyntaxNode } from "@lezer/common"
+import { getComarkRenderer } from "@/lib/comark-registry"
 import { findChildByTypeName } from "@/lib/markdown-rules/inline-rules"
 import type {
   BlockDecoration,
@@ -316,6 +317,26 @@ export function createTableRule(): BlockRule {
 
 // ── Default block rule set ────────────────────────────────────────
 
+// ── Comark block component rule ────────────────────────────────────
+
+export function createBlockComponentRule(): BlockRule {
+  return {
+    name: "block-component",
+    nodeTypes: ["BlockComponent"],
+    match(node) {
+      return node.type.name === "BlockComponent"
+    },
+    run(node, ctx) {
+      const nameNode = findChildByTypeName(node, "ComponentName")
+      if (!nameNode) return null
+      const name = ctx.doc.slice(nameNode.from, nameNode.to)
+      const renderer = getComarkRenderer(name)
+      if (!renderer) return null
+      return renderer(node, ctx) as BlockDecoration | null
+    },
+  }
+}
+
 export function createBlockRules(): BlockRule[] {
   return [
     createBlockquoteRule(),
@@ -323,5 +344,6 @@ export function createBlockRules(): BlockRule[] {
     createTaskRule(),
     createHorizontalRuleRule(),
     createTableRule(),
+    createBlockComponentRule(),
   ]
 }

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

@@ -9,6 +9,7 @@
  * The engine shifts them to absolute document positions before use.
  */
 
+import { parseAttrs } from "@enesis/lezer-comark"
 import type { SyntaxNode } from "@lezer/common"
 import type {
   BlockRefDecoration,
@@ -308,6 +309,78 @@ export function createImageRule(): MarkdownRule<ImageDecoration> {
   }
 }
 
+// ── Inline component rule (Comark: :priority{value="high"}) ─────────
+
+export function createInlineComponentRule(): MarkdownRule<unknown> {
+  return {
+    name: "inline-component",
+    nodeTypes: ["InlineComponent"],
+    match(node) {
+      return node.type.name === "InlineComponent"
+    },
+    run(node, ctx) {
+      const nameNode = findChildByTypeName(node, "ComponentName")
+      const attrsNode = findChildByTypeName(node, "ComponentAttrs")
+      const bodyNode = findChildByTypeName(node, "ComponentBody")
+      if (!nameNode) return null
+
+      const name = ctx.doc.slice(nameNode.from, nameNode.to)
+
+      // If the component has attrs, treat it as a typed property.
+      // This feeds into Phase 2 property extraction in engine.ts,
+      // making :priority{value="high"} queryable like key:: value.
+      if (attrsNode) {
+        const rawAttrs = ctx.doc.slice(attrsNode.from, attrsNode.to)
+        const attrs = parseAttrs(rawAttrs)
+        return {
+          type: name,
+          decorationRanges: [],
+          properties:
+            attrs.value !== undefined
+              ? [
+                  {
+                    key: name,
+                    value: String(attrs.value),
+                    keyRange: [nameNode.from, nameNode.to] as [number, number],
+                    delimiterRange: [0, 0] as [number, number],
+                    valueRange: [0, 0] as [number, number],
+                  },
+                ]
+              : [],
+        }
+      }
+
+      // Standalone or body-only component: render as inline chip
+      if (bodyNode) {
+        return {
+          type: name,
+          decorationRanges: [
+            {
+              type: "content",
+              from: bodyNode.from,
+              to: bodyNode.to,
+              className: `md-${name}`,
+            },
+          ],
+        }
+      }
+
+      // Standalone with no attrs and no body (e.g. :icon-check): render as chip
+      return {
+        type: name,
+        decorationRanges: [
+          {
+            type: "content",
+            from: nameNode.from - 1,
+            to: nameNode.to,
+            className: `md-${name}`,
+          },
+        ],
+      }
+    },
+  }
+}
+
 export function createInlineRules(): MarkdownRule<unknown>[] {
   return [
     createDelimitedRule({

+ 6 - 5
packages/lezer-comark/src/block-component.ts

@@ -2,7 +2,6 @@ import {
   type BlockContext,
   type LeafBlock,
   type LeafBlockParser,
-  type Line,
   type MarkdownConfig,
 } from "@lezer/markdown"
 
@@ -23,12 +22,14 @@ const BLOCK_COMPONENT_OPEN = /^::([\w-]+)(\{.*\})?\s*$/
 const BLOCK_COMPONENT_CLOSE = /^::\s*$/
 
 class BlockComponentParser implements LeafBlockParser {
+  name: string
+  rawAttrs: string
   bodyLines: string[] = []
 
-  constructor(
-    public name: string,
-    public rawAttrs: string,
-  ) {}
+  constructor(name: string, rawAttrs: string) {
+    this.name = name
+    this.rawAttrs = rawAttrs
+  }
 
   /**
    * nextLine handles lines only in the initial leaf-parsing pass.

+ 3 - 0
pnpm-lock.yaml

@@ -196,6 +196,9 @@ importers:
       '@codemirror/view':
         specifier: ^6.43.1
         version: 6.43.1
+      '@enesis/lezer-comark':
+        specifier: workspace:^
+        version: link:../lezer-comark
       '@floating-ui/vue':
         specifier: ^2.0.0
         version: 2.0.0([email protected]([email protected]))