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 2-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 (editor shell):
apps/dev/src/pages/editor-shell.vue
└── apps/dev/src/components/Editor.vue
└── packages/editor/src/components/Editor.vue ← multi-block shell
├── packages/editor/src/components/EditorInsertionZone.vue
└── packages/editor/src/components/EditorBlock.vue ← self-contained block
├── packages/editor/src/components/EditorSuggestionMenu.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
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()inblock-classifier.tsis used only by the paste handler (usePasteHandler.ts), not by the main decoration engine.
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
└──┘
status
TODO, DOING, DONE, LATER, NOW, WAITING, CANCELLEDTODO → DOING → DONE (linear). LATER, NOW, WAITING, CANCELLED are orthogonal.| 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 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
| 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. |
There are three registration points in the pipeline. Every new syntax touches all three.
| # | File | Purpose |
|---|---|---|
| 1 | markdown-extensions.ts |
Define the Lezer node type via MarkdownConfig (so the parser produces AST nodes for your syntax) |
| 2 | inline-rules.ts / block-rules.ts |
Create a MarkdownRule<unknown> or BlockRule factory that matches the new node type and returns decorations |
| 3 | engine.ts (or consumer code) |
Register the rule via the extraInlineRules/extraBlockRules constructor param, or add to createInlineRules()/createBlockRules() for built-in syntax |
Follow the example below — it adds a ::foo:: inline syntax end to end.
markdown-extensions.ts)If your syntax isn't already a standard GFM node, add a MarkdownPattern entry to defaultPatterns or createMarkdownExtensions():
// markdown-extensions.ts — add to defaultPatterns array
{
name: "FooMarker",
start: "::",
end: "::",
nodeType: "FooMarker",
decoration: {
contentClass: "md-foo",
hiddenClass: "md-hidden",
},
}
This creates a "FooMarker" Lezer inline node for text between :: delimiters. For block-level syntax (e.g. ::component{attrs}), use a LeafBlockParser instead — see the existing TaskParser or MdcParser in the MDC plan.
Reference: @lezer/markdown MarkdownConfig API
inline-rules.ts or block-rules.ts)Build a factory function that matches your Lezer node type and returns DecorationRange objects:
// inline-rules.ts
function createFooRule(): MarkdownRule<unknown> {
return {
name: "foo",
nodeTypes: ["FooMarker"],
match(node) {
return node.type.name === "FooMarker"
},
run(node, ctx) {
// Return two ranges: hidden delimiters + visible content
return [
{ type: "hidden", from: node.from, to: node.from + 2, className: "md-hidden" },
{ type: "content", from: node.from + 2, to: node.to - 2, className: "md-foo" },
{ type: "hidden", from: node.to - 2, to: node.to, className: "md-hidden" },
]
},
}
}
For block-level syntax, create a BlockRule instead (see createHeadingRule, createTableRule in block-rules.ts).
For external/plugin code (recommended — no source changes needed):
import { MarkdownRuleEngine } from "@enesis/editor"
const engine = new MarkdownRuleEngine({
extraInlineRules: [createFooRule()],
// extraBlockRules: [createMyBlockRule()],
})
For built-in syntax (modify engine.ts):
// engine.ts constructor
this.inlineRules = [
...createInlineRules(),
createFooRule(), // ← add here
]
Add a test in packages/editor/src/lib/__tests__/markdown-parser.test.ts:
describe("FooRule", () => {
it("decorates foo content correctly", () => {
const result = engine.parse("hello ::world:: foo")
expect(result.content).toContainEqual({
type: "content",
from: 8,
to: 13,
className: "md-foo",
})
})
})
For any rule with hidden ranges: confirm that from/to offsets are computed relative to the block's document offset, not the raw fragment string. Check createDelimitedRule as the reference.
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 |
EditorBlock.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 EditorBlock.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 EditorBlock.vue, pass rules to autoClosePlugin([...]) in the EditorState's plugins array. Rules are evaluated in order; the first matching rule handles the event.
Each EditorBlock.vue renders its own EditorSuggestionMenu instance inline (not teleported). The menu is positioned with position: absolute inside a position: relative wrapper so it scrolls naturally with content.
PM input → pattern plugin detects trigger (/ [[ (( #)
→ onPatternOpen callback
→ localSession ref set in EditorBlock
→ EditorSuggestionMenu becomes visible
→ capture-phase keydown listener registered on document
Keyboard navigation is not handled by useKeyboard in the Editor shell. Each EditorBlock.vue registers its own capture-phase listener when its localSession is active:
| Key | Action |
|---|---|
ArrowDown |
Highlight next item (wraps) |
ArrowUp |
Highlight previous item (wraps) |
Enter |
Select highlighted item |
Escape |
Close menu, cancel session |
The Editor's useKeyboard only handles Escape (close-palette), Mod+Z (undo), and Mod+Shift+Z (redo). Arrow navigation keys pass through the Editor's handler (the keybinding returns null for them) and are caught by the focused Block's capture listener.
A passive window scroll listener calls view.coordsAtPos(session.range.from) on scroll and converts viewport coords to wrapper-relative coords by subtracting getBoundingClientRect() of the positioned wrapper. This keeps the menu attached to the cursor position during scroll.
The onSelect callback in each menu item calls payload.respond({ text, mode: "replace" }) which replaces the trigger range ([[query) with the full syntax ([[Selected Item]]). The trigger from the payload is included in the replacement text so delimiters aren't stripped.
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.
CM6 keymap handler
↓
CodeBlockView emits intent via CodeBlockNavigationDelegate
↓
EditorBlock.vue handles the delegate (creates PM transactions)
↓
(planned) Editor shell → insertion zones replace direct mutations
CodeBlockNavigationDelegate interfaceDefined 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.
| 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. |
| EditorBlock.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. |
All materialization of paragraphs at boundaries is temporary — handled in EditorBlock.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. |
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.
EditorBlock.vue instances.visitTree processes each Lezer node exactly once. Do not introduce nested tree walks.invalidateChangedCaches always passes undefined as the old tree. Incremental parse fragments prevent GFM from re-classifying Paragraph → Table when the delimiter line is typed later. Full re-parse is negligible for blocks under 100 chars.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.
__testUtils__)Injected in Editor.vue when testMode prop is true (gated by ?e2e=true).
selectText position conventionselectText(blockId, from, to) treats from/to as 0-based character offsets within the block's content string (what docToContent returns). Internally these are mapped to PM positions by iterating through all textblock nodes via doc.descendants() and accounting for \n\n separators between paragraphs. This correctly handles multi-paragraph blocks (e.g. doc(paragraph("abc"), paragraph("def")) maps char offset 5 to PM position 6 = "d" in the second paragraph). Position 0 is before the first paragraph node and resolves outside any textblock, causing currentBlock() to return null.
focusAtEnd / focusAtStartpage.keyboard.type() inserts at the cursor. But focus() on a contenteditable leaves the cursor at a browser-dependent position (often position 0), and press("End") only reaches line-end, not doc-end. This causes text to land at the wrong position in a block.
Use focusAtEnd(blockId) or focusAtStart(blockId) instead to set cursor position via PM's selection API before typing:
const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
const lastId = blockIds![blockIds!.length - 1]
await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
await page.keyboard.type("hello world") // reliably appends at end of block
Both helpers call view.focus() + view.dispatch(tr.setSelection(...)) — absolute position, no browser quirk.
page.keyboard.type() vs __testUtils__| Action | Method | Reason |
|---|---|---|
| Set exact text content | insertText() |
Fast, bypasses plugins |
| Replace entire block content | replaceContent() |
Single PM transaction — deterministic undo history |
| Select precise range | selectText() |
Unreliable via Playwright in contenteditable |
| Caret positioning before typing | focusAtEnd() / focusAtStart() |
Browser focus() + End is unreliable |
Typing [[, ((, /, # |
page.keyboard.type() |
Must go through handleTextInput → pattern plugin |
Typing ( for auto-close |
page.keyboard.type() |
Must go through auto-close handler |
| Typing ` | page.keyboard.type() |
Must go through tripleBacktickRule |
| Paste | page.keyboard.press('Control+V') |
Must go through paste plugin |
The @enesis/tauri app adds page-level navigation and editing on top of the core editor. The page model lives in apps/tauri/src/.
A single catch-all route [...slug].vue maps workspace-relative file paths to views:
| Route | Classifier | Component |
|---|---|---|
/ |
index.vue |
Journal feed (unchanged) |
/journals/2026-06-22.md |
starts with journals/, has .md |
PageView |
/pages/Architecture.md |
default case | PageView |
/tag/design |
starts with tag/, no .md |
TagView |
/Readme.md |
default case | PageView |
The file path IS the URL — no fragile ID scheme. encodeURIComponent is used for path segments in routes.
Click [[Page Name]] in editor
↓
EditorBlock click handler (packages/editor/src/components/EditorBlock.vue:756)
emits "page-ref-clicked" { pageName, alias }
↓
Event bubbles through Editor → JournalDay → JournalView (tauri)
↓
usePageNavigation().navigateToPage(pageName)
├── Look up title in SQLite index → SELECT path FROM pages WHERE title = ?
├── If found: router.push(`/${path}`)
└── If not found: construct conventional path (pages/<safeName>.md), push.
No file is created on disk. (D4 change: creation is lazy.)
pages/[...slug].vue)User types in editor
↓
localContent ref in PageView updates via v-model:content
↓
watch(localContent) → emit("update:content", val)
↓
[...slug].vue sets content = $event
↓
watch(content) → 500ms debounce → fs.writeFile(fullPath, val)
↓
existsOnDisk = true
↓
ws.indexFile(relativePath, val)
↓
scanFiles() → sidebar refreshes
Key behaviors:
readFile throws), existsOnDisk stays false. Empty content on a non-existent file is never written.onUnmounted) so quick navigation doesn't lose data.@tauri-apps/plugin-fs. If no content exists yet, just navigates to the new path.The indexer operates on two paths:
syncIndex (startup) |
fullReindex (File → Reindex Library) |
|
|---|---|---|
| Intent | Fast incremental sync | Complete rebuild |
| Wipes first? | No — keeps existing data | Yes — DELETE FROM pages |
| Unchanged files | Skip (hash match) | Always re-indexed |
| Deleted files | Cleaned up | Cleaned up |
| When | Every app launch | User-initiated (Cmd+Shift+R) |
syncIndex reads every .md file, computes its djb2 content hash, and compares against pages.content_hash. If the hash matches, the file is skipped entirely — no splitMarkdownIntoBlocks, no block diff, no SQL writes. Files not on disk are cascade-removed from the index.
Watcher race: The file watcher starts before sync completes. If a file changes mid-sync, the watcher fires independently. Worst case: a stale entry that corrects on the next change.
lib/indexer.ts uses computePageTitle() from lib/parse.ts to guarantee every indexed page has a non-empty title:
extractTitle(content) → first # Heading 1, or ""
↓
"" → filePath.split("/").pop().replace(".md", "") → filename stem
↓
Always non-empty → stored in pages.title
This makes two critical queries reliable:
useSidebarIndex): SELECT title FROM pages WHERE title != '' — no page is invisible.usePageNavigation): SELECT path FROM pages WHERE title = ? — [[New Page]] resolves correctly even without a heading.| Component | Role | Props in | Events out |
|---|---|---|---|
[...slug].vue |
Route controller: classify, load file, query backlinks, auto-save, rename | route params | — |
PageView.vue |
Presentation: title input, editor, backlinks panel | path, filename, content, backlinks |
update:content, rename, backlink-clicked |
TagView.vue |
Virtual tag aggregation from index | tag |
— |
usePageNavigation |
Shared nav logic | — | — |
modified_at is index-time (not file mtime), making block ordering unreliableString.slice() can split 4-byte Unicode characters[[links]] in other filespnpm --filter @enesis/tauri test # or vitest run in apps/tauri
biome check apps/tauri/src/ # run from project root