Răsfoiți Sursa

feat(lezer-comark): create Comark block/inline component grammar for Lezer

New @enesis/lezer-comark package — standalone Lezer extensions for
Comark component syntax, with zero editor dependencies.

Adds four syntaxes to @lezer/markdown:
- ::component{attrs}...:: block components (via LeafBlockParser)
- :component{attrs} and :component[body]{attrs} inline components
- :standalone-name (no attrs, no body)
- [text]{.class #id key="val"} span attributes
- {key="val" .class #id bool} attribute parser

All syntaxes coexist with GFM — tables, task lists, strikethrough,
autolinks, and existing editor extensions remain unchanged.

37 tests across 4 test files covering: attributes, block components,
inline components (props-only, body+props, standalone, no-match
edge cases for colon text and emoji), and span attributes.
Zander Hawke 8 ore în urmă
părinte
comite
bbf2229f15

+ 3 - 3
packages/editor/package.json

@@ -70,9 +70,9 @@
     "@codemirror/state": "^6.6.0",
     "@codemirror/view": "^6.43.1",
     "@floating-ui/vue": "^2.0.0",
-    "@lezer/common": "^1.2.2",
-    "@lezer/highlight": "^1.2.3",
-    "@lezer/markdown": "^1.6.4",
+    "@lezer/common": "catalog:",
+    "@lezer/highlight": "catalog:",
+    "@lezer/markdown": "catalog:",
     "@nuxt/kit": "^4.4.8",
     "@nuxt/schema": "^4.4.8",
     "katex": "^0.17.0",

+ 153 - 0
packages/lezer-comark/README.md

@@ -0,0 +1,153 @@
+# @enesis/lezer-comark
+
+Lezer extensions for Comark block and inline component syntax.
+
+## Status
+
+Active development. The grammar is functional but not yet wired into the editor's decoration pipeline.
+
+## What it does
+
+Adds four syntaxes to `@lezer/markdown`:
+
+| Syntax | Lezer node | Example |
+|---|---|---|
+| Block component | `BlockComponent` | `::query{type="TODO"}...::` |
+| Inline component | `InlineComponent` | `:priority{value="high"}` |
+| Inline component (with body) | `InlineComponent` | `:badge[Active]{color="green"}` |
+| Span attributes | `Span` | `[text]{.class #id key="val"}` |
+
+## Usage
+
+```typescript
+import { parser } from "@lezer/markdown"
+import { GFM } from "@lezer/markdown"
+import { createComarkExtensions } from "@enesis/lezer-comark"
+
+const fullParser = parser.configure([
+  GFM,
+  ...createComarkExtensions(),
+])
+```
+
+## Package structure
+
+```
+src/
+  index.ts              createComarkExtensions() — barrel export
+  types.ts              Attrs, InlineComponentData, BlockComponentData
+  attributes.ts         parseAttrs() — parses {key="val" .class #id}
+  block-component.ts    LeafBlockParser for ::name{...}...::
+  inline-component.ts   InlineParser for :name{...} and :name[...]{...}
+  span-attr.ts          InlineParser for [text]{.class #id}
+```
+
+## Syntax reference
+
+### Block component
+
+```
+::component-name{key="val"}
+Content lines
+::
+```
+
+Children: `ComponentName`, `ComponentAttrs`, `ComponentBody`
+
+### Inline component (props only)
+
+```
+:priority{value="high"}
+```
+
+### Inline component (content + props)
+
+```
+:badge[Active]{color="green"}
+```
+
+### Span attributes
+
+```
+[highlighted text]{.highlight .yellow #ref-1}
+```
+
+Nested markdown is supported inside spans:
+
+```
+[**Bold** and *italic* text]{.emphasized}
+```
+
+### Attribute syntax
+
+| Syntax | Result |
+|---|---|
+| `{key="value"}` | `{ key: "value" }` |
+| `{boolflag}` | `{ boolflag: true }` |
+| `{.class-one .class-two}` | `{ class: "class-one class-two" }` |
+| `{#my-id}` | `{ id: "my-id" }` |
+
+## What's implemented vs deferred
+
+### ✅ Implemented
+
+| Feature | Nodes |
+|---|---|
+| `::component{attrs}...::` blocks | `BlockComponent`, `ComponentName`, `ComponentAttrs`, `ComponentBody` |
+| `:component{attrs}` inline | `InlineComponent`, `ComponentName`, `ComponentAttrs` |
+| `:component[body]{attrs}` inline | `InlineComponent`, `ComponentName`, `ComponentBody`, `ComponentAttrs` |
+| `[text]{.class #id}` spans | `Span`, `SpanBody`, `SpanAttrs` |
+| `{key="val" .class #id bool}` attrs | `parseAttrs()` utility |
+
+### ❌ Not yet implemented (future)
+
+| Feature | Priority | Notes |
+|---|---|---|
+| `:emoji:` | Low | Simple pattern extension when needed |
+| `<!-- comments -->` | Low | Simple when needed |
+| Frontmatter `---\nkey: val\n---` | Medium | Useful for property schemas in documents |
+| Block `{attrs}` on `# h1`, `- list` | Low | Trailing `{...}` on standard elements |
+| Code block `{filename}` `{highlights}` | Low | Comark code block metadata |
+| Component `#slot` | Low | Named slots for complex components |
+| YAML block props inside components | Medium | `\`\`\`yaml [props]\nkey: val\n\`\`\`` inside components |
+| Nested components `:::outer\n:::inner\n:::\n::` | Low | Component nesting |
+| Property `<`/`>`/`==` queries | Medium | Type-aware comparison ops in query attrs |
+
+## What the editor already handles (GFM + custom)
+
+The editor's `@lezer/markdown` parser is configured with `GFM` (tables, task lists, strikethrough, autolinks) plus custom extensions for page refs, block refs, tags, highlights, inline math, and task states. Comark extensions add on top of these — they don't replace them.
+
+## Roadmap
+
+### Phase 1 — Grammar (this package)
+- [x] Block component parser
+- [x] Inline component parser
+- [x] Span attribute parser
+- [x] Attrs utility
+- [x] TypeScript types
+
+### Phase 2 — Editor integration (in @enesis/editor)
+- [ ] Wire `createComarkExtensions()` into `markdown-extensions.ts`
+- [ ] Create `createBlockComponentRule()` and `createInlineComponentRule()` in the rule engine
+- [ ] Build component registry (`comark-registry.ts`)
+- [ ] Build `::note`/`::tip`/`::warning`/`::caution` callout renderers
+- [ ] Build `::query` renderer with GraphService
+- [ ] Add `:` trigger to pattern plugin for property autocomplete
+
+### Phase 3 — Typed properties
+- [ ] Add `type` column to properties table
+- [ ] Index typed properties from `:priority{type="enum" value="high"}`
+- [ ] Property schema definitions (`::schema` block or frontmatter)
+- [ ] Property autocomplete with type-aware values
+
+### Phase 4 — Advanced comark features
+- [ ] Block `{attrs}` on headings, lists, paragraphs, blockquotes
+- [ ] Element `{attrs}` on bold, italic, links, images, inline code
+- [ ] Span vs. block attribute disambiguation (`[span]{attrs}` vs `[text] {attrs}`)
+- [ ] Frontmatter parsing
+- [ ] YAML block props
+- [ ] Named slots (`#slot-name` inside block components)
+- [ ] Nested components (`:::outer\n:::inner:::`)
+- [ ] Emoji (`:smile:`)
+- [ ] Comments (`<!-- -->`)
+- [ ] Data binding (`:` prefixed attrs — semantic, not parsing)

+ 30 - 0
packages/lezer-comark/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@enesis/lezer-comark",
+  "type": "module",
+  "version": "0.1.0",
+  "description": "Lezer extensions for Comark block and inline component syntax",
+  "license": "MIT",
+  "exports": {
+    ".": {
+      "import": "./src/index.ts",
+      "types": "./src/index.ts"
+    }
+  },
+  "main": "./src/index.ts",
+  "types": "./src/index.ts",
+  "scripts": {
+    "test": "vitest run"
+  },
+  "files": [
+    "src"
+  ],
+  "peerDependencies": {
+    "@lezer/common": "catalog:",
+    "@lezer/markdown": "catalog:"
+  },
+  "devDependencies": {
+    "@lezer/common": "catalog:",
+    "@lezer/markdown": "catalog:",
+    "vitest": "catalog:"
+  }
+}

+ 56 - 0
packages/lezer-comark/src/attributes.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest"
+import { parseAttrs } from "./attributes"
+
+describe("parseAttrs", () => {
+  it("parses a key-value attribute", () => {
+    expect(parseAttrs('{type="TODO"}')).toEqual({ type: "TODO" })
+  })
+
+  it("parses multiple key-value attributes", () => {
+    expect(parseAttrs('{key="val" color="red"}')).toEqual({ key: "val", color: "red" })
+  })
+
+  it("parses a boolean flag", () => {
+    expect(parseAttrs("{disabled}")).toEqual({ disabled: true })
+  })
+
+  it("parses a class", () => {
+    expect(parseAttrs("{.highlight}")).toEqual({ class: "highlight" })
+  })
+
+  it("parses multiple classes", () => {
+    expect(parseAttrs("{.class-one .class-two}")).toEqual({
+      class: "class-one class-two",
+    })
+  })
+
+  it("parses an id", () => {
+    expect(parseAttrs("{#my-id}")).toEqual({ id: "my-id" })
+  })
+
+  it("parses mixed attributes", () => {
+    const result = parseAttrs('{key="val" .highlight #my-id boolflag}')
+    expect(result).toEqual({
+      key: "val",
+      class: "highlight",
+      id: "my-id",
+      boolflag: true,
+    })
+  })
+
+  it("handles empty attrs", () => {
+    expect(parseAttrs("{}")).toEqual({})
+  })
+
+  it("handles empty input", () => {
+    expect(parseAttrs("")).toEqual({})
+  })
+
+  it("handles hyphenated keys", () => {
+    expect(parseAttrs('{data-value="test"}')).toEqual({ "data-value": "test" })
+  })
+
+  it("parses value with colon prefix (JSON-bound)", () => {
+    expect(parseAttrs('{:count="5"}')).toEqual({ ":count": "5" })
+  })
+})

+ 36 - 0
packages/lezer-comark/src/attributes.ts

@@ -0,0 +1,36 @@
+import type { Attrs } from "./types"
+
+/**
+ * Parse a `{key="value" .class #id boolflag}` string into an Attrs object.
+ *
+ * Grammar (per comark spec):
+ *   Attrs  ::= (KeyValue | Class | Id | Bool)*
+ *   KeyValue ::= word "=" JSONString
+ *   Class    ::= "." word
+ *   Id       ::= "#" word
+ *   Bool     ::= word
+ */
+export function parseAttrs(input: string): Attrs {
+  const attrs: Attrs = {}
+  const re = /(:?[\w-]+)="([^"]*)"|\.([\w-]+)|#([\w-]+)|(\b[\w-]+\b)/g
+  let match: RegExpExecArray | null
+
+  while ((match = re.exec(input)) !== null) {
+    if (match[1] !== undefined) {
+      // key="value"
+      attrs[match[1]] = match[2]
+    } else if (match[3] !== undefined) {
+      // .class-name
+      const cls = match[3]
+      attrs.class = attrs.class ? `${attrs.class} ${cls}` : cls
+    } else if (match[4] !== undefined) {
+      // #id
+      attrs.id = match[4]
+    } else if (match[5] !== undefined) {
+      // bool flag
+      attrs[match[5]] = true
+    }
+  }
+
+  return attrs
+}

