Architecture
June 10, 2026 · View on GitHub
Source layout
Sources/
├── MarkdownEngine/ # core target — zero deps
│ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme
│ ├── Services/ # 4 protocols, no-op defaults, WikiLinkService
│ ├── Parser/ # two-phase AST: BlockParser → InlineParser → DocumentAST (+ token projection)
│ ├── Styling/ # MarkdownASTStyler (AST walk) + MarkdownStyler facade for NSImage passes
│ ├── Renderer/ # LayoutBridge, MarkdownTextLayoutFragment, EmbeddedImageCache
│ ├── Input/ # MarkdownInputHandler + MarkdownListHandler
│ ├── TextView/
│ │ ├── NativeTextViewWrapper.swift # SwiftUI entry point (NSViewRepresentable)
│ │ ├── NativeTextViewContainer.swift # the scroll view's documentView: header band + text column stacking
│ │ ├── ScrollingHeaderController.swift # scroll-away header: hosting, collapse/expand, teardown
│ │ ├── ClampedScrollView.swift # scroll range clamped to real content height
│ │ ├── NativeTextView/ # AppKit subclass + UX extensions (paste, drag-select, …)
│ │ └── Coordinator/ # NSTextViewDelegate split by concern (restyling, find, …)
│ └── MarkdownEngine.docc/ # DocC catalog
├── MarkdownEngineCodeBlocks/ # opt-in SPM product — pulls in HighlighterSwift
│ └── HighlighterSwiftBridge.swift # SyntaxHighlighter conformance
└── MarkdownEngineLatex/ # opt-in SPM product — pulls in SwiftMath
└── SwiftMathBridge.swift # LatexRenderer conformance
The rest of this file is a per-directory tour, in the order text flows through the engine.
Parser/: text → AST → tokens
A two-phase AST pipeline following CommonMark's model — block structure first, inline content second. There is no regex tokenizer anymore; the structural regexes are gone, replaced by hand-written scanners and a real syntax tree.
BlockParsersplits the document into a flat, gap-free (tiling) sequence ofBlocks:heading,paragraph,blockquote,list,fencedCode,blockLatex,table,thematicBreak,blank. Hand-written line scanners. It memoizes the last parse (UTF-16 buffer cache) so the per-keystroke callers share one line-scan.InlineParserturns a single inline-bearing block's text into an inline AST ([InlineNode]) with correct CommonMark precedence: code spans → escapes → link family (![[…]],[[…]],,[…](…),~~…~~,$…$) → emphasis (*/_delimiter runs) →buildTree. Each pass claims spans only in regions not already claimed, so there are never partial overlaps and the tree is a clean containment tree.MarkdownAST/DocumentAST.parsecombines the two into the semantic document AST —[BlockNode], each inline-bearing block carrying its parsed[InlineNode]children in absolute document coordinates.BlockNode,InlineNode, andListItemare defined here.
Tokens are now a projection of the AST, not the source of truth.
MarkdownTokenizer is just a namespace; its entry point parseTokensViaAST
(implemented in BlockScopedTokenizer — the live tokenization pipeline)
walks each BlockParser block and emits the legacy flat [MarkdownToken]
shape: block-level tokens (heading, blockquote, fenced code, table, block
LaTeX) come from BlockLevelTokenizer (hand scanners), inline tokens from
the AST via InlineASTAdapter ([InlineNode] → [MarkdownToken]). Token
shapes are reproduced 1:1 from the old regex tokenizer (parity-checked), so the
consumers that still read tokens — the NSImage render passes, code-block
handling, MarkdownInputHandler, ContextMenu, and MarkdownDetection
(caret-aware active-token indices) — keep working unchanged.
Invariant: Ranges everywhere are absolute UTF-16 NSRanges into the source
(the editor is TextKit-2 / NSTextView-based, so UTF-16 offsets are the native
currency).
Invariant: Parsing is incremental. With scopedRanges, DocumentAST.parse
parses inlines only for blocks intersecting the edit, and BlockScopedTokenizer
memoizes per-block tokens (substring → tokens, FIFO-capped) — so a keystroke
re-parses one block, not the whole document (≈ O(edit)).
Services/: how does the engine talk to your app?
MarkdownEditorServices.swift declares the four service protocols. Each is
called synchronously when its construct is styled or rendered: WikiLinkResolver
while styling wiki-links, EmbeddedImageProvider from the image-embed render
pass, SyntaxHighlighter from code styling, LatexRenderer from the LaTeX
render passes.
WikiLinkService.swift handles the dual-form storage / display transform —
storage is [[Name|<id>]], display is [[Name]]. The coordinator runs it both
ways every time rebuildTextStorageAndStyle() fires.
Invariant: Service callbacks are synchronous. If an embedder's implementation is slow, it caches (both bundled bridges do); the engine never async-renders.
Invariant: Wiki-link storage and display are different strings. Display IDs never leak into the binding.
Styling/: how does the AST become attributes?
MarkdownASTStyler.styleAttributes() is the live styler. It walks the document
AST and emits [StyledRange], composing attributes on descent: a heading
sets a large bold font, descending into bold adds the bold trait (keeping the
size), into italic adds italic — so nested / combined inline styles stack
instead of overwriting each other. (Composition is what the old flat pass
pipeline got wrong, e.g. the shrinking bold in # **n*o*des**.)
MarkdownStyler.styleAttributes() (MarkdownStyler.swift:43) is now a thin
facade: it builds the StylingContext, runs the AST styler for all text
styling, then appends the passes that still render NSImages and therefore
still consume tokens — block / inline LaTeX (+Latex), image embeds and image
links (+Images), and rendered tables (+Tables). MarkdownStyler+TaskCheckboxes
and +BulletMarkers no longer style (the AST styler does); they keep only the
caret / selection range helpers (taskSyntaxRange, bulletSyntaxRange,
hrLineRange) the text-view delegate uses.
If the coordinator passes scopedRanges, only the intersecting blocks are
re-styled — the optimization that keeps per-keystroke restyling cheap.
Invariant: Markers shrink, they don't disappear. Inactive markers render at
hiddenMarkerFontSize; they're never removed from text storage. Every
selection / copy / find / undo bug downstream traces back to violating this.
Renderer/: TextKit 2 layout
Thin wrappers around NSTextLayoutManager (LayoutBridge.swift), a custom
MarkdownTextLayoutFragment for precise positioning, and EmbeddedImageCache
keyed by an embedder-supplied fingerprint so images and LaTeX results
invalidate when the embedder says so.
Input/: typing-time helpers
MarkdownInputHandler.swift handles auto-wrap for $…$ / $$…$$ / ![[…]].
MarkdownListHandler.swift handles list continuation, indent / outdent, and
task-checkbox toggling on Enter / Tab / Backspace. Both run synchronously inside
the text-view delegate.
TextView/: NSTextView + SwiftUI bridge
The entry point is NativeTextViewWrapper.swift — an NSViewRepresentable that
owns the coordinator and the configured text view.
The scroll view's documentView is always NativeTextViewContainer, never
the text view itself. The container stacks up to three kinds of siblings in a
flipped coordinate space: the optional scroll-away header band at the top (a
clipped NSHostingView managed by ScrollingHeaderController, reserved height
mirrored into container.headerHeight), the NativeTextView at
y = headerHeight (centered at a fixed width when
configuration.readingWidth is set), and — in reading-column mode — the
full-width wide-table breakout overlays. Anything that converts between
text-view-local rects and scroll/document space must lift by the text view's
origin inside the container (convert(_:to:) or frame.origin); see
viewRect(forCharacterRange:) and the find-in-document paths for the pattern.
Two sub-folders matter:
NativeTextView/— extensions on the AppKit subclass (paste, drag-select boost, spell policy, caret workarounds, frame/overscroll management)Coordinator/—NSTextViewDelegateglue, split by concern (restyling, writing-tools, find, code-blocks, inline selection, autocorrect)
Application of [StyledRange] to text storage happens in
Coordinator/NativeTextViewCoordinator+Restyling.swift →
rebuildTextStorageAndStyle(), which tokenizes via parseTokensViaAST and
calls MarkdownStyler.styleAttributes().
Configuration/: the tunables
MarkdownEditorConfiguration is a struct of structs — one nested group per
concern (headings, codeBlock, blockLatex, overscroll, markers, lists, …) —
passed by reference into the styler via the StylingContext.
MarkdownEditorTheme is its colour sub-field.