Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

38 KiB

Edmund — architecture & agent onboarding

A native macOS Markdown editor with live preview (AppKit + TextKit 2, SPM, macOS 14+). This doc gets an AI agent productive fast: read it before any non-trivial change. Keep it updated — when you learn something non-obvious or change an invariant, edit this file in the same PR.

This is the agent-oriented canonical summary — dense, terse, optimized for fast onboarding before a change. Humans wanting the fuller narrative write-up (rationale, worked examples, prose) start at docs/architecture/README.md; the same-PR update rule above still applies to both.


1. Build / run / test

swift build                 # debug build of both targets
swift test                  # full suite (≈750+ tests, ~10s)
swift test --filter Callout # one suite
./scripts/build-app.sh      # builds build/Edmund.app (release + bundles + icon + codesign)

Run the app for visual checks (see §8 for the gotchas):

pkill -x edmd; build/Edmund.app/Contents/MacOS/edmd /path/to/file.md &

Two SPM targets (see Package.swift):

  • EdmundCore — the library: all editor logic, parsing, rendering, the EditorTextView. Has the tests. Most work happens here.
  • edmd — the executable: NSDocument app shell, Settings (SwiftUI), menus, window setup. Depends on EdmundCore.

Dependencies: swift-markdown (CommonMark/GFM parsing), SwiftMath (LaTeX), and Sparkle (auto-update).


2. The two non-negotiable invariants

