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.
apps/dev/ Development and testing sandbox (Vue 3 + Vite + TypeScript)
packages/editor/ Core headless engine — parsers, rule registries, composables, tests
Component flow:
apps/dev/src/pages/EditorView.vue
└── apps/dev/src/components/Editor.vue
└── packages/editor/src/components/Block.vue
├── packages/editor/src/composables/useMarkdownDecorations.ts
│ └── packages/editor/src/lib/markdown-parser.ts
│ └── packages/editor/src/lib/markdown-rules/engine.ts
│ ├── block-classifier.ts
│ ├── inline-rules.ts
│ └── block-rules.ts
├── packages/editor/src/composables/useBlockKeyboardHandlers.ts
└── packages/editor/src/composables/usePatternPlugin.ts
└── Lezer Parser Engine
All syntax features flow through a 3-stage pipeline in packages/editor/src/lib/markdown-rules/engine.ts:
[Raw Block Content String]
│
├── Phase 1: Block Classification → regex classifyBlock() on first line
├── Phase 1a: Priority Detection → separate from task state
├── Phase 2: Lezer AST Visitor → Inline Rules (Bold, Links, PageRef, Tags, Highlights)
└── Phase 3: Property Extraction → Suffix Matcher (Key::Value)
│
▼
[ParsedDecorations — unified output struct]
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.
TODO Fix the critical focus bug [#A]
└──┘ └──┘
status priority
TODO, DOING, DONE, LATER, NOW, WAITING, CANCELLEDTODO → DOING → DONE (linear). LATER, NOW, WAITING, CANCELLED are orthogonal.[#A], [#B], [#C] — independent of task state.| Syntax | Example |
|---|---|
| Page reference | [[Page Name]] or [[Real Page\|Display Name]] |
| Block reference | ((block-id-1234)) |
| Callout | > [!NOTE] Description text |
| Property | author:: John Doe |
Valid callout types: NOTE, WARNING, TIP, DANGER, INFO
| 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. |
Follow these steps exactly. Do not deviate from this pattern.
1. Identify the pipeline stage.
InlineRule (defined as MarkdownRule<unknown>)BlockRuleparseProperties() in engine.ts2. 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.
Decorations alternate between hidden (delimiters) and content (payload). For example, in **bold**:
**) → hidden when cursor is inside content (positions 2–5)bold) → content**) → hidden when cursor is inside contentRules:
hidden ranges disappear from view when the cursor is inside the enclosing content range.content ranges are always visible.hidden and content segments.A namespace-based logger system in packages/editor/src/lib/logger.ts.
| 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.
| 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 |
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)
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 visibleIf console output is too noisy, filter to info+: localStorage.setItem("editor:debug", "*") and set min level via configureDebug({ minLevel: "info" }) in DevTools.
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.
| Factory | Purpose | Returns |
|---|---|---|
createPairRule(open, close, opts?) |
Brackets (), [], {}, "", '' |
AutoCloseRule |
createToggleRule(delimiter, opts?) |
Multi-char toggles **, ~~ |
AutoCloseRule |
createPairRule behavior( inserts () with cursor between) when next char is ) moves cursor past it (only when no text selected)( with selected text wraps it as (selected) with cursor after )() deletes both characters" only after whitespace/structural chars)createToggleRule behavior**, ~~, not * or ~)wordBoundary: true prevents mid-word triggering| 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 |
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.
Block.vue instances.visitTree processes each Lezer node exactly once. Do not introduce nested tree walks.Read-only packed files. Aggregated repo views from tools like Repomix or
workspace-packare transport-only. Never write output targeting packed container files. Always emit changes to original source paths underapps/orpackages/.Rule isolation. Never modify
MarkdownRuleEngine.parse()to add syntax support. New syntax belongs in a newInlineRuleorBlockRulefactory, registered in the constructor.Test coverage is required. A rule without a mirror test in
__tests__/markdown-parser.test.tsis not complete.Strict types. Do not introduce
any. If a type is genuinely unknown, model it explicitly with a discriminated union orunknownwith a type guard.