+ 78 - 0
packages/lezer-comark/src/block-component.test.ts

@@ -0,0 +1,78 @@
+import { beforeAll, describe, expect, it } from "vitest"
+import { parser } from "@lezer/markdown"
+import { GFM } from "@lezer/markdown"
+import { createComarkExtensions } from "./index"
+
+let fullParser: ReturnType<typeof parser.configure>
+
+beforeAll(() => {
+  fullParser = parser.configure([GFM, ...createComarkExtensions()])
+})
+
+function parse(text: string) {
+  const tree = fullParser.parse(text)
+  const nodes: { name: string; from: number; to: number }[] = []
+  tree.iterate({
+    enter: (node) => {
+      nodes.push({
+        name: node.type.name,
+        from: node.from,
+        to: node.to,
+      })
+    },
+  })
+  return nodes
+}
+
+function nodeNames(text: string): string[] {
+  return parse(text).map((n) => n.name)
+}
+
+describe("BlockComponent", () => {
+  it("parses a simple block component", () => {
+    const names = nodeNames("::query{type=\"TODO\"}\nResults\n::")
+    expect(names).toContain("BlockComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentAttrs")
+    expect(names).toContain("ComponentBody")
+  })
+
+  it("extracts component name", () => {
+    const nodes = parse("::note{title=\"Info\"}\nContent\n::")
+    const nameNode = nodes.find((n) => n.name === "ComponentName")
+    expect(nameNode).toBeDefined()
+    expect("::note{title=\"Info\"}\nContent\n::".slice(nameNode!.from, nameNode!.to)).toBe("note")
+  })
+
+  it("extracts attrs", () => {
+    const nodes = parse("::alert{type=\"warning\"}\nBe careful\n::")
+    const attrsNode = nodes.find((n) => n.name === "ComponentAttrs")
+    expect(attrsNode).toBeDefined()
+    expect("::alert{type=\"warning\"}\nBe careful\n::".slice(attrsNode!.from, attrsNode!.to)).toBe('{type="warning"}')
+  })
+
+  it("extracts body", () => {
+    const nodes = parse("::card\nContent here\n::")
+    const bodyNode = nodes.find((n) => n.name === "ComponentBody")
+    expect(bodyNode).toBeDefined()
+    expect("::card\nContent here\n::".slice(bodyNode!.from, bodyNode!.to)).toBe("Content here")
+  })
+
+  it("handles multi-line body", () => {
+    const names = nodeNames("::component\nLine 1\nLine 2\nLine 3\n::")
+    expect(names).toContain("BlockComponent")
+    expect(names).toContain("ComponentBody")
+  })
+
+  it("handles component with no attrs", () => {
+    const names = nodeNames("::divider\n::")
+    expect(names).toContain("BlockComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).not.toContain("ComponentAttrs")
+  })
+
+  it("handles component with no body", () => {
+    const names = nodeNames("::divider\n::")
+    expect(names).toContain("BlockComponent")
+  })
+})

+ 102 - 0
packages/lezer-comark/src/block-component.ts

@@ -0,0 +1,102 @@
+import {
+  type BlockContext,
+  type LeafBlock,
+  type LeafBlockParser,
+  type Line,
+  type MarkdownConfig,
+} from "@lezer/markdown"
+
+/**
+ * LeafBlockParser for Comark block components:
+ *
+ *   ::component-name{key="val"}
+ *   Content lines
+ *   ::
+ *
+ * Uses the same pattern as GFM's TableParser:
+ * - nextLine() always returns false — the second line is handled via endLeaf
+ * - endLeaf() accumulates body lines and detects the closing ::
+ * - finish() creates the BlockComponent node
+ */
+
+const BLOCK_COMPONENT_OPEN = /^::([\w-]+)(\{.*\})?\s*$/
+const BLOCK_COMPONENT_CLOSE = /^::\s*$/
+
+class BlockComponentParser implements LeafBlockParser {
+  bodyLines: string[] = []
+
+  constructor(
+    public name: string,
+    public rawAttrs: string,
+  ) {}
+
+  /**
+   * nextLine handles lines only in the initial leaf-parsing pass.
+   * Returning false immediately defers body accumulation to endLeaf,
+   * which is called for each subsequent line.
+   */
+  nextLine(): boolean {
+    return false
+  }
+
+  finish(cx: BlockContext, leaf: LeafBlock) {
+    const content = leaf.content
+    const children: import("@lezer/markdown").Element[] = []
+    const body = this.bodyLines.join("\n")
+
+    // ComponentName child (after "::")
+    children.push(cx.elt("ComponentName", leaf.start + 2, leaf.start + 2 + this.name.length))
+
+    // ComponentAttrs child (if present on the opening line)
+    if (this.rawAttrs) {
+      const attrsStart = leaf.start + content.indexOf(this.rawAttrs)
+      children.push(cx.elt("ComponentAttrs", attrsStart, attrsStart + this.rawAttrs.length))
+    }
+
+    // ComponentBody child (if body was accumulated via endLeaf)
+    if (body) {
+      const bodyStart = leaf.start + content.length + 1 // past opening line + \n
+      const bodyEnd = bodyStart + body.length
+      children.push(cx.elt("ComponentBody", bodyStart, bodyEnd, [
+        ...cx.parser.parseInline(body, bodyStart),
+      ]))
+    }
+
+    // BlockComponent spans the opening line (children may extend beyond this
+    // range via absolute positions — same pattern as GFM's Table parser).
+    const endPos = leaf.start + content.length
+    cx.addLeafElement(leaf, cx.elt("BlockComponent", leaf.start, endPos, children))
+    return true
+  }
+}
+
+export const blockComponentConfig: MarkdownConfig = {
+  defineNodes: [
+    { name: "BlockComponent", block: true },
+    { name: "ComponentName" },
+    { name: "ComponentAttrs" },
+    { name: "ComponentBody" },
+  ],
+  parseBlock: [
+    {
+      name: "BlockComponent",
+      leaf(_cx, leaf) {
+        const match = leaf.content.match(BLOCK_COMPONENT_OPEN)
+        return match
+          ? new BlockComponentParser(match[1]!, match[2] ?? "")
+          : null
+      },
+      endLeaf(_cx, line, leaf) {
+        const parser = leaf.parsers.find(
+          (p): p is BlockComponentParser => p instanceof BlockComponentParser,
+        )
+        if (!parser) return false
+        // Closing :: ends the block; body content extends it
+        if (BLOCK_COMPONENT_CLOSE.test(line.text)) return false
+        parser.bodyLines.push(line.text)
+        return true
+      },
+      after: "SetextHeading",
+    },
+  ],
+}

+ 38 - 0
packages/lezer-comark/src/index.ts

@@ -0,0 +1,38 @@
+/**
+ * @enesis/lezer-comark — Lezer extensions for Comark block and inline component syntax.
+ *
+ * Adds four syntaxes to `@lezer/markdown`:
+ *   ::component{key="val"}...::  → BlockComponent node
+ *   :component{key="val"}         → InlineComponent node
+ *   :component[content]{key="val"}→ InlineComponent node with body
+ *   [text]{.class #id key="val"} → Span node
+ *
+ * Each returns a `MarkdownConfig` that can be merged into the parser:
+ *
+ *   import { parser } from "@lezer/markdown"
+ *   import { GFM } from "@lezer/markdown"
+ *   import { createComarkExtensions } from "@enesis/lezer-comark"
+ *
+ *   const fullParser = parser.configure([GFM, ...createComarkExtensions()])
+ */
+
+import { blockComponentConfig } from "./block-component"
+import { inlineComponentConfig } from "./inline-component"
+import { spanAttrConfig } from "./span-attr"
+import { parseAttrs } from "./attributes"
+import type { Attrs, InlineComponentData, BlockComponentData } from "./types"
+
+export { parseAttrs }
+export type { Attrs, InlineComponentData, BlockComponentData }
+
+/**
+ * Combine all comark extensions into a single array for parser.configure().
+ * Order matters: block parsers run first, then inline parsers.
+ */
+export function createComarkExtensions() {
+  return [
+    blockComponentConfig,
+    inlineComponentConfig,
+    spanAttrConfig,
+  ]
+}

+ 106 - 0
packages/lezer-comark/src/inline-component.test.ts

@@ -0,0 +1,106 @@
+import { beforeAll, describe, expect, it } from "vitest"
+import { parser } from "@lezer/markdown"
+import { GFM } from "@lezer/markdown"
+import { createComarkExtensions } from "./index"
+
+let fullParser: ReturnType<typeof parser.configure>
+
+beforeAll(() => {
+  fullParser = parser.configure([GFM, ...createComarkExtensions()])
+})
+
+function parse(text: string) {
+  const tree = fullParser.parse(text)
+  const nodes: { name: string; from: number; to: number }[] = []
+  tree.iterate({ enter: (n) => nodes.push({ name: n.type.name, from: n.from, to: n.to }) })
+  return nodes
+}
+
+function nodeNames(text: string): string[] {
+  return parse(text).map((n) => n.name)
+}
+
+describe("InlineComponent (props only)", () => {
+  it("parses inline component with value attr", () => {
+    const names = nodeNames(":priority{value=\"high\"}")
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentAttrs")
+  })
+
+  it("extracts component name", () => {
+    const nodes = parse(":status{value=\"active\"}")
+    const nameNode = nodes.find((n) => n.name === "ComponentName")
+    expect(nameNode).toBeDefined()
+    expect(":status{value=\"active\"}".slice(nameNode!.from, nameNode!.to)).toBe("status")
+  })
+
+  it("extracts attrs", () => {
+    const nodes = parse(":due{value=\"2026-07-01\"}")
+    const attrsNode = nodes.find((n) => n.name === "ComponentAttrs")
+    expect(attrsNode).toBeDefined()
+  })
+
+  it("sits within a paragraph alongside text", () => {
+    const names = nodeNames("Task is :priority{value=\"high\"} and :status{value=\"active\"}")
+    expect(names.filter((n) => n === "InlineComponent").length).toBe(2)
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentAttrs")
+  })
+})
+
+describe("InlineComponent (body + props)", () => {
+  it("parses inline component with body and attrs", () => {
+    const names = nodeNames(":badge[Active]{color=\"green\"}")
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).toContain("ComponentBody")
+    expect(names).toContain("ComponentAttrs")
+  })
+
+  it("parses inline component with body only (no attrs)", () => {
+    const names = nodeNames(":icon[Check]")
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentBody")
+    expect(names).not.toContain("ComponentAttrs")
+  })
+
+  it("extracts body text", () => {
+    const nodes = parse(":btn[Click Me]{color=\"blue\"}")
+    const bodyNode = nodes.find((n) => n.name === "ComponentBody")
+    expect(bodyNode).toBeDefined()
+    expect(":btn[Click Me]{color=\"blue\"}".slice(bodyNode!.from, bodyNode!.to)).toBe("Click Me")
+  })
+
+  it("sits inline within a paragraph", () => {
+    const names = nodeNames("Click :btn[Submit]{type=\"primary\"} to continue.")
+    expect(names).toContain("InlineComponent")
+  })
+})
+
+describe("InlineComponent (standalone — no body, no attrs)", () => {
+  it("parses a standalone inline component", () => {
+    const names = nodeNames(":icon-check")
+    expect(names).toContain("InlineComponent")
+    expect(names).toContain("ComponentName")
+    expect(names).not.toContain("ComponentBody")
+    expect(names).not.toContain("ComponentAttrs")
+  })
+
+  it("parses standalone component within text", () => {
+    const names = nodeNames("Check out :icon-star in text.")
+    expect(names.filter((n) => n === "InlineComponent").length).toBe(1)
+  })
+})
+
+describe("InlineComponent — no match", () => {
+  it("does not match plain colon text", () => {
+    const names = nodeNames("just some text: not a component")
+    expect(names).not.toContain("InlineComponent")
+  })
+
+  it("does not match emoji syntax (single char after colon)", () => {
+    const names = nodeNames(":smile:")
+    expect(names).not.toContain("InlineComponent")
+  })
+})

