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.GNU sed, not BSD sed. This is macOS but
sedrefers to GNU sed (installed via Homebrew). Usesed -iwithout an empty-string argument for in-place edits, e.g.sed -i 's/foo/bar/' file. Do not usesed -i ''(that's BSD sed syntax).
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.