Break either and the whole editor misbehaves in subtle ways.

  1. Text storage always equals rawSource. Rendering is attribute-only — we never insert/delete display characters. Delimiters (**, `, [!note], etc.) are hidden (near-zero font + clear color), never stripped. Consequences: no NSTextAttachment (TextKit 2 only honors them on U+FFFC, which rawSource never contains — so images/icons are drawn as overlays instead, see §5); display offset == raw offset (identity mapping).

  2. TextKit 2 only. Never touch NSTextView.layoutManager and never store NSTextBlock/NSTextTable attributes — either silently reverts the view to TextKit 1 for good. A DEBUG tripwire asserts if TK1 is engaged. Layout is viewport-based (only on-screen content is laid out), which is what makes big documents fast.


3. Rendering pipeline (the core loop)

rawSource ──BlockParser──▶ [Block]  ──styleBlock per block──▶ attributed runs
                                          │
                                          ├─ SyntaxHighlighter (swift-markdown
                                          │   Walker + custom parsers for
                                          │   callouts, ==highlight==, wikilinks,
                                          │   %% and <!-- --> comments, footnotes,
                                          │   math, backslash escapes, inline HTML
                                          │   tags incl. <img>, GFM autolinks)
                                          └─ writes attributes into textStorage
  • BlockParser (Parsing/) splits rawSource into Blocks (one per logical block: paragraph, heading — ATX or setext, list run, quote/callout run, code fence, indented code run, table…). Block.kind is a BlockKind enum (e.g. .quoteRun(isCallout:)). The indented-code rule is the parser's only backward-looking one (a run starts only after a blank line), so consumeBlock carries a prevLine context and the incremental parse won't resync onto an indented-code-ish line.

  • SyntaxHighlighter + +Walker/+WalkerInline/+CustomParsers produce styling spans. The custom parsers handle the non-CommonMark syntax.

  • styleBlock(_:cursorPosition:) (Rendering/EditorTextView+Rendering.swift) renders ONE block to an attributed string. Each feature has a Rendering/ extension: Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks.

  • Recompose (TextView/EditorTextView+Composition.swift) drives styling:

    • recompose(cursorInRaw:) — full: replaces storage with rawSource, restyles all blocks (load, undo, indent).
    • recomposeDirty(_:cursorInRaw:) — restyles a specific set of block indices in place (the workhorse; attribute-only).
    • recomposeIncremental(...) — restyles just the block(s) the cursor moved between (most cursor moves).
    • Active block: the block under the caret renders its raw markdown (delimiters visible/editable); all others render styled. This is the "live preview reveals the active line" behavior.
  • Lazy styling (TextView/EditorTextView+LazyStyling.swift): a large dirty set styles only the viewport synchronously; the rest is finished by the idle drain (time-budgeted main-thread slices) and scroll promotion (style blocks as they enter the viewport). Keeps large docs responsive.


4. Edit flow & undo

  • Edits go through NSTextView's normal path: shouldChangeText records a coalesced undo snapshot → NSTextView mutates storage → didChangeText syncs rawSource and restyles the edited block(s) (+EditFlow, +Composition).
  • Incremental parsing: edits capture a pending edit on EditorTextStorage and reparse a window, not the whole doc.
  • Undo/redo is custom: stacks of rawSource snapshots (+Undo.swift), bypassing NSTextView's built-in undo. Restoring a snapshot diffs the snapshot against the current text (textDiff, single contiguous span) and applies it with the range-bounded recomposeReplacing — never a full recompose, which would reset every fragment to a TextKit 2 height estimate and make the follow-up scroll land wrong. The changed text is selected (caret at the deletion point for pure deletions) and drives the viewport: hold if any of it is on-screen, else center it. The snapshot's stored caret is only a fallback for a no-op diff.
  • Cursor tracking (+SelectionTracking): moving between blocks restyles both; moving within a block updates which token's delimiters are revealed.

5. TextKit 2 specifics (how visuals are drawn)

  • DecoratedTextLayoutFragment (custom NSTextLayoutFragment, in +TextKit2.swift) draws two kinds of custom attributes behind/over the text:
    • .blockDecoration (paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code-block backgrounds. Fragments tile vertically, so a multi-line run renders as one continuous box/bar. A box's bottomPad grows the last fragment's frame (TextKit 2 omits trailing paragraph spacing from the fragment, so padding done that way would be dead space).
    • .fragmentOverlay (character-level): an image or stroked vector path drawn at a character's laid-out position — rendered math, list bullets/checkboxes, the callout header icon+name image, the custom-title callout icon (path). The anchor glyph is hidden and .kern reserves the drawing's advance width (the same trick the table renderer uses).
  • Hiding text = hiddenFont (≈0.01pt) + clear foregroundColor. This is how delimiters and synthesized-but-in-source markers vanish without changing the string.
  • Image overlays only work on single-line fragments. Drawing an image on a multi-line (wrapping) fragment re-triggers a layout pass that wedges it to one line; drawing a shape does not. So the wrapping callout custom title's icon is a stroked CGPath (SVGPath parses the vendored Lucide geometry), never an image — see docs/investigations/archives/callout-title-wrap-investigation.md for the full saga.

6. Feature map (where to look)

Area Files
Editor core / state TextView/EditorTextView.swift (+ many extensions)
Parsing Parsing/BlockParser.swift, SyntaxHighlighter*.swift, CodeHighlighter.swift, CodeSyntaxPalette.swift (shared Tomorrow/One-Dark hex table — read by both the editor's NSColor palette and the HTML CSS generator)
Block model Model/Block.swift, Callout.swift, EditorTheme.swift, ListIndentState.swift
Rendering Rendering/EditorTextView+*Rendering.swift (Callout, Code, Image, List, Math, Table, WikiLinks)
Read mode / Export Export/HTMLRenderer (MarkupVisitor → HTML; callout/checkbox icons are inline Lucide SVGs from LucideIcons), HTMLTheme (EditorTheme → CSS), DocumentHTML (assembly + asset inlining: math + local images → data URIs), ReadModeWebView, MarkdownPrinter (PDF/Print). Document.refreshReadView() keeps an open Read view in sync with edits and theme changes.
Icons Model/LucideIcons.swift — vendored Lucide SVGs (ISC, LICENSES/lucide.txt). Callout headers use them in both modes: Read inlines the SVG (CSS-tinted via currentColor); Edit rasterizes to a tinted NSImage overlay. We can't ship SF Symbols in exported PDFs (license). App-chrome (toolbar/settings) SF Symbols are fine (in-app UI). Edit-mode task checkboxes still use SF Symbols (on-screen only); Read-mode checkboxes are a composed Lucide SVG.
Edit behaviors Editing/EditorTextView+{List,Blockquote}Continuation.swift, +Indentation.swift
Formatting commands Editing/EditorTextView+Formatting{Core,Commands}.swift (Format-menu actions: bold/lists/links/callouts/…); menu built from edmd/App/FormatMenu.swift
Lazy/compose/undo/scroll TextView/EditorTextView+{Composition,LazyStyling,Undo,TypewriterScroll,SelectionTracking,ContentWidth,EditFlow}.swift
App shell edmd/App/{main,Document,DocumentController}.swift; menu bar in main.swift setupMenuBar() + FormatMenu.swift; Sparkle SPUStandardUpdaterController in AppDelegate
Settings (SwiftUI) edmd/Settings/* (AppSettings = UserDefaults keys; FontSettings; Appearance/General/Advanced views)
Crash-log uploading EdmundCore/Diagnostics/CrashReporter.swift (see §7)
Auto-update Sparkle 2.x. Info.plist: SUFeedURL (raw GitHub URL to appcast.xml), SUPublicEDKey (ed25519 public key). scripts/release.sh: build → DMG (sindresorhus create-dmg, npm — not the homebrew tool) → EdDSA sign → update appcast → gh release create. The DMG is the Sparkle enclosure (it mounts the image and installs the .app inside). CI: .github/workflows/release.yml (tag-triggered). Full pipeline, signing, and the RELEASE_TOKEN PAT: §13.
Status bar edmd/Views/StatusBarView.swift
Build/packaging scripts/build-app.sh (release build + Sparkle.framework embedding + signing), Package.swift, Info.plist, Resources/

Notable subsystems: typewriter scroll (keeps caret vertically centered; must lay out the viewport↔caret span before measuring or it reads stale TK2 height estimates — and re-centers only on typing, not on clicks: a mouse-down sets suppressTypewriterCentering so positioning the caret by clicking doesn't yank the viewport), content width (+ContentWidth.swift: an absolute physical max-column width — set in cm/in in Settings, stored as cm, converted to points via the display's real PPI (NSScreen.physicalPPI, from CGDisplayScreenSize). It's a CSS max-width cap applied as a symmetric textContainerInset.width: windows wider than the cap center the column, narrower ones fill. Recomputed on resize and when the window moves to a differently-scaled display (NSWindow.didChangeScreenNotification)).

Format menu & shortcuts are pure AppKit (the app has no SwiftUI scene, so SwiftUI Commands isn't an option). FormatMenu.swift is a declarative command table (MenuCommand + Shortcut, each with a stable id so a later pass can read per-command shortcut overrides from UserDefaults) that builds the menu; items use a nil target and route through the responder chain to the focused EditorTextView's @objc format… actions — the same wiring as undo/redo. Those actions funnel through two primitives in +FormattingCore.swift (applyFormattingEdit for a single contiguous edit, modeled on the Tab-indent path; applyWholeDocumentEdit for non-contiguous edits like footnotes). The View-menu ⌘E item (and the toolbar button) toggles the editing view ↔ Read via Document.toggleViewMode. Source is not a third toggle stop — it's a persisted preference (AppSettings.sourceMode, a "Source Mode" checkbox in the View menu and the toolbar button's right-click menu): when on, the editing half of the toggle is Source instead of Edit (so ⌘E flips Source ↔ Read), and a freshly opened document honors it. The toolbar's view-mode button left-clicks to toggle and right-clicks for the full mode menu (see the §8 gotcha on why that right-click is intercepted in DocumentWindow.sendEvent). The toolbar enables allowsUserCustomization (Customize Toolbar… is an AppKit NSToolbar feature, not a SwiftUI one).

Read mode is a separate WKWebView, not an editor styling mode. Entering .reading swaps the editor's scroll view for a ReadModeWebView that renders the document as themed HTML (the Export/ group); Edit and Source stay on the EditorTextView. The HTML path walks the same swift-markdown Document the editor parses (one parser, two back-ends: SpanCollector → editor attributes, HTMLRenderer → HTML), themed from the same EditorTheme/CalloutStyle via HTMLTheme, so the two can't drift. The webview disables JavaScript, loads the generated HTML against an explicit about:blank base URL, and inlines every asset (math/icons as data URIs) so it needs no file/network reach; external http/https/mailto links open in the default browser, while file: and unknown schemes are cancelled instead of fetched in-view. Raw HTML passes through per GFM, filtered by tagfilter + hardening, and the page carries a script-src 'none' CSP meta (see §10 for the full policy). File ▸ Export as PDF… / Print… (⌘P) run the same HTML through WKWebView.printOperation for real vector (selectable) text — MarkdownPrinter. Math glyphs are high-DPI PNG (SwiftMath has no SVG path yet); callout/checkbox icons are inline Lucide SVG (vector); everything else is vector. Code blocks are syntax-colored by CodeHighlighter (same tokenizer and CodeSyntaxPalette as Edit mode). Local images are inlined as data URIs via a baseURL (document directory) threaded through DocumentHTML/ReadModeWebView/MarkdownPrinter; remote images are off by default (ReadRenderOptions.allowRemoteImages). [[Wikilinks]] and relative markdown links use private URL schemes (x-edmund-wiki:, x-edmund-link:) in the rendered HTML so the nav coordinator can intercept them and route through the app's document graph without JavaScript.


7. Settings & persistence

  • AppSettings (edmd/Settings) defines UserDefaults keys + typed accessors. SwiftUI panes use @AppStorage. Live changes broadcast to every open Document.editor (see the font/line-height/content-width applyTo… helpers).
  • Theme/appearance/fonts flow into EditorTheme → the editor's derived bodyFont, colors, paragraph styles.
  • Max content width persists as centimetres (maxContentWidthCm); the cm/in shown in Settings is a display unit (contentWidthUnit), the column itself is always physical (see §6 content width). The default is locale-aware — 5 in (US) / 12 cm (elsewhere) — and doubles as the slider's magnetic snap point; the slider runs from a 3 in floor to the current screen's physical width (NSScreen.physicalWidthCm).
  • Window size persists as the last document window's full frame size (lastWindowSize) — see the §8 gotcha on why it's the frame, not the content size.
  • Diagnostic logging (EdmundCore/Diagnostics/Log.swift): an always-on (opt-out) file logger. Log.{debug,info,error}(_:category:) and Log.measure(_:) { … } (single-line durations) write to ~/.edmund/logs/edmund-YYYY-MM-DD.log on a private serial queue. One compile-time level threshold ships (DEBUG = debug+; release = info+) — the user only toggles it on/off and picks a retention window (Settings ▸ General ▸ Diagnostics). AppSettings.applyLogging() pushes the toggle/retention into Log.configure at launch and on change; retention is pruned there.
  • Crash-log uploading (EdmundCore/Diagnostics/CrashReporter.swift): opt-in (default off), fire-and-forget upload of the .ips crash reports macOS writes to ~/Library/Logs/DiagnosticReports/. On launch, if AppSettings.sendCrashLogs is true, we POST any edmd-*.ips files not in AppSettings.sentCrashReports (dedup) to CrashReporter.reportingEndpoint. Note: edmd is the Mach-O executable name (not "Edmund") — that's the prefix in crash-report filenames. The Settings ▸ Advanced toggle is currently commented out (the // Crash reports: GridRow in AdvancedSettingsView.swift); uncomment it and replace the placeholder reportingEndpoint URL once the receiving server exists. The upload path reads ~/Library/Logs/DiagnosticReports/ directly, which only works because the app is not sandboxed; if App Sandbox is ever adopted, switch to MetricKit's MXCrashDiagnostic API instead.

8. Quirks & gotchas (will bite you)

  • SwiftMath fonts: build-app.sh must copy *.bundle into the .app root (it does). Without it, the app crashes the instant it renders any LaTeX.
  • Sparkle codesign — must seal the whole bundle: at install time Sparkle re-validates the downloaded update's Apple code signature (SUUpdateValidator). Even with a valid EdDSA signature, a bundle that reports as code-signed but fails SecStaticCodeCheckValidity is rejected as "The update is improperly signed and could not be validated." The old build script signed only the main binary as a standalone file and never sealed the bundle — so it had no _CodeSignature/CodeResources and every Sparkle update failed (the v0.1.0→0.1.1 update error). The fix: build-app.sh seals the whole .app (codesign --deep). The obstacle is the SwiftMath resource bundle, which must sit at the .app root (its generated Bundle.module accessor is hardcoded to Bundle.main.bundleURL), and codesign refuses to seal a bundle with any item at the root. So we seal while the root holds only Contents/, then copy the SwiftMath bundle in after signing. That one unsealed root item makes codesign --verify (CLI) and --strict complain, but Sparkle's actual check is non-strict (SecStaticCodeCheckValidityWithErrors with kSecCSCheckAllArchitectures) and tolerates it — verified against that exact API. A Developer ID cert + notarization would be cleaner; ad-hoc is the current limit.
  • Sparkle keypair: set up and verified — public key in Info.plist SUPublicEDKey, private key in the login keychain and the CI secret SPARKLE_ED_PRIVATE_KEY. Don't let them diverge. Full release/signing details in §13.
  • create-dmg — npm only, three quirks:
    • Install via npm (npm install --global create-dmg), not Homebrew — they are different tools with incompatible CLIs. Requires Node ≥20 (node --version).
    • Exits 2 (not 0) when it can't Developer-ID-sign the image (we ship ad-hoc). It still produces the .dmg; release.sh and the CI step both use || true and then verify the file exists.
    • Names the output "Edmund <version>.dmg" (space, not hyphen). Both scripts rename it to Edmund-<version>.dmg before signing and uploading.
  • Stale release builds: swift build -c release / build-app.sh sometimes reuses stale object files (you'll run an old binary and be baffled). Debug builds have the same disease: swift build can print Build complete! after compiling a changed file without relinking edmd — the app then runs old code. Detect it by grepping strings .build/arm64-apple-macosx/debug/edmd for a long string literal unique to the new code (literals ≤15 bytes are stored inline on arm64 and never appear); cure with swift package clean. Never delete .build/…/edmd.build/ by hand — that corrupts the output-file-map and wedges the target until a full clean. If a visual change "doesn't take," rm -rf .build and rebuild. shasum the binary to confirm it changed.
  • open Edmund.app foregrounds a running instance instead of relaunching. Always pkill -x edmd first, or launch the binary directly (build/Edmund.app/Contents/MacOS/edmd file.md &).
  • Screencapture for visual verification: capture a specific window by id (reliable even if not frontmost) via Quartz: CGWindowListCopyWindowInfo → find the window by kCGWindowNamescreencapture -x -o -l<id> out.png. The desktop wallpaper defeats brightness-based auto-cropping; crop by the detected window bounds instead. Window-server state can glitch (tiny windows, state restoration) after many rapid launch/kill cycles — rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState and relaunch.
  • Width not known at first styling: on load the view may be unsized, so anything that bakes the content width (e.g. callout header images) renders at a fallback width until a width-settled re-render. Prefer real wrapping text over width-baked images where possible.
  • Attribute-only changes don't re-measure geometry in TK2: after restyling a block whose height/indent changed, you must invalidateLayout(for:) its range or the fragment keeps a stale frame (empty bands / clipped lines). recompose Dirty and the idle drain already do this; new paths must too.
  • TextKit 2 height estimates are the root of most viewport glitches. A fragment has a real frame only once laid out; everything else (and the total document height) is an estimate that gets corrected as layout reaches it — which is what makes the scroller jump and scroll-to-target land wrong (a widely documented TK2 limitation; even TextEdit shows it). Mitigations here: documents ≤ fullLayoutMaxLength (100k UTF-16) are kept fully laid out by a coalesced next-run-loop settle (scheduleFullLayoutSettle, wrapped in preservingViewportAnchor so corrections never shift what's on screen); centerViewportOnCaret re-measures after its first scroll and corrects the residual error; and undo/redo avoids resetting layout at all (see §4). Don't add code that trusts an off-screen fragment's y-coordinate without laying out the span first.
  • TK2 can strand content above the document origin. Edits near the top of a tall document sometimes leave the first fragment at negative y: the first line sits above the visible area and the scroller is already at the top, so the user can't reach it. repairContentAboveOrigin (run by the layout settle) detects a negative first-fragment origin and re-lays start→viewport inside preservingViewportAnchor to renormalize.
  • A selection taller than the viewport must be revealed at its nearest end (scrollRangeToVisible override): always revealing the top fought the drag-selection autoscroll and oscillated the viewport mid-drag.
  • Never mutate storage while an IME is composing (hasMarkedText()): during composition the storage holds the provisional marked text, so storage == rawSource is transiently false and didChangeText defers syncing until commit. Any styling that runs beginEditing/setAttributes/invalidateLayout mid-composition can strand the marked text in the input context — after which didChangeText keeps bailing on its own guard and the invariant stays broken, so every later edit drifts the caret (the old "delete-drift" bug). Every storage-touching styling path must guard !hasMarkedText() — including async ones scheduled before composition began (the caret-move restyle in +SelectionTracking). becomeFirstResponder resyncs from storage as a catch-all if a composition is ever left stranded. Full write-up: docs/investigations/delete-drift-investigation.md.
  • AppKit does not pair every storage mutation with didChangeText. A drag-move of the selected text whose drop lands on no valid target (e.g. released past the end of the document) deletes the dragged range via shouldChangeTextreplaceCharacters and never calls didChangeText — silently freezing rawSource/blocks, after which every edit drifts the caret and autosave writes the stale rawSource (delete-drift round 4). shouldChangeText therefore schedules a next-run-loop bypass check: a storage pendingEdit still unconsumed by then means the closing didChangeText never came, and the editor heals by running the same sync (+EditFlow, scheduleBypassedEditSyncCheck; healing storage edit that bypassed didChangeText in ~/.edmund/logs is its breadcrumb). Never build a sync path on the assumption that didChangeText follows every edit.
  • A bypassed edit also leaves TextKit 2's selection fixup queued (delete- drift round 6). The skipped -[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:] fires at the next endEditing — even an attribute-only restyle — where it maps the stale selection against post-edit coordinates and leaps the caret blocks away. It moves even a freshly set, valid caret, so the heal must set the caret (derived from the pendingEdit hull) before the sync and re-assert it after (+EditFlow). Corollary: any suspicious selection change arrives mid-recompose (up=Y in traces); under verbose diagnostics traceSelectionOrigin logs the call stack of whoever moved it — start there. This class of bug does not reproduce headless (the test harness runs the fixup synchronously): use the in-process repro driver — DEBUG builds accept -debug.reproScript <path> (Sources/edmd/App/ReproScript.swift) and replay keystroke scripts (caret / type / backspace / bypassdelete / assertcaret / logsel) through the real window.sendEvent key path — no Accessibility/TCC needed, works with the window on an invisible Space. Full chronicle: docs/investigations/delete-drift-investigation.md round 6; step-by-step method for producing such repros: docs/dev-guides/live-repro-guide.md.
  • A custom toolbar item can't win a right-click from a view-level handler. With NSToolbar.allowsUserCustomization = true, the toolbar turns any secondary (right / control) click over the toolbar — including a custom item view — into its own "Customize Toolbar…" context menu. Setting the view's menu, overriding rightMouseDown, and a secondary-button NSClickGestureRecognizer all lose, because the toolbar claims the click downstream of them. The fix (view-mode button): intercept in DocumentWindow.sendEvent(_:) — the documented funnel every window event passes through before the toolbar acts — and when the click falls inside the button's bounds, pop the menu and return (swallow it). Other right-clicks fall through to super. (Caveat: in true fullscreen the toolbar moves to a separate window, so this main-window hook wouldn't cover that case.)
  • Window-size persistence must round-trip the frame, not the content size. Saving contentView.bounds.size and re-applying it as the initializer's contentRect makes the window grow by the title-bar + (unified) toolbar height on every reopen — and content heights below the frame minSize get silently rejected. Save window.frame.size and re-apply it with window.setFrame(_:) after the toolbar is installed (the frame is only final then), so frame-in == frame-out (windowDidResizemakeWindowControllers in Document.swift).

9. Known issues / lurking problems

  • Images can't be drawn on multi-line (wrapping) fragments (TextKit 2 image wedge — collapses the fragment's layout to one line). Resolved for the callout custom-title icon by drawing it as a stroked CGPath instead (shapes don't trigger the wedge); the constraint still holds for any new overlay that could share a line with wrapping text. Full investigation: docs/investigations/archives/callout-title-wrap-investigation.md.
  • (Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in docs/.)

10. Still to address

  • Raw HTML (§G): Read renders it per GFM; Edit shows colored source — the GitHub split (their editor shows source too; rendered HTML in Edit is impossible under the storage==rawSource invariant). Read mode passes raw HTML through (GFM §6.10 inline / §4.6 blocks) filtered by HTMLRenderer.filterRawHTML = the spec tagfilter (§6.11: the nine dangerous tag names get <&lt;) plus Edmund hardening beyond spec: on* event-handler attributes stripped, javascript:/vbscript: URL schemes neutralized. Defense layers behind that filter: the read webview disables JS, the page carries a script-src 'none' CSP meta (DocumentHTML.full — Read view and Print/PDF both consume it), and it loads with baseURL: nil. A raw <img src=…> (lone or inside a block) is rewritten to the md-image placeholder — the only way images load under baseURL: nil, and the remote-image-policy chokepoint (DocumentHTML.fillImages). Edit mode: .htmlBlock blocks (all seven §4.6 start conditions in BlockParser) and inline tags (full §6.10 grammar in parseHTMLTags) render as colored source; the SyntaxHighlighter.htmlFormatTags whitelist (u/kbd/mark/sub/sup/small) is presentation in Edit (rendered formatting), not sanitization. Deliberate divergences from spec: the HTML comment regex is laxer (interior -- allowed); a <-line that forms a valid table header+separator becomes a table (tables win); the type-1 tag set is pinned to CommonMark 0.29 (script|pre|style, no textarea); a lone self-closing <script/> is a type-7 HTML block, not a paragraph; the Edit-mode htmlPairRegex attr swallow breaks on > inside a quoted attribute of a whitelist open tag (falls back to colored source). Still deferred in Edit mode: reference links/definitions, blockquote lazy continuation, entity-reference styling, the 2-trailing-space hard-break span.
  • Edit-mode table alignment distributes each cell's slack via .kern, putting the right/center "before" pad on the cell's hidden leading pipe (0.01pt glyph) — kern still adds advance there. See EditorTextView+TableRendering.swift. Column widths are clamped to the available line width (distributeColumnWidths, EditorTextView+TableSupport.swift) so one very wide cell can't stretch the table off screen; a cell whose styled width still exceeds its clamped column hides its real characters and is redrawn wrapped instead, via a .tableCellWraps attribute (EditorTextView+TextKit2.swift) resolved through a detached scratch NSTextContentStorage/NSTextLayoutManager sized to the column's content width — the same "hide the real chars, draw the visual yourself" pattern as .fragmentOverlay, needed because TextKit 2 only wraps a whole paragraph at the container edge and has no per-cell flow region (that's what NSTextTable/NSTextBlock are for, and they're banned, see §2). Click-to-caret placement inside a wrapped, non-active cell is approximate as a result. Every data row also draws a full-width bottom grid line (.tableRow's bottomBorder) — the header/body boundary already gets its line from separator, so only rows after the separator set it.
  • (Track larger roadmap items in README/ROADMAP; track code-debt here.)

11. Quick start for an agent

  1. Skim README (what/why), then this doc (how).
  2. swift build && swift test — confirm green before changing anything.
  3. Find the feature's Rendering/ or TextView/ extension via §6.
  4. Make the change; add/adjust tests in Tests/EdmundTests (helpers: makeEditor(), ensureFullLayout(), styleBlock()).
  5. Verify per §12 before committing.

Debugging a live-only bug (caret, IME, drag, viewport timing)? Follow docs/dev-guides/live-repro-guide.md — trace-reading first, then the in-process ReproScript driver; don't burn time on headless attempts for that class.


12. Working agreements (pre-commit checklist)

Do these before every commit (this is the workflow that worked; deviate only with reason):

  1. swift test is green (all pass). Add tests for new behavior / bug repros.
  2. Visual changes are eyeballed — build the app and screencapture the result (§8), or render offscreen to a PNG. Don't trust headless layout alone for anything that draws.
  3. Frequent, small, logical commits — one feature/fix each. Don't discard uncommited changes.
  4. Don't autopush, PR, or merge unless asked. Branch off main (don't commit straight to it); each fix on its own branch.
  5. Touch only what the task needs; match surrounding style; don't refactor unrelated code.
  6. Concurrent local work uses git worktree, not multiple clones or branch-switching. git worktree add .worktrees/<branch> <branch> — the directory path mirrors the branch name (e.g. .worktrees/fix/foo for fix/foo), so branches keep the normal type/slug naming and never get renamed to worktree-*. .worktrees/ is gitignored. This is distinct from .claude/worktrees/, which Claude Code's own EnterWorktree tool manages automatically for agent isolation — don't hand-edit that one.

If you (the agent) improve this workflow or discover a better verification trick, update this section.


13. Release & CI pipeline

How a release happens, plus the non-obvious things that broke shipping 0.1.0.

Flow (tag-triggered). Push a vX.Y.Z tag → .github/workflows/release.yml (macos-14): build the .app (build-app.sh) → DMG (npm create-dmg) → EdDSA-sign the DMG → gh release create (notes are the matching CHANGELOG.md section, extracted by awk) → commit the new <item> into appcast.xml on main. scripts/release.sh mirrors this locally but leaves the appcast commit/push to you. The new <item> also gets a <description> (HTML release notes from the CHANGELOG section, via scripts/changelog-to-html.py) so Sparkle's update dialog shows the changelog in its scrollable pane.

To cut a release: bump CFBundleShortVersionString / CFBundleVersion in Info.plist, add a ## [x.y.z] section to CHANGELOG.md, merge to main, then git tag vx.y.z && git push origin vx.y.z.

EdDSA keypair — set up, not a placeholder. The keypair exists. The public key is in Info.plist SUPublicEDKey (0XdLbbuO…); the private key lives in two places that must stay the same keypair: the maintainer's login keychain (used by release.sh locally, no flag) and the GitHub Actions secret SPARKLE_ED_PRIVATE_KEY (used by CI). Verified end-to-end: a CI-signed DMG verifies against the Info.plist public key (sign_update --verify <dmg> <sig>). If the signing key and SUPublicEDKey ever diverge, the DMG signs fine but every user's update fails signature verification.

Signing flag — sign_update -s is fatal for newly generated keys. Sparkle's sign_update deprecated -s <key>; for keys generated after that change it prints only a deprecation warning and exits 1 ("no longer supported"). This killed the first 0.1.0 release. Feed the key on stdin instead — both release.yml and release.sh now use: echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>.

Appcast push to protected main needs an admin PAT. main requires the test status check. The workflow's last step commits the appcast update and pushes to main, but the default GITHUB_TOKEN / github-actions[bot] is not an admin, so the required check rejects it (GH006 … protected branch hook declined). main protection has enforce_admins: false, so an admin's push bypasses the check — release.yml's checkout step therefore authenticates with a fine-grained admin PAT in the RELEASE_TOKEN secret (Contents: read/write). Set it on checkout, not by rewriting the push URL: actions/checkout persists an http.<host>.extraheader credential that otherwise overrides inline-URL creds, so the push would keep using the bot token. RELEASE_TOKEN expires 2027-06-27 — rotate it before then or releases fail at the appcast push.

Gatekeeper / notarization. The app is ad-hoc signed, not notarized, so users hit Gatekeeper on first launch; the README documents the xattr -dr com.apple.quarantine / right-click-Open workarounds. A Developer ID cert + notarization (see §8) would remove the prompt entirely.

Never release anything through this pipeline except the main app. The tag-triggered flow above builds and ships Edmund.app only. Extension payloads (e.g. the RaTeX WASM) are a separate concern with their own hosting/release path — never bundled into or triggered by an Edmund version tag. Case in point: the RaTeX WASM asset was pulled from release pending erweixin/RaTeX inline-mode support and the Advanced Math extension's own repo migration — see misc/backlog.md.


14. References

Dependencies and prior art worth consulting before designing something new here:

  • apple/swift-markdown — the CommonMark/GFM parser both back-ends walk (§3, §6 Read mode).
  • SwiftMath — LaTeX rendering (raster only; no SVG output yet — why exported math is PNG, §6).
  • Sparkle — auto-update (§8, §13 for the signing/appcast quirks).
  • Lucide — vendored icon SVGs (ISC), Model/LucideIcons.swift.
  • nodes-app/swift-markdown-engine — an independent AppKit + TextKit 2 live-preview markdown engine (Apache 2.0, macOS 14+). Solves the same problems (viewport virtualization, live styling, wiki links, reading column, LaTeX) with different trade-offs — useful comparison point before inventing a new mechanism for an editing-experience problem, and a candidate source of techniques (e.g. drag-select autoscroll, overscroll) if we hit the same walls.