Convert from git submodule to plain vendored copy. No push access to upstream nodes-app/swift-markdown-engine meant our local fix commit (05c1720) was stranded and unreachable from any remote on fresh clones/CI. Vendoring as plain files folds it into normal repo history instead.
21 KiB
Changelog
All notable changes to swift-markdown-engine are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.12.0] - 2026-08-10
Added
-
onPersistScrollOffset/restoreScrollOffsetonNativeTextViewWrapper— scroll memory an embedder can keep somewhere that outlives the editor. The engine's own per-document offsets live on the coordinator, so an embedder that routes to a different screen and back lost them: nothing recorded the offset on the way out (there was nodismantleNSViewat all), and the restore was gated on a document switch, which a remount is not —makeCoordinatorseedsdocumentId, so the first update pass never looks like one. Teardown now hands the offset over, and the restore is latched instead of gated, retrying for a bounded few passes because the first pass after a remount still carries the embedder's empty buffer. Both closures are asked at call time, so the embedder's own retention rules can see changes made on the way out. Passing neither leaves behavior unchanged. -
NSAttributedString.Key.markdownBlockBackground— a background painted across the whole line box byMarkdownTextLayoutFragmentinstead of the glyph box AppKit's.backgroundColorcovers. Embedder extensions can use it wherever a fill should read as a block.
Changed
- Inline parse cost is linear in the spans per region, not quadratic. Every
pass after the first consulted the claimed ranges by scanning the whole array
— once per character in
scanEscapesandcollectDelimiterRuns, once per candidate inscanLinkFamily— andbuildTreedecided containment by testing each span against every other. The passes walk the string left to right and claimed ranges never partially overlap, so a cursor over the sorted ranges answers both questions in amortised constant time, and sorting spans by start ascending / length descending turns containment into a single ordered walk. A paragraph of 240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the parse instead of ~30x. Affects every claimed-span construct — code, escapes, links, images, wiki links, inline LaTeX, emphasis, and extension spans. No parse result changes. ==highlight==fills the line box. AppKit paints.backgroundColorover ascent + descent only, so the marker fell short of the line height by the leading plusparagraph.lineHeightExtraSpacing, and a highlight that wrapped came out as a stack of bands.HighlightExtensionreturns.markdownBlockBackgroundnow, so the block is continuous at any font size. Table cells rasterize their own text and keep the glyph-box fill.
Fixed
- Bare URLs and emails survive rich copy as real links. The editor styler
linkifies them with
NSDataDetector, but the HTML renderer emitted them as plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor at all, and whether a copied URL arrived clickable was left to the receiving app — Apple Mail runs its own detection and linkifies anyway, a consumer that takes the rich flavor verbatim pastes dead text.MarkdownHTMLRenderernow wraps detector matches in<a href>(emails asmailto:) using the same system detector as the styler; the RTF and web-archive flavors are derived from that HTML, so all three inherit the link. Explicit[title](url)links were already correct; a URL-shaped run inside a link's own title stays plain so anchors never nest, and code spans remain excluded, matching the styler. Table cells are unaffected: they render no inline markup on the copy path. - Markdown link labels may hold inline code and escaped punctuation —
[`App`](/tmp/App.swift:56)stayed literal. Code spans and escapes are claimed before links so they stay opaque, and the link pass rejected every candidate overlapping a claimed span, including one lying entirely inside the label. Spans contained in the label are permitted now, links act as containers when the tree is built, and partial overlaps or spans crossing the label boundary are still rejected. - Initially narrow tables reflow when the editor width shrinks instead of retaining stale image geometry until an unrelated full restyle.
[0.11.0] - 2026-07-31
Added
- Ordered lists render their position. An item's number is computed from its
place in the run and painted over the source digits, so typing, deleting,
merging and pasting renumber live. The
.mdfile is never rewritten — the source stays valid CommonMark whatever it says. The whole source marker is hidden as one unit and the slot is kerned to the display width, so the dot travels with the digits at any digit count; the raw digits are revealed while they are edited. MarkdownEditorConfiguration.cursorFollowsSpanInk(opt-in, off by default): the caret and the I-beam take the ink of the extension span they sit in. It matters for an extension that INVERTS its content — dark ink on a light block — where both cursors are otherwise drawn in the block's own color and disappear inside it.InvertedIBeamCursorrecolors the liveNSCursor.iBeamimage, which keeps the system shape and the user's pointer size.
Fixed
- Find-in-document no longer erases other backgrounds. Clearing its highlights
removed
.backgroundColoracross the whole document, which blanked extension spans, code fences and table cells until some unrelated restyle repainted them. Find now marks its own backgrounds and restyles only the paragraphs it touched.
Performance
- Large notes open ~14× faster. Measured on a 346k-char / 5,241-block note
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
away 440 ms → 115 ms. Styling is built on a detached string and transferred
with one
setAttributedString— per-keyaddAttributeon live TextKit-2 storage left weak tombstones in Foundation's attribute-intern table, which turned quadratic. The restyle apply uses a paragraph overlap index above 32 paragraphs, the redundant second full-document parse and the re-entrant full-document layout during rebuild are gone, and the SwiftMath render cache persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry). - Editing long ordered lists. One Return in an 800-item loose list: 944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans: 0.134 ms → 0.002 ms per keystroke.
Known limitations
- A list item's continuation line is a paragraph and ends the run, so a multi-line item switches numbering off below it.
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not classified as structural).
- Ordered task items (
1. [ ] x) consume a number but render none.
[0.10.1] - 2026-07-22
Added
- Custom SF Symbols for task checkboxes:
MarkdownEditorConfigurationaccepts custom unchecked and checked symbols for- [ ]/- [x]task-list items (opt-in; the defaults are unchanged).
Fixed
- List markers no longer disappear while a selection covers them, and selecting a list item now reveals its raw marker syntax like other inline constructs.
- An unclosed ``` fence no longer swallows the rest of the document: typing an opening fence above existing content left every block below it (tables, block LaTeX, thematic breaks, links) unrendered until the closing fence was typed. A fence now forms a code block only once its closing fence exists.
[0.10.0] - 2026-07-15
Added
- Extension seam: opt-in constructs beyond pure markdown. A
MarkdownExtensioncontributes an inline form (==highlight==), a fenced block form (::: … :::), or both — plus content attributes and an HTML wrapper for the clean-copy path; register instances viaMarkdownEditorConfiguration.extensions. Extensions never emit ranges — the parser derives all geometry, so a misbehaving extension can at worst restyle its own construct. Marker/fence hiding, caret reveal, incremental restyle, table cells, and rich copy are handled generically. Registered extensions can change at runtime; all parse caches key on the registry. HighlightExtension(==text==) andStrikethroughExtension(~~text~~), the former built-ins repackaged as extensions, andContainerExtension(::: … :::), the first fenced block extension.
Changed
- Breaking:
==highlight==and~~strikethrough~~are no longer part of the core grammar. Unregistered, the syntax stays literal text. To keep the previous behavior:configuration.extensions = [HighlightExtension(), StrikethroughExtension()]. The formatting actions (context menu,applyHighlightRequest/applyStrikethroughRequest) still insert/remove the markers either way; construct detection (toggle-off, selection state) requires the extension.
Fixed
- A pre-existing incremental-parse gap surfaced by the seam review: backspace-joining two paragraphs could leave transiently wrong styling (extra spacing or a stray emphasis/code span across the join) until the next edit re-parsed the region.
[0.9.0] - 2026-07-13
Added
MarkdownEditorConfiguration.rawSourceMode: present the document as raw Markdown source — no syntax hiding, no markdown styling, and no wiki-link display transform ([[Name|UUID]]shows verbatim). The editor keeps base font/paragraph styling and stays fully editable; smart Markdown input handling (list continuation,$$/![[auto-wrap, ⇧⇥ outdent) is disabled while raw. Runtime switching is supported and rebuilds the document immediately; the current document's undo stack is dropped on a switch because undo actions recorded against the other mode's display text would replay at stale ranges. Defaultfalse— existing embedders are unaffected.- Find & replace: two optional bus notifications,
replaceCurrent(replace the focused match and advance) andreplaceAll(replace every match in one undo step, back-to-front so ranges stay valid). Both edit the engine's displayed text with propershouldChangeText/didChangeTextundo registration and report the remaining count viafindResults. Purely additive — embedders that don't set the bus names are unaffected. - Clean clipboard: ⌘C copies the selection as rich text (RTF +
com.apple.webarchive) built from the AST rather than the raw storage form, and paste converts HTML to Markdown. Wiki-link[[Name|UUID]]side-channels no longer leak into copied text.
Fixed
- Find/jump scroll now works without a reading column. The TextKit 2
fragment-enumeration scroll path (with
.ensuresLayout) runs universally instead of only whenreadingWidthwas set; the unreliableNSTextView.scrollRangeToVisible(which routes through the absent TextKit 1 layout manager for off-screen content) is now only the last-resort fallback. - Inline syntax markers (
**,*,~~,==) now usemutedTextforeground color while the caret is inside the corresponding span, matching the existing behavior of inline code backticks and link/wiki-link brackets. This makes highlight==markers visually distinct from body text in edit state. - Web links
[text](url)now share the wiki-link "edit zone": clicking the outer ~30% of the link's first/last visible character places the caret just outside the markers (before[/ after)) and reveals the source for editing instead of navigating, matching[[…]]behavior. Previously the edit zone only resolved.wikiLinktokens, so a web link dropped the caret between its brackets and did not reveal. Middle-of-link clicks still navigate; read-only links stay navigable. - Auto-linking (
NSDataDetector) no longer linkifies a URL that sits inside a markdown or wiki link's own range. A link's(url)previously got its own competing.linkattribute on top of the link — making the raw URL independently navigable and offsetting the click edit zone. Bare URLs outside links still autolink; URLs inside code were already excluded. - Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline autocomplete list.
Contributors
- Find/jump scroll fix and find & replace by @ChristineTham
- Inline syntax-marker color fix by @sospartan
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
[0.8.0] - 2026-06-28
Added
MarkdownEditorBus.findQuery/findResults: query-based in-document find. The host posts a search string (+ current index) and the engine matches against its OWN displayed text, highlighting in display coordinates and posting the match count back. This is correct where the displayed text differs from the source — e.g. node links rendered shorter than[[Name|UUID]], LaTeX, or images — which the legacyfindScrollToRange(host-computed source-coordinate ranges) highlighted at the wrong offset. Opt-in;findScrollToRangeis unchanged for existing embedders.NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?— embedder-supplied predicate that suppresses the edit-mode I-beam cursor when the mouse is inside a defined exclusion zone (e.g. a formatting toolbar). When the closure returnstrue,mouseMoved:skips callingsuper.mouseMovedto avoid NSTextView's built-in I-beam cursor, setting the arrow cursor instead. Exposed throughNativeTextViewWrapper.isCursorExcluded.NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?— embedder hook to build the editor's right-click menu. The engine hands over the defaultNSMenu+ the current selection; the embedder returns the menu to show (driving thedidMarkdown*actions through the bus). Keeps the engine UI-free.==highlight==inline markup: double-equals markers around text apply a background color (configurable viaMarkdownEditorTheme.highlightColor, default.systemOrange.withAlphaComponent(0.4)). Content is recursively parsed so nested emphasis, code, etc. work inside highlights.MarkdownEditorBus.applyHighlightRequest/selectionHighlightDidChange: bus notification names for driving a highlight toolbar button from host UI.MarkdownEditorBusextended with nine new notification types for formatting toolbar integration:applyStrikethroughRequest,applyInlineCodeRequest,applyBlockquoteRequest,applyUnorderedListRequest,applyOrderedListRequest,applyLinkRequest,applyCodeBlockRequest,applyHorizontalRuleRequest,applyImageRequest. Embedders wire these intoNotificationCenterto trigger formatting from external UI (toolbars, menus) without reaching into the editor's view hierarchy.- New formatting actions on the coordinator (callable directly or
via the bus above):
didMarkdownStrikethrough,didMarkdownInlineCode,didMarkdownBlockquote,didMarkdownLink,didMarkdownCodeBlock,didMarkdownHorizontalRule,didMarkdownImage. - Word-boundary detection in inline formatting: when the cursor is
placed inside an English word with no active text selection, bold,
italic, strikethrough, and inline-code actions now auto-select the
containing word before wrapping. If no word character is adjacent
to the cursor, empty markers are inserted as before. The cursor's
relative offset within the word is preserved after wrapping
(e.g.
wo|rd→**wo|rd**). - Headless test suite for formatting actions (
FormattingActionTests— 21 tests covering bold, strikethrough, inline code, blockquote, link, code block, horizontal rule, and image insertion).
Changed
- The engine no longer ships a built-in right-click "Format" context menu — menus
are now embedder-supplied via
onBuildContextMenu(above). The system rich-text "Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since those font traits don't apply to Markdown.
Fixed
- Blockquote removal no longer doubles trailing newlines when the original line already carries one.
0.7.1 - 2026-06-20
Added
MarkdownEditorConfiguration.heightBehavior(.scrollsdefault /.fitsContent): in.fitsContentthe editor grows to its content height and reports it to SwiftUI, so an enclosingScrollViewscrolls the page instead of a nested internal scroller. Opt-in, off by default — no change for existing embedders. (#75)BlockquoteStyleconfiguration struct withextraLineHeightto control line spacing inside blockquotes, following theListStyle.extraLineHeight/ParagraphStyle.lineHeightExtraSpacingpattern. Defaults to0(no extra spacing), preserving existing rendering. (#76)
Fixed
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a stale-small content-height measurement. (#71)
- Inspector clip mask and caret reveal at the document end. (#73)
- Scroll position is remembered per document across switches, and Writing Tools results stay styled and visible after accept. (#70)
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
Added
- Scroll-away header:
NativeTextViewWrappergainsheader: AnyView?,headerCollapsedHeight: CGFloat, andheaderExpanded: Bool. The engine hosts the supplied SwiftUI view above the document body, scrolling with it; collapsing animates the reserved band down toheaderCollapsedHeight(the top row stays visible, lower rows clip away). The hosted content refreshes on every SwiftUI update and stays fully interactive. Composes withreadingWidth. See the README's Scrolling Header section.
Changed
-
The scroll view's
documentViewis now always an engine-internal container view (hosting the text view, the optional scroll-away header, and the reading column's breakout overlays) rather than sometimes theNSTextViewitself. Embedders that reached intoscrollView.documentViewexpecting anNSTextViewmust adapt — the document view's class was never API. -
Breaking: The editor's enclosing scroll view no longer applies a hard-coded
top: 55.4content inset. The default is now0on every edge, matching the most common embedding case where the editor fills its container exactly. Embedders that previously relied on the engine reserving header space (e.g. for a translucent toolbar) must opt in explicitly:var config = MarkdownEditorConfiguration.default config.safeAreaInsets = SafeAreaInsets(top: 55.4)
Added
SafeAreaInsetsstruct exposingtop/leading/trailing/bottominset knobs for the editor's enclosing scroll view, configurable viaMarkdownEditorConfiguration.safeAreaInsets.MarkdownASTStylernow stamps.spellingState: 0on fenced code blocks and inline`code`spans, completing the engine's existing spell-check suppression convention (links, wiki-links, LaTeX, and tables already carry the same attribute). The system spell-checker no longer underlines tokens inside code regions even when continuous spell checking is enabled.
Fixed
- Undo is now kept per
documentId, so Cmd+Z keeps working after switching files. The single reusedNSTextViewpreviously wiped its undo manager on every document switch; the editor now vends a per-documentUndoManager(via the newundoManager(for:)delegate method) whose undo/redo stack survives switching away and back. (#77) - A document's surviving undo stack is dropped when its text is reloaded
changed while it was switched away (e.g. renaming a node rewrites the
[[label]]in every file that links it), so Cmd+Z can no longer replay stale ranges against the rewritten content. (#78) NativeTextViewWrapperkeeps links clickable and text selectable whenisEditable: false;isSelectableis no longer coupled toisEditable. (#31)NativeTextViewWrappernow applies its initial styling pass even when the bound text starts at its final value (e.g. supplied as a SwiftUI@Stateinitializer). Previously the editor would render the raw Markdown source until the user clicked into the document, because the coordinator'slastSyncedTextalready matched the bound text at firstupdateNSView. The early-return now also requiresdidInitialFormattingto be true, which only flips after the first styling pass completes.
Added
- Initial public API surface:
NativeTextViewWrapper— SwiftUI bridge for the AppKit-backed editorMarkdownEditorConfiguration— every spacing / sizing / behavior knobMarkdownEditorTheme— color palette, defaults to system colorsMarkdownEditorServices— container for the four service protocols- Service protocols:
WikiLinkResolver,EmbeddedImageProvider,SyntaxHighlighter,LatexRenderer - No-op default implementations:
NoOpWikiLinkResolver,NoOpEmbeddedImageProvider,PlainTextSyntaxHighlighter,NoOpLatexRenderer WikiLinkService— bidirectional storage / display roundtrip helperPasteboardImageReader— pasteboard image inspection helpers- Selection / replacement value types:
WikiLinkSelection,InlineSelectionState,InlineReplacementRequest,CodeBlockSelection CodeBlockButton— drop-in copy button overlay
- DocC documentation catalog with landing page and topic groups
- Triple-slash documentation comments on the full public API surface