AGENTS.md 17 KB

AGENTS.md

System Intent & Context

This repository is a high-performance, rule-based block markdown editor built with Vue 3, TypeScript, and the Lezer parsing framework. Documents are structured as distinct, interactive Blocks rather than a single flat string. A 3-stage parsing pipeline bridges UI state down to ASTs, enabling real-time extraction of pages, references, task state, callouts, and inline decorations.


Monorepo Structure

apps/dev/          Development and testing sandbox (Vue 3 + Vite + TypeScript)
packages/editor/   Core headless engine — parsers, rule registries, composables, tests

Component flow (editor shell):

apps/dev/src/pages/EditorView.vue
  └── apps/dev/src/components/Editor.vue
        └── packages/editor/src/components/Editor.vue    ← multi-block shell
              ├── packages/editor/src/components/InsertionZone.vue
              └── packages/editor/src/components/Block.vue
                    ├── packages/editor/src/composables/useMarkdownDecorations.ts
                    │     └── packages/editor/src/lib/markdown-parser.ts
                    │           └── packages/editor/src/lib/markdown-rules/engine.ts
                    │                 ├── block-classifier.ts  (paste only)
                    │                 ├── inline-rules.ts
                    │                 └── block-rules.ts
                    ├── packages/editor/src/composables/useBlockKeyboardHandlers.ts
                    └── packages/editor/src/composables/usePatternPlugin.ts

Parsing Pipeline

All syntax features flow through a 2-stage pipeline in packages/editor/src/lib/markdown-rules/engine.ts:

[Raw Block Content String]
       │
       ├── Phase 1: Lezer AST Visitor
       │     ├─ enter → block rules (headings, blockquotes, callouts, tasks)
       │     └─ leave → inline rules (bold, italic, code, links, refs, tags, highlights)
       │
       ├── Phase 2: Property Extraction    →  Suffix Matcher (Key::Value)
       │
       ▼
[ParsedDecorations — unified output struct]