+ 85 - 0
packages/lezer-comark/src/inline-component.ts

@@ -0,0 +1,85 @@
+import { type InlineContext, type MarkdownConfig } from "@lezer/markdown"
+
+/**
+ * InlineParser for Comark inline components:
+ *
+ *   :icon-check              (standalone — no body, no attrs)
+ *   :priority{value="high"}  (with attrs)
+ *   :badge[Active]{color="green"}  (with body + attrs)
+ *
+ * Produces an InlineComponent node with children:
+ *   ComponentName, ComponentBody (if [body] present), ComponentAttrs (if {attrs} present)
+ *
+ * Emoji syntax (`:smile:`) is distinguished by the trailing colon — if
+ * `:name` is followed by `:`, this parser returns -1 so the Emoji parser
+ * (if registered) can handle it.
+ */
+
+const COMPONENT_RE = /^:([\w-]+)/
+const EMOJI_TAIL_RE = /^:([\w-]+):/
+const BODY_RE = /^\[([^\]]*)\]/
+const ATTRS_RE = /^\{[^}]*\}/
+
+function parseInlineComponent(cx: InlineContext, next: number, pos: number): number {
+  if (next !== 58 /* colon */) return -1
+
+  const text = cx.slice(pos, cx.end)
+
+  // Reject if this looks like emoji syntax (:name:)
+  if (EMOJI_TAIL_RE.test(text)) return -1
+
+  const nameMatch = text.match(COMPONENT_RE)
+  if (!nameMatch) return -1
+
+  const name = nameMatch[1]!
+  let cur = pos + nameMatch[0].length
+  let bodyStart = -1
+  let bodyEnd = -1
+  let attrsStart = -1
+  let attrsEnd = -1
+
+  // Optional [body]
+  const bodyRest = cx.slice(cur, cx.end)
+  const bodyMatch = bodyRest.match(BODY_RE)
+  if (bodyMatch) {
+    bodyStart = cur + 1 // skip '['
+    bodyEnd = cur + bodyMatch[0].length - 1 // exclude ']'
+    cur += bodyMatch[0].length
+  }
+
+  // Optional {attrs}
+  const attrsRest = cx.slice(cur, cx.end)
+  const attrsMatch = attrsRest.match(ATTRS_RE)
+  if (attrsMatch) {
+    attrsStart = cur
+    attrsEnd = cur + attrsMatch[0].length
+    cur += attrsMatch[0].length
+  }
+
+  const children: import("@lezer/markdown").Element[] = [
+    cx.elt("ComponentName", pos + 1, pos + 1 + name.length),
+  ]
+
+  if (bodyStart !== -1) {
+    children.push(cx.elt("ComponentBody", bodyStart, bodyEnd))
+  }
+
+  if (attrsStart !== -1) {
+    children.push(cx.elt("ComponentAttrs", attrsStart, attrsEnd))
+  }
+
+  cx.addElement(cx.elt("InlineComponent", pos, cur, children))
+  return cur
+}
+
+export const inlineComponentConfig: MarkdownConfig = {
+  defineNodes: [
+    { name: "InlineComponent" },
+    { name: "ComponentName" },
+    { name: "ComponentBody" },
+    { name: "ComponentAttrs" },
+  ],
+  parseInline: [
+    { name: "InlineComponent", parse: parseInlineComponent },
+  ],
+}

+ 74 - 0
packages/lezer-comark/src/span-attr.test.ts

@@ -0,0 +1,74 @@
+import { beforeAll, describe, expect, it } from "vitest"
+import { parser } from "@lezer/markdown"
+import { GFM } from "@lezer/markdown"
+import { createComarkExtensions } from "./index"
+
+let fullParser: ReturnType<typeof parser.configure>
+
+beforeAll(() => {
+  fullParser = parser.configure([GFM, ...createComarkExtensions()])
+})
+
+function parse(text: string) {
+  const tree = fullParser.parse(text)
+  const nodes: { name: string; from: number; to: number }[] = []
+  tree.iterate({
+    enter: (node) => {
+      nodes.push({
+        name: node.type.name,
+        from: node.from,
+        to: node.to,
+      })
+    },
+  })
+  return nodes
+}
+
+function nodeNames(text: string): string[] {
+  return parse(text).map((n) => n.name)
+}
+
+describe("Span attributes", () => {
+  it("parses span with class", () => {
+    const names = nodeNames("[text]{.highlight}")
+    expect(names).toContain("Span")
+    expect(names).toContain("SpanBody")
+    expect(names).toContain("SpanAttrs")
+  })
+
+  it("parses span with id", () => {
+    const names = nodeNames("[text]{#my-id}")
+    expect(names).toContain("Span")
+  })
+
+  it("parses span with key-value", () => {
+    const names = nodeNames("[text]{data-value=\"test\"}")
+    expect(names).toContain("Span")
+  })
+
+  it("parses span with multiple attrs", () => {
+    const names = nodeNames("[text]{.highlight #my-id key=\"val\"}")
+    expect(names).toContain("Span")
+    expect(names).toContain("SpanAttrs")
+  })
+
+  it("sits within a paragraph", () => {
+    const names = nodeNames("This is [highlighted]{.yellow} text.")
+    expect(names).toContain("Span")
+    expect(names).toContain("SpanBody")
+    expect(names).toContain("SpanAttrs")
+  })
+
+  it("does not match when space before braces", () => {
+    // space between ] and { means block-level attribute, not span
+    const names = nodeNames("[text] {.class}")
+    expect(names).not.toContain("Span")
+  })
+
+  it("extracts body text", () => {
+    const nodes = parse("[Important]{.badge}")
+    const bodyNode = nodes.find((n) => n.name === "SpanBody")
+    expect(bodyNode).toBeDefined()
+    expect("[Important]{.badge}".slice(bodyNode!.from, bodyNode!.to)).toBe("Important")
+  })
+})

+ 82 - 0
packages/lezer-comark/src/span-attr.ts

@@ -0,0 +1,82 @@
+import {
+  type InlineContext,
+  type MarkdownConfig,
+} from "@lezer/markdown"
+
+/**
+ * InlineParser for Comark span attributes:
+ *
+ *   [text content]{.class-name #id key="val"}
+ *
+ * Produces a `Span` node with optional `SpanAttrs` child.
+ *
+ * IMPORTANT: The `{...}` must immediately follow `]` without whitespace.
+ */
+
+const SPAN_OPEN_RE = /^\[([^\]]*)\]\{/
+
+function parseSpan(cx: InlineContext, next: number, pos: number): number {
+  if (next !== 91 /* left square bracket */) return -1
+
+  const text = cx.slice(pos, cx.end)
+  const match = text.match(SPAN_OPEN_RE)
+  if (!match) return -1
+
+  const body = match[1]!
+  let cur = pos + match[0].length
+
+  // Find the closing }
+  const rest = cx.slice(cur, cx.end)
+  let depth = 1
+  let attrEnd = cur
+  for (let i = 0; i < rest.length; i++) {
+    if (rest[i] === "{") depth++
+    else if (rest[i] === "}") {
+      depth--
+      if (depth === 0) {
+        attrEnd = cur + i
+        break
+      }
+    }
+  }
+  if (depth !== 0) return -1 // unmatched braces
+
+  const rawAttrs = cx.slice(cur, attrEnd + 1)
+  cur = attrEnd + 1
+
+  const children: import("@lezer/markdown").Element[] = []
+
+  // SpanBody child (with inline markdown parsing)
+  const bodyStart = pos + 1
+  const bodyEnd = bodyStart + body.length
+  children.push(
+    cx.elt("SpanBody", bodyStart, bodyEnd, [
+      ...cx.parser.parseInline(body, bodyStart),
+    ]),
+  )
+
+  // SpanAttrs child
+  if (rawAttrs) {
+    children.push(
+      cx.elt("SpanAttrs", cur - rawAttrs.length, cur),
+    )
+  }
+
+  cx.addElement(cx.elt("Span", pos, cur, children))
+  return cur - pos
+}
+
+export const spanAttrConfig: MarkdownConfig = {
+  defineNodes: [
+    { name: "Span" },
+    { name: "SpanBody" },
+    { name: "SpanAttrs" },
+  ],
+  parseInline: [
+    {
+      name: "Span",
+      parse: parseSpan,
+      before: "Link",
+    },
+  ],
+}

+ 32 - 0
packages/lezer-comark/src/types.ts

@@ -0,0 +1,32 @@
+/**
+ * Parsed component attributes from the `{...}` syntax.
+ *
+ * Supports:
+ *   key="value"    → string value
+ *   boolflag       → boolean true
+ *   .class-name    → appended to `class` string
+ *   #my-id         → sets `id` field
+ */
+export interface Attrs {
+  id?: string
+  class?: string
+  [key: string]: unknown
+}
+
+/**
+ * A parsed inline component node from `:name{attrs}` or `:name[content]{attrs}`.
+ */
+export interface InlineComponentData {
+  name: string
+  attrs: Attrs
+  body?: string
+}
+
+/**
+ * A parsed block component node from `::name{attrs}...::`.
+ */
+export interface BlockComponentData {
+  name: string
+  attrs: Attrs
+  body: string
+}

+ 11 - 0
packages/lezer-comark/vitest.config.ts

@@ -0,0 +1,11 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+  test: {
+    include: ["src/**/*.test.ts"],
+    // Use forks (separate process per file) instead of the default threads.
+    // Multiple test files calling parser.configure() in beforeAll share
+    // Lezer's module-level node type registry, which corrupts across threads.
+    pool: "forks",
+  },
+})

+ 36 - 9
pnpm-lock.yaml

@@ -4,6 +4,21 @@ settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+catalogs:
+  default:
+    '@lezer/common':
+      specifier: ^1.2.2
+      version: 1.5.2
+    '@lezer/highlight':
+      specifier: ^1.2.3
+      version: 1.2.3
+    '@lezer/markdown':
+      specifier: ^1.6.4
+      version: 1.6.4
+    vitest:
+      specifier: ^4.1.4
+      version: 4.1.9
+
 importers:
 
   .:
@@ -185,13 +200,13 @@ importers:
         specifier: ^2.0.0
         version: 2.0.0([email protected]([email protected]))
       '@lezer/common':
-        specifier: ^1.2.2
+        specifier: 'catalog:'
         version: 1.5.2
       '@lezer/highlight':
-        specifier: ^1.2.3
+        specifier: 'catalog:'
         version: 1.2.3
       '@lezer/markdown':
-        specifier: ^1.6.4
+        specifier: 'catalog:'
         version: 1.6.4
       '@nuxt/kit':
         specifier: ^4.4.8
@@ -285,6 +300,18 @@ importers:
         specifier: ^3.3.5
         version: 3.3.5([email protected])
 
+  packages/lezer-comark:
+    devDependencies:
+      '@lezer/common':
+        specifier: 'catalog:'
+        version: 1.5.2
+      '@lezer/markdown':
+        specifier: 'catalog:'
+        version: 1.6.4
+      vitest:
+        specifier: 'catalog:'
+        version: 4.1.9(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
+
 packages:
 
   '@alloc/[email protected]':
@@ -7413,8 +7440,8 @@ snapshots:
       typescript: 6.0.3
       ufo: 1.6.4
       unplugin: 3.0.0
-      unplugin-auto-import: 21.0.0(@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected])))
-      unplugin-vue-components: 32.1.0(@nuxt/[email protected])([email protected]([email protected]))
+      unplugin-auto-import: 21.0.0(@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected])))
+      unplugin-vue-components: 32.1.0(@nuxt/[email protected]([email protected]))([email protected]([email protected]))
       vaul-vue: 0.4.1([email protected]([email protected]([email protected])))([email protected]([email protected]))
       vue-component-type-helpers: 3.3.5
     optionalDependencies:
@@ -7524,8 +7551,8 @@ snapshots:
       typescript: 6.0.3
       ufo: 1.6.4
       unplugin: 3.0.0
-      unplugin-auto-import: 21.0.0(@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected])))
-      unplugin-vue-components: 32.1.0(@nuxt/[email protected])([email protected]([email protected]))
+      unplugin-auto-import: 21.0.0(@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected])))
+      unplugin-vue-components: 32.1.0(@nuxt/[email protected]([email protected]))([email protected]([email protected]))
       vaul-vue: 0.4.1([email protected]([email protected]([email protected])))([email protected]([email protected]))
       vue-component-type-helpers: 3.3.5
     optionalDependencies:
@@ -11794,7 +11821,7 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected](@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected]))):
+  [email protected](@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected]))):
     dependencies:
       local-pkg: 1.2.0
       magic-string: 0.30.21
@@ -11811,7 +11838,7 @@ snapshots:
       pathe: 2.0.3
       picomatch: 4.0.4
 
-  [email protected](@nuxt/[email protected])([email protected]([email protected])):
+  [email protected](@nuxt/[email protected]([email protected]))([email protected]([email protected])):
     dependencies:
       chokidar: 5.0.0
       local-pkg: 1.2.0

+ 7 - 0
pnpm-workspace.yaml

@@ -1,6 +1,13 @@
 packages:
   - "apps/*"
   - "packages/*"
+
+catalog:
+  "@lezer/common": ^1.2.2
+  "@lezer/highlight": ^1.2.3
+  "@lezer/markdown": ^1.6.4
+  vitest: ^4.1.4
+
 allowBuilds:
   '@parcel/watcher': true
   better-sqlite3: true