Imported from Zuixi/oh-my-md (
packages/engine/AGENTS.md). Install upstream withnpx skills add Zuixi/oh-my-md --skill engine. Copyright stays with the author.
Engine Domain
Parent:
../../AGENTS.mdScope: Markdown parsing, CodeMirror live-preview state, decorations, block widgets, and engine tests. The package is framework-independent but browser-DOM-aware.
Read This When
- You are changing
packages/engine/src/**orpackages/engine/test/**. - You are adding or changing Markdown syntax, folded markers, widgets, or live/source behavior.
- A rendering defect appears to come from the syntax tree or decoration ranges rather than CSS.
Local Structure
packages/engine/
├── src/
│ ├── index.ts # Public editorExtensions, collectOutline, exportHtml
│ ├── outline.ts # Read-only heading outline from the Lezer tree
│ ├── docText.ts # Streamed-chunk → Text assembly (LARGE-tier open ingest; parity-guarded)
│ ├── safeModeRendering.ts # Safe-mode globals: render budget + windowed live decorations (LIVE_WINDOW_*, prune margins)
│ ├── lists/ordered.ts # Ordered-list display labels and live-preview source renumbering
│ ├── parse/ # Lezer Markdown, math, footnote, HTML-entity, and gemoji helpers
│ │ └── chars.ts # Named ASCII char-code constants (no magic numbers)
│ ├── modes/livePreview.ts # Live/source compartment and Mod-e keymap
│ ├── paste/ # Rich-HTML paste → Markdown (htmlPaste) + block-boundary normalization (blockBoundaries)
│ ├── export/html.ts # Document → HTML projection (not CM widget DOM)
│ └── decorations/
│ ├── build.ts # StateField live decorations (seed + pending model) and incremental updates
│ ├── buildDriver.ts # Idle-sliced ViewPlugin that drains LiveDeco.pending (viewport priority)
│ ├── inline.ts # Inline and marker decorations
│ ├── blocks.ts # Block detection and widget selection
│ ├── blockWidget.ts # Shared widget lifecycle and edit transition
│ ├── icons.ts # Vendored inline SVG icons (lucide/Tabler — THIRD_PARTY_NOTICES.md)
│ └── widgets/ # Code, math, table, Mermaid, and image renderers
├── test/ # Vitest tests
└── test/fixtures/ # Markdown regression inputs
Domain Boundaries
- Keep Markdown syntax knowledge, Lezer tree inspection, and Markdown-specific CodeMirror decorations in this package.
- Do not import React, Tauri APIs, desktop modules, or native filesystem APIs.
- DOM use is allowed inside CodeMirror widgets. Keep it localized and compatible with the
happy-domtest environment. - The engine emits semantic
omd-*class names; visual declarations belong inapps/desktop/src/styles.css. - Expose desktop-facing behavior through
src/index.ts:editorExtensions(options), plus read-onlycollectOutline(state)andexportHtml(state), plus the ordered-list normalization commands (getPendingOrderedListNormalization,acceptOrderedListNormalization,rejectOrderedListNormalization), plus navigation lookups (classifyLink,footnoteAt,footnoteDefinitionPosition,footnoteReferencePosition), plus table range edits (replaceTableCell,insertTableRow,insertTableColumn,deleteTableRow,deleteTableColumnfromtables/edit.ts) and the cell-escaping utilityescapeTableCellValue. The range-edit helpers are pure: each takes the table source plus table-relative model data and returnsreadonly TableSourceChange[]range edits (from/to/insert, table-relative) ornullfor stale/out-of-range input — never a serialized whole-table string.escapeTableCellValuereturns an escaped string suitable for insertion. Do not make desktop consumers assemble engine internals or re-parse Markdown. Command helpers stay pure: ordered-list normalization returns aTransactionSpec(or a"stale"result) and table edits return range changes; neither dispatches. Read pending and build accept/reject transactions in the same tick as the triggering update; stale offsets if deferred. - Keep generic host choices such as editor dimensions, base typography, history, and non-Markdown keymaps in
apps/desktop/src/Editor.ts. - Shortcut labels are derived, not duplicated. The formatting keymap is data:
markdownKeyBindings(format/commands.ts) andtoggleKeyBindings(modes/livePreview.ts) carryid/key/display/run; the CM keymap useskeyand the desktop palette imports the derivedmarkdownShortcutLabels/toggleShortcutLabelsviasrc/index.ts. Change a shortcut in the binding entry only and keepdisplaymatchingkey—test/shortcuts.test.tsguards the pair.
Ordered-list normalization invariants
orderedNormalizationStateis a top-levelStateFieldmounted directly ineditorExtensions(), never insidelivePreviewCompartment, so pending and post-reject session suppression survive Source/Live toggles.- Preview-entry batches merge under one pending id: keep the first
original, take the latestnormalized, leavemarkerCountunchanged. Follow-up batches (complete tree after a user edit) may refresh markers already pending but must not add new pending entries. rejectOrderedListNormalizationrestores first-recordedoriginalmarkers; markers the user edited in Source (or that follow-up batches overwrote) are skipped. Preview labels stay consecutive viawidget:ordered-markeven when source markers are gapped after reject.- Session-local suppression after reject prevents further auto-renumbering until a fresh
EditorState(reopen/reload). A new tab or document gets default behavior. - Compare pending notices by
id, not object reference —getPendingOrderedListNormalizationreturns a new object each call.
Decoration and Widget Invariants
-
Decorations must be sorted and non-overlapping where CodeMirror requires it.
-
When a block widget replaces a syntax node, skip that node's subtree and filter enclosed outer decorations; overlapping replace ranges can make
Decoration.setthrow. -
Provide block/replacing decorations from a
StateField, never aViewPlugin. Update incrementally from mapped unchanged ranges, changed syntax blocks, selection-adjacent lines/blocks, and syntax-tree progress. Off-screen widgets are left to CodeMirror's DOM virtualization. Rebuild ranges end atendLine.to + 1(exclusive). Treat removal overlap as half-open so the next line's point decorations stay put; dedupe additions against retained specs. Empty ranges stay closed. Equivalent widget specs must be reused, not recreated (updateLiveDecorationsremovedByKeypass): CM's heightmap treats a Decoration identity change as height-relevant and resets that region's measured heights toestimatedHeight(heightRelevantDecoChangesnever callseq) — the estimate↔measured oscillation showed up as scroll rollback and scrollbar-thumb drift. Reuse is gated onoldWidget.eq(newWidget); content edits must still rotate instances (test/specIdentity.test.ts). Everyblock: truewidget declares anestimatedHeightfromdecorations/widgetHeights.ts(test/widgetHeights.test.ts). 3a. Live decoration builds are progressive: field create/reconfigure only builds the cursor seed (seedLiveDecorations), the rest isLiveDeco.pending(sorted, disjoint, closed; point ranges are valid targets).liveBuildDriver(inlivePreviewExt(), same compartment as the field) drains pending in microtask/idle callbacks only — never dispatch insideViewPlugin.update. Never calldrainPendingLiveBuildfrom production code; it is the synchronous test-only drain. 3b. Safe-mode windowing (src/safeModeRendering.ts, Task 3): whensetSafeModeRendering(true), the driver no longer drains to completion — it builds only pending inside the build window (each visible range ±LIVE_WINDOW_CHARS) and, after viewport/doc/selection changes, dispatcheslivePruneOutsidewith the prune window (±LIVE_WINDOW_CHARS+LIVE_PRUNE_MARGIN_CHARS; the margin is hysteresis so edge decorations do not flip between built and pending). The field removes specs/deco/atomic entries entirely outside every prune region and returns their line-aligned spans to pending; decorations partially overlapping a region stay. Default off keeps Task 2 drain-to-completion semantics byte-identical. Consequence of 3a+3b: decorations far from the viewport may be absent at any moment — production code must never assume a decoration exists outside the viewport window, and tests needing a complete build use the test-onlydrainPendingLiveBuild(see the「Decorations are seeded and windowed」entry indocs/memory/gotchas-engine.md). -
Preserve the source document, except for live-preview ordered-list marker normalization in
lists/ordered.ts. That rewrite must stay reversible: every batch carries a trigger fromnormalizationTrigger(preview entry while the syntax tree does not yet cover the document, follow-up only for a complete-tree pass after a user edit) and records the replaced markers inorderedNormalizationState, which is mounted directly ineditorExtensions()(never insidelivePreviewCompartment) so a pending notice and its post-reject suppression survive Source/Live toggles. Preview-entry batches merge under one id; follow-up batches only refresh markers already pending. Never gate reversibility on pass count. A preview widget renders a projection and enters source editing from its current decoration range, never a captured constructor offset or opaque-blockposAtCoords.TableWidgetalso edits cells in place: the wrap itself never enters source (enterSourceOnClick() → false— wrap padding/border/toolbar-gap clicks must not unmount the widget to pipe soup); cell/toolbarmousedownstillstopPropagationso the wrap handler does not steal focus mid-cell-click; opening a cell editor places a caret instead of callinginput.select()(which misleadingly paints the whole cell dark blue). The explicit source entry for such blocks is the</>button (omd-block-edit, own mousedown handler inBlockWidget.toDOM). -
Treat a block as editable when the selection overlaps its source range including both boundaries and does not fully cover it (
!(sel.from <= from && sel.to >= to)). A fully-covering selection (Cmd+A, drag across, Shift+↓) keeps the widget mounted with theomd-block-coveredoverlay fromdecorations/blockSelectionOverlay.ts; only a caret entering or a partial overlap reveals source. Do not replace selected source with a widget. -
Fold
QuoteMarkunless the selection is inside>/>itself. Do not reuse line-basednearCursorfor quote markers; the cursor stays on the quote line while typing content. Inline marks inside a quote (**, links, images) also usecursorInside, so clicking quote content does not unfold syntax. A whole-line selection (triple-click) is not "inside" a mark. Nested quotes emit oneomd-blockquote-Nclass per line from the innermost quote. List indent inside a quote starts after>, never fromline.from. A list inside a quote keepsomd-li-N(hanging indent, quote bar at x=0). A quote inside a list item emitsomd-quote-in-li-Ninstead, so the bar starts at the list indent and hanging indent stays off. Fold leading spaces before>asQuoteIndent. Fenced code in Live mode: lang blocks useCodeWidgetonly while the caret is outside (blockSelected→styleEditingCodeblock, so the caret stays in native source). A fence still being typed (FencedCode covering only its opening line, caret on it) gets NO block styling — plain text until Enter;continueFenceSpec(format/fences.ts,fenceKeymapatPrec.highEnter) then completes it: inserts the closing marker line and places the caret on the content line. The command refuses for non-empty selections and never hijacks a complete block (in-quote fences complete; see the prefix rules below). Fence label + language live in the info string (parseFenceInfo/formatFenceInfo/replaceFenceInfo); never write a title as the language token. Code chrome (title, lang, copy) is always visible in BOTH states — while the widget is mounted, and while editing via theCodeChromeWidgetthat replaces the opening fence line (styleEditingCodeblock: numbered container classes, Setext-style closing-fence collapse, raw fence fallback when the caret sits on a fence line; mermaid stays plain); clicking the code body enters source at the clicked content line (span.lineindex →doc.line), dispatched on click with a 4px drift guard so drag-select stays a native widget text selection, and header chrome / read-only views never enter; metadata writes use the live widget range, never constructor offsets. Mermaid/table/math still use block widgets and inherit quote/list depth viaBlockWidget.embed; mermaid carries the sameblockSelectedgate as lang-code (a gate-less branch leaves the caret inside the replaced, invisible source range — the pencil button and ↑/↓ entry appear dead). Line starts are tree-driven:format/blockPrefix.ts::blockPrefixOfextracts the 行块前缀 (indent + eachQuoteMark/ListMark/TaskMarker+ the whitespace after it) from the Lezer tree, andformat/quotes.ts(Enter) plusformat/commands.ts::toggleQuoteSpec(Mod-Alt-9) both consume it — never reintroduce the line-start regexes ((?:> )*,startsWith("> ")) that could only see>and silently mishandled>x,> x,- > x,> - [x] y. Enter ownership ineditorExtensionsiscodeLineKeymap→codeIndentKeymap→fenceKeymap→quoteKeymap→listKeymap, all atPrec.highest:@codemirror/lang-markdown'smarkdown()pushes its ownPrec.highEnter (insertNewlineContinueMarkup) andmarkdownLanguageSupport()is the first extension, so anything belowhighestis silently shadowed (the upstream command agrees with ours on> helloand diverges on prefix-only lines,>xstyle, and in-fence carets — assert ownership signatures, seetest/enter-ownership.test.ts).continueCodeLine(a line inside a fenced code block that is not a fence line) copies the line block prefix + the current line's content indentation and adds one level when the text before the caret ends with{ ( [ :;indentCodeLine/outdentCodeLinemove that content indentation by one level on Tab / Shift-Tab.continueFenceclaims the unclosed fence line,continueQuoteany remaining line with a quote mark (a prefix-only line drops its innermost mark),continueListpure list lines (ordered-list increment, empty-item exit); each returnsfalsefor lines it does not own, so the family stays mutually exclusive. Every continuation spec sets an explicitselection(anchor = insertStart + insert.length): a collapsed caret maps through an insertion at its own position withassoc = -1and would otherwise stay on the previous line. Caret entry points that could land inside a folded prefix (mousedown viaposAtCoords, Home/Shift-Home, ArrowUp/ArrowDown after the host command) are clamped toblockPrefix.ts::lineContentStartinnavigation/caretClamp.ts— never make line-start marks atomic (see the atomicRanges rules), and never classify the whitespace beyond one space per mark as prefix: the rest is content indentation (markRemovalRange). Removing a quote mark deletes the mark plus ONE following space (markRemovalRange) — the rest of the whitespace is content indentation. Quote toggling is all-or-nothing: every selected line quoted → unquote all, otherwise quote only the unquoted lines; a per-line flip reverses already-quoted lines inside a mixed selection. The same prefix rule governs fences and code blocks:continueFenceSpeccompletes an unclosed fence on ANY line whose caret is at the line end, repeating the source before the openingCodeMarkverbatim (> ```js→> ```js\n> \n> ````) — so in-quote fences complete too, while list-item fences stay refused (list content indentation has no correct answer).isUnclosedFenceLineis what makesquoteKeymapyield, so the three Enter commands are mutually exclusive regardless of registration order.styleFencedCode(decorations/blocks.ts) branches in this order and must keep it: typing-fence plain text → mermaid widget →blockSelectedediting chrome (language-less included: the picker is the only GUI path to add a language,langToken !== "mermaid") → language-less line styling →CodeWidget. In-quote language blocks take the sameCodeWidgetpath as outside (the embed carriesomd-blockquote-N, exactly like table/math/mermaid); only the rendered state of a language-less block stays line-styled. Because.omd-codeand the editing chrome set an opaquebackgroundshorthand, the quote bar survives only through the.omd-code*.omd-blockquote-N/.omd-code-header.omd-blockquote-Nrules inapps/desktop/src/styles.cssthat repaint--omd-bq-image`. -
BlockWidget.eqcompares source text andembed. If rendering also depends on another input (lang,alt, table cells, resolver output), that input must participate in widget equality or trigger a safe rebuild. Range lookup must prefer widget identity beforeeq()because two distinct blocks can have identical source. 7a.MathBlockWidgetreuse/refresh contract — 逐键回写文档不得重建 widget DOM: itseqcomparessrc+embed(with aninstanceofguard against cross-type pairing). CodeMirror's findWidget pass 0 reuses DOM of tiles whoseeqsucceeds WITHOUT callingupdateDOM;updateDOMonly runs (pass 1, matched by constructor) wheneqFAILED. Soeqmust return false whenever the content changed, andupdateDOMis the in-place refresh path: it re-syncs the popup's inner editor (no focus guard — Undo/Redo and external edits must re-sync too; during normal typing the write-back makes the sync a no-op) and re-renders the KaTeX preview via the rAF-coalescedthrowOnError: falserender. The popup'sResizeObserveris stored on the widget and disconnected on close and indestroy(). Editing happens in a click-opened popup (omd-math-popuphosting an inner CodeMirror 6EditorViewunder.omd-math-editor):enterSourceOnClickreturnsfalse,onWrapClickopens/refocuses it; each inner change re-reads the block text from the current document viablockWidgetRange(the wrap listener'sthis.srcis stale after the first write-back) and dispatches a same-position replace; the selection stays outside the block so the widget stays mounted. The inner view instance lives on the popup DOM node (__omdMathPopupView), never on widget fields — instances rotate across pass-1 reuse. Inner extensions: stexStreamLanguagehighlight (colors reuse the--omd-syn-*tokens),lineWrapping, its ownhistory()+historyKeymap(the outer keymap never sees popup keys —ignoreEventcovers keydown/keyup/keypress/input/click and the composition family, since the inner editor is a contenteditable), plus highest-prec Escape (close) and Tab (insert spaces — unbound Tab follows browser focus-nav, blurs, and closes the popup).updateDOMsync rules: extract withmathEditorTexOf(exact inverse ofrebuildMathSrc;mathTexOf's trim eats leading/trailing spaces and would roll back Tab/indent input), clamp the caret instead of letting the full-doc replace map it to a boundary, skip whileview.composing(IME pre-edit), and skip when the live range fails to resolve — the inner dispatch → write-back → outer rebuild is re-entrant, wherethis.srcis stale,posAtDOMthrows, and the syntax tree lags the doc (liveRangeshape-validates its slice for exactly this reason); syncing on stale data rolls back fresh input. Closing (Escape or popup-levelfocusout) guards with an explicitclosedflag:inner.destroy()fires a synchronous focusout that anisConnectedcheck cannot intercept, double-destroying the view.destroy(dom)also destroys the inner view when CM discards the wrap (caret entering the block never fires focusout). Escape/blur (outside the popup) close it; read-only views never open it. -
Async block widgets must install a synchronous source-shaped placeholder during
toDOM()before CodeMirror's first layout measurement, then refresh the decoration StateField after the final DOM write. Failures must stay contained inside the widget and display actionable fallback content; they must not reject into the editor lifecycle. Instance lifetime and tile lifetime are different. CM callsdestroy(dom)when a tile is dropped, and the decoration rebuild reuses the same widget instance, sotoDOMcan run twice on one instance and the old tile'sdestroymay arrive afterwards —toDOMmust therefore reactivate the instance and re-record its DOM (BlockWidget.activateDom(wrap); overrides that build their own wrap call it directly), whiledestroy(dom)retires the instance only whendomis the current DOM (or no DOM is passed). Gating the final async write on anything else (a bareisConnected, oralivealone before this split) either drops the render forever or writes into a dead tile. Every async widget also exposes an observable degrade marker (data-omd-highlightfor code,data-omd-renderfor mermaid/math) so e2e can assert "rendered or explicitly degraded" instead of only waiting for the happy-path DOM — a silentcatch {}is what let the doc-start quoted fence stay on its placeholder unnoticed. 8a. Live code highlighting keeps the synchronous plain<pre>placeholder/fallback path even when Shiki later succeeds. The widget HTML cache stays engine-local and bounded viacreateCodeHtmlCache()(src/decorations/widgets/codeHtmlCache.ts): 128 entries, 8 MiB total, UTF-16 sizing (html.length * 2), key =${lang}:${src}. Do not replace it with an unboundedMap, and do not cache plain fallback output separately from highlighted HTML. 8b. The live code-widget Shiki loader stays lazy and single-flight: onlygetCodeHighlighter()(src/shiki/codeHighlighter.ts) mayimport()shiki/core,shiki/engine/javascript, and the GitHub themes for this path; concurrent callers share one in-flight promise, and a rejected initialization must clear that promise so the next render retries. Do not reintroduce eager top-level Shiki imports into the live widget path. -
Image paths are resolved through the
EngineOptions.resolveImageSrchost callback. The engine must not assume Tauri file URLs. -
Route A mark folding (
decorations/types.tsdocuments the split): paired emphasis marks, ATX/Setext heading marks, and list marks fold unconditionally — the caret never reveals them, so clicking text only places the caret and lines never reflow. Editing goes throughformat/commands.tstoggles, or Backspace at a folded boundary (mid-line atoms delete whole viaskipAtomic; line-start marks delete one char = progressive demotion). Syntax whose source is the sole editing entry (link URL/title, image, inline math, footnote reference/definition) reveals via node-levelcursorInside— the caret must enter that syntax's own span, never just its line (line-level reveal made any click in a soft-wrapped paragraph unfold every link on it). Gate at the whole node, never per-child: the caret in link text is inside the LinkMark span but outside the URL span, and per-child gating yields a half-revealed[text](state.cursorInsidealso serves QuoteMark, fence marks, and entities/emoji. Never reintroduce caret-driven reveal for decorative marks — that is the click-flash regression this model removed. -
Blank lines keep natural height with zero decorations: blank lines emit zero
line:omd-emptydecorations and keep full natural height at all times; this prevents viewport jitter when the caret moves onto or off an empty line. Never hide blank lines withdisplay:none— CM's line measurement and click mapping need a real box. Blank lines inside a block widget's replaced range are filtered like any other covered decoration. -
Table edits are Lezer-range-based, per-view, and byte-minimal.
tables/model.ts::tableDataFromNodeowns extractingTableDatafrom the currentTablenode: every cell/row offset is table-relative,lineFrom/lineToinclude quote/list continuation source inside the table span, an empty source-backed slot is a zero-lengthTableCellData, and only a missing ragged tail isnull.blocks.tsmust feed that model toTableWidgetand never reduce an editable table to a display-only/string model — widget range edits would then stop addressing the document. Widget continuity is perEditorView:pendingTableEdits/pendingTableToolsareWeakMap<EditorView, …>values keyed to the table's live position (livePos(), not a captured constructor offset), deleted by the rebuilt widget in its render pass and acted on in a microtask — never module-global tokens that leak across tabs. The widget stores table-relative offsets and turns them into live absolute offsets (pos + change.from/to) only insideTableWidget.replace()at dispatch; failing dispatch clears both pending maps. Cell/row/column edits preserve every source byte outside their exact ranges — spacing, one-sided outer pipes, alignment markers, and quote/list row prefixes stay untouched. TableWidget in-place refresh (pass 1). Committing one cell must not rebuild the whole table DOM:eqcomparessrc+embedand then the cheaptableEqualityKey(cell sources + aligns + shape — neverJSON.stringify, and computed lazily because the commit path short-circuits onsrc), andupdateDOM(dom, view, prev)patches only cells whosesourcechanged, refreshing alignment styles and the toolbar alignment highlight, returningfalsewhen the shape changed so CM falls back totoDOM. Because CM keeps the new widget instance while the DOM and its listeners belong to the old one (docviewfindWidget→new WidgetTile(tile.dom, length, widget, flags)), every DOM listener resolves the current owner through the module-leveldomOwnerWeakMap keyed by BOTH the wrap returned bytoDOMand the.omd-block-bodythatrenderIntoactually receives — registering only the wrap silently breaks every cell/toolbar click; never close overthis.updateDOMre-registers both keys and migratesrow/col/editingfromprev(guarding the edited cell by "the input is still in that cell", never byisConnected, since CM may diff detached DOM). Pending consumption splits by path:pendingTableEditsreopens the destination cell synchronously when the DOM survived (no microtask gap between Tab presses) while thetoDOMpath keeps its microtask;pendingTableToolsis always scheduled in a microtask becauseupdateDOMruns inside CM's update cycle, where a synchronousdispatchthrows. Cell input is never silently discarded. The cell input commits its value onfocusout, before the</>button takes the block into source (capture-phase listener on the wrap), and when another cell is clicked — the click-away path commits with the clicked cell as an explicit pending destination, because after thatdispatchthe instance is no longer the owner (updateDOMrotates it) and the pending path reopens the editor on the new owner; an unmodified cell closes without a transaction.keydownreturns early whilee.isComposing || e.keyCode === 229, so an IME candidate-confirm Enter never commits the cell; Escape keeps its cancel semantics and the subsequentfocusoutis a no-op becauseeditingis already cleared (the guard is idempotent by design).
Parsing Rules
- Extend Lezer Markdown instead of introducing a second Markdown parser.
- Preserve existing syntax limitations unless the task explicitly changes them; encode intentional limitations in tests.
- Keep format-specific parsing in
parse/and rendering decisions indecorations/. - Use the named ASCII constants in
parse/chars.ts(CHAR_EQ,CHAR_UNDERSCORE, …) when inspectingcx.char(...)— never bare character codes. - Check child and parent node behavior when adding rules; block replacements can suppress inline decorations by design.
Testing
Run:
pnpm test
pnpm --filter @omd/engine bench
pnpm testperformstsc --noEmitand then runs Vitest.pnpm --filter @omd/engine bench跑 advisory 大文档基准(typing p95 / 冷解析 / 装饰重建 / 字数统计 / 切 Live 种子(toggle.bench)/ open-live 摄入与分块 Text 对照(open.bench)/ 安全模式窗口化 typing / 表格滚动(tables.bench:高度估算增益 + 表格滚入视口成本));预算超限只告警不阻断,CI 中 continue-on-error。- Use
test/helpers.ts::makeStatewhen tests require a fully available syntax tree; it forces synchronous parsing withensureSyntaxTree. - Production code must never advance parsing to
doc.length(forceParsing/ensureSyntaxTree) — the complete-tree trap indocs/memory/gotchas-engine.mdmakes every subsequent keystroke O(fragment restart) at MB scale;apps/desktop/test/crossLayerNoFullTree.test.tsguards it. - Add focused tests for parser node shapes, decoration tags/ranges, widget editing state, and live/source round trips.
- Add a fixture when a Markdown sample is useful across snapshot or regression tests. Keep fixtures deterministic and small unless testing large-document behavior.
- Async widgets require explicit timing/error assertions; do not rely on incidental microtask completion.
- Valid Mermaid diagrams can stall in
happy-dom; mockmermaidfor success SVG and keep the real library for invalid-syntax view tests. - Incremental updates should match a full rebuild on the affected document and keep selection/local-edit spec churn far below the full spec list, plus a lenient
large.mdtiming gate. - For changes affecting appearance or interaction, also build the desktop frontend and perform the relevant items in
docs/manual-qa.md.
Common Pitfalls
- A correct parser node does not guarantee a valid decoration set; inspect overlaps and ordering separately.
- Cursor proximity and selection are part of rendering state. Test both collapsed cursors and non-empty selections.
- CodeMirror widgets create real DOM even though the package has no React dependency.
- CSS regressions cannot usually be fixed in this package because engine tests assert structure and ranges, not final desktop appearance.
- Mermaid, Shiki, and KaTeX may render asynchronously or throw on invalid source; preserve the original Markdown in error output.
- Do not add
indentOnInput, stockcloseBrackets, or genericautocompletionto compensate for preview behavior. Fix the underlying parse/decoration interaction. Two engine-owned overrides are the exceptions, and both stay in this package: the:-only emoji completion (parse/emojiComplete.ts) and the Markdown-aware pairing (format/autoPair.ts, three toggles hot-swapped per view throughsetAutoPair). Keep default word completion off increateEditor, and never reimplement pairing there. - Engine keymaps that must beat desktop
defaultKeymap(Enter,Tab,ArrowUp/ArrowDown) needPrec.high. Desktop registersdefaultKeymapbeforeeditorExtensions(), and earlier bindings win. - Selection is visual, the caret is editing:
nearCursor/cursorInsidereturn false for any non-empty selection, and a fully-covering selection keeps block widgets rendered (see known-gotchas). Never reintroduce selection-driven source reveal — it relayouts lines mid-drag and pushes pointer-selection endpoints across newlines (atomicRanges Rule 3). Under Route A (invariant 10), decorative marks also never reveal to a collapsed caret.
Documentation Maintenance
Before concluding engine work, check:
- Did public engine options or exports change?
- Did a parser or widget invariant change?
- Does a new regression belong in
docs/memory/gotchas-engine.md(plus an index line inknown-gotchas.md)? - Do fixtures, snapshots, or
docs/manual-qa.mdneed updating? - Did an approved architecture decision in the product spec change?