Note: classifyBlock() in block-classifier.ts is used only by the paste handler (usePasteHandler.ts), not by the main decoration engine. Priority detection ([#A], [#B], [#C]) was removed from the parser; these are treated as plain text.

Unified Output Contract

export interface DecorationRange {
  type: "hidden" | "content"
  from: number
  to: number
  className?: string
}

export interface ParsedDecorations {
  content: (TokenDecoration | PageRefDecoration | BlockRefDecoration | TagDecoration | HighlightDecoration)[]
  properties: PropertyInfo[]
  blocks: BlockDecoration[]
}

Do not change this interface without updating both unit tests and block render cycles.


Supported Syntax

Task States and Priorities

TODO Fix the critical focus bug [#A]
└──┘                            └──┘
status                          priority
  • Valid states: TODO, DOING, DONE, LATER, NOW, WAITING, CANCELLED
  • State progression: TODO → DOING → DONE (linear). LATER, NOW, WAITING, CANCELLED are orthogonal.
  • Valid priorities: [#A], [#B], [#C] — independent of task state. Priority tokens are plain text in the parser; they are not extracted as decorations.

Obsidian-Style Elements

| Syntax | Example | |---|---|---| | Page reference | [[Page Name]] or [[Real Page\|Display Name]] | | Block reference | ((block-id-1234)) | | Callout | > [!NOTE] Description text | | Property | author:: John Doe | | Table | \| A \| B \|\n\|---\|---\|\n\| 1 \| 2 \| |

Tables

Tables use a two-state rendering pattern: blurred → source hidden, HTML widget rendered via renderTableHtml()renderCellContent() (runs each cell through the Lezer parser for inline markdown → HTML conversion); focused → | pipes styled as md-decorator, cell content editable as raw markdown. Clicking the rendered table focuses the block.

Valid callout types: NOTE, WARNING, TIP, DANGER, INFO


Technology Stack

Layer Technology Rule
Framework Vue 3 (Composition API, <script setup>) Use composables for local state slices (e.g. useBlockKeyboardHandlers).
Type Safety TypeScript (strict: true) No implicit any. Use strict sum-types for internal configs like TaskState.
AST Parsing @lezer/common / Lezer Prefer specific SyntaxNode targeting over brute-force regex on blocks.
Formatting Biome Run biome check before committing.
Testing Vitest Run vitest run to verify. Every new rule must have a mirror test.

How to Add a New Rule

Follow these steps exactly. Do not deviate from this pattern.

1. Identify the pipeline stage.

  • Inline syntax (spans within a line, e.g. bold, links) → InlineRule (defined as MarkdownRule<unknown>)
  • Line/block syntax (whole-line patterns, e.g. headings, blockquotes) → BlockRule
  • Key-value extraction → add to parseProperties() in engine.ts

2. Implement a self-contained factory function in the appropriate file under packages/editor/src/lib/markdown-rules/. Do not modify MarkdownRuleEngine.parse().

// Example: a minimal InlineRule factory (add to inline-rules.ts)
function createMyInlineRule(): MarkdownRule<unknown> {
  return {
    name: "my-rule",
    // Lezer node type(s) this rule targets
    nodeTypes: ["MyNode"],
    match(node) {
      return node.type.name === "MyNode"
    },
    run(node, ctx) {
      return [{
        type: "content",
        from: node.from,
        to: node.to,
        className: "my-decoration",
      }]
    },
  }
}

3. Register it in the MarkdownRuleEngine constructor in engine.ts.

constructor() {
  this.inlineRules = [
    ...createInlineRules(),
    createMyInlineRule(), // ← add here
  ]
}

4. Write a mirror test in packages/editor/src/lib/__tests__/markdown-parser.test.ts. Follow the existing pattern:

describe("MyInlineRule", () => {
  it("decorates correctly", () => {
    const result = engine.parse("some ~example~ text")
    expect(result.content).toContainEqual({
      type: "content",
      from: 5,
      to: 12,
      className: "my-decoration",
    })
  })
})

5. Verify cursor behavior. For any rule with hidden ranges (e.g. delimiters that hide when cursor is inside): confirm that from/to offsets are computed relative to the block's document offset, not the raw fragment string. Check the existing createDelimitedRule implementation as the reference.

6. Run checks.

biome check packages/editor/src/lib/markdown-rules/
vitest run

Both must pass before the change is complete.


Cursor Behavior — Hidden vs. Content Ranges

Decorations alternate between hidden (delimiters) and content (payload). For example, in **bold**:

  • Positions 0–1 (**) → hidden when cursor is inside content (positions 2–5)
  • Positions 2–5 (bold) → content
  • Positions 6–7 (**) → hidden when cursor is inside content

Rules:

  • hidden ranges disappear from view when the cursor is inside the enclosing content range.
  • content ranges are always visible.
  • Never update extraction slices without verifying that cursor offset tracking remains accurate for both the hidden and content segments.

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

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:

// 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.


Auto-Close Plugin

Defined in packages/editor/src/lib/auto-close-plugin.ts. A ProseMirror Plugin that intercepts text input (handleTextInput) and backspace (handleDOMEvents.keydown) to auto-close bracket pairs, toggle delimiters, and create code blocks.

Factories

Factory Purpose Returns
createPairRule(open, close, opts?) Brackets (), [], {}, "", '' AutoCloseRule
createToggleRule(delimiter, opts?) Multi-char toggles **, ~~ AutoCloseRule

createPairRule behavior

  • Auto-close: typing ( inserts () with cursor between
  • Skip-over: typing ) when next char is ) moves cursor past it (only when no text selected)
  • Wrap selection: typing ( with selected text wraps it as (selected) with cursor after )
  • Delete-pair: Backspace between () deletes both characters
  • Context option: regex filters when auto-close fires (e.g. " only after whitespace/structural chars)

createToggleRule behavior

  • Requires multi-character delimiter (**, ~~, not * or ~)
  • Typing the last character of the delimiter when prefix already present inserts delimiter pair, cursor between
  • Word boundary: optional wordBoundary: true prevents mid-word triggering

Structural rules (built-in)

Rule Trigger Action
tripleBacktickRule `at paragraph start on third ` Replaces paragraph with code_block node, cursor after opening fence
doubleAsteriskRule ** (via createToggleRule) Inserts ****, cursor between
doubleTildeRule ~~ (via createToggleRule) Inserts ~~~~, cursor between
singleUnderscoreRule _ at word boundary Inserts __, cursor between; no auto-close mid-word

Registration

In Block.vue, pass rules to autoClosePlugin([...]) in the EditorState's plugins array. Rules are evaluated in order; the first matching rule handles the event.


Code Block Navigation Architecture

Code blocks use a delegate pattern to decouple CM6 from PM document mutations. This is necessary because stopEvent() returns true in CodeBlockView — PM never sees keystrokes inside the node view.

Data flow

CM6 keymap handler
       ↓
CodeBlockView emits intent via CodeBlockNavigationDelegate
       ↓
Block.vue handles the delegate (creates PM transactions)
       ↓
(planned) Editor shell → insertion zones replace direct mutations

CodeBlockNavigationDelegate interface

Defined in packages/editor/src/composables/useCodeBlockView.ts:23:

interface CodeBlockNavigationDelegate {
  requestNavigateOut(direction: "up" | "down", nodePos: number): void
  requestRemoveBlock(nodePos: number): void
  requestChangeLanguage(newLanguage: string, body: string, nodePos: number): void
}

Each method receives nodePos — the PM document position of the code_block node at call time — so the delegate can resolve neighbours without depending on PM's NodeView.getPos() lifecycle.

Responsibility split

Layer Role
CM6 keymap Detects boundary condition (cursor at first/last char, empty doc) and calls the delegate.
CodeBlockView Constructs the CM6 EditorState, syncs content bidirectionally, delegates navigation intent. Never mutates PM document structure directly.
Block.vue Creates the delegate closure with access to the PM EditorView. Dispatches PM transactions to implement navigation, removal, and language changes.
(future) Editor shell Will own insertion zones (click targets between blocks) and handle navigation to empty zones by materializing paragraphs.

Document boundary rules

All materialization of paragraphs at boundaries is temporary — handled in Block.vue behind // FIXME: Replace with Editor shell insertion zone. markers.

Direction Boundary Behavior
ArrowUp Code block is first node (nodePos === 0) Insert paragraph at position 0, focus it.
ArrowUp Neighbour paragraph exists before Exit to it via TextSelection.near / GapCursor.
ArrowDown Code block is last node (targetPos > doc.content.size) Insert paragraph at end of doc, focus it.
ArrowDown Neighbour paragraph exists after Exit to it via TextSelection.near / GapCursor.
Backspace Code block is empty (body.length === 0) Remove block (replace with paragraph if single child).
Backspace Code block has content Normal CM6 deletion — no delegate call.

Fence separation

Code block content is stored in PM as a single text node with fence markers (language\n\n). The CM6 EditorState receives only the body via stripFences() — fences are stripped on the way in and re-added on the way out via addFences().

// PM node text:  "```typescript\nconst x = 1\n```\n"
// CM6 doc:       "const x = 1\n"
const body = stripFences(pmNode.textContent).body
// ...
const fenced = addFences(cmBody, language) // "```typescript\nconst x = 1\n```\n"

This prevents CM6 from parsing fence backticks as template literals.


Performance Constraints

  • Target < 16ms per parse cycle (60fps budget). The parser runs on every keystroke in focused Block.vue instances.
  • visitTree processes each Lezer node exactly once. Do not introduce nested tree walks.
  • Avoid allocations inside hot paths — reuse range objects where possible.
  • No incremental Lezer parsing. invalidateChangedCaches always passes undefined as the old tree. Incremental parse fragments prevent GFM from re-classifying ParagraphTable when the delimiter line is typed later. Full re-parse is negligible for blocks under 100 chars.

Environment

GNU sed, not BSD sed. This is macOS but sed refers to GNU sed (installed via Homebrew). Use sed -i without an empty-string argument for in-place edits, e.g. sed -i 's/foo/bar/' file. Do not use sed -i '' (that's BSD sed syntax).


Critical Constraints

Read-only packed files. Aggregated repo views from tools like Repomix or workspace-pack are transport-only. Never write output targeting packed container files. Always emit changes to original source paths under apps/ or packages/.

Rule isolation. Never modify MarkdownRuleEngine.parse() to add syntax support. New syntax belongs in a new InlineRule or BlockRule factory, registered in the constructor.

Test coverage is required. A rule without a mirror test in __tests__/markdown-parser.test.ts is not complete.

Strict types. Do not introduce any. If a type is genuinely unknown, model it explicitly with a discriminated union or unknown with a type guard.