[EPIC-12] Export: HTML + PDF pipeline — architecture + implementation - #49
Merged
Conversation
What: make destination states unambiguous, define raw-HTML and cmark policies, and replace the multi-file atomicity assumption with a resource-first primary-last commit protocol. Why: implementation workers should not have to invent security, packaging, resource ownership, or output durability behavior. Verification: architecture re-read against EPIC_STANDARD, RELEASE_HARDENING, live E11 code, and the pinned cmark 0.8.0 APIs.
Owner
Author
First implementation handoffStart with Slice 0 only from The first worker should implement the dependency/type-contract gate and nothing beyond it:
Do not start HTML templates, resource copying, PDF/WebKit, UI integration, or any renderer-specific branch in this slice. Stop and return for architecture revision if exact cmark resolution conflicts, required GFM APIs are unavailable, C lifetime cannot be safely contained, or another dependency appears necessary. Do not solve any of those by adding a second Markdown renderer/library. Once Slice 0 is green and reviewed, proceed serially through Slices 1–6; the plan's stop conditions remain binding. |
…ontract What: Implement the E12 export pipeline end-to-end. Markdown exports to standalone HTML (embedded or linked CSS), self-contained HTML (CSS/assets embedded), and PDF, all local and offline. Includes the renderer-neutral derived-content destination that later math (E19) and diagram (E20/E21) features will consume. Why: Export is the shared destination for future first-party technical content, so a dependable offline path and a stable contribution contract must land before those features build their own exporters. How it works: A live editor snapshot is freshly parsed through the injected ParseExecuting, converted to GFM-faithful HTML via swift-cmark 0.8.0 (isolated in ExportService), and combined with theme CSS, a content-addressed resource manifest and one built-in template into a frozen PreparedExportDocument. The HTML writer emits self-contained or companion (report.assets/*) output with a versioned ownership marker and primary-last durability. The PDF adapter renders the same prepared HTML through locked-down WebKit, the macOS print system and PDFKit validation. ExportDerivedContribution is the renderer-neutral destination for inline and block derived content, with bounded fallback that preserves authored source on failure. Verification: swift test --filter ExportServiceTests passes 53 tests in 7 suites; swiftlint lint --strict and swiftformat --lint are clean; the app builds in both Debug and Release configuration.
Deep review of the export pipeline landed in #49, with fixes applied. Correctness and contract - cmark-gfm's `ensure_registered` guards a process-global registry with a plain `int`; concurrent exports raced it. Registration now runs once through a Swift `static let` initialiser. - Inline sentinels could not be told apart: `…INLINE1` was matched inside `…INLINE10`. Sentinels now terminate their digit run, and matching is longest-first. - A derived contribution's own diagnostics were dropped, and an error-level one was still spliced. Diagnostics are always forwarded and an error rejects placement, per the plan's "silent omission is forbidden". - A failed derived contribution made a PDF/self-contained export fatal. Only unresolved resources close an export now. - Self-contained HTML silently omitted authored raw HTML instead of failing; PDF gave no signal at all. Self-contained now fails with a clear error and PDF warns, matching the architecture contract. - The resolver read any path a reference resolved to, including `../../.ssh/id_rsa`. Resource root containment is now enforced: only files inside the document's own folder are ever read. - Added the plan's `ExportResourceBudget` (source, per-file, aggregate, count, derived and prepared-HTML gates). Oversized files are rejected from metadata before any read. - The PDF adapter never implemented `didFailProvisionalNavigation`, so a load failure hung the export forever; added it plus a watchdog. The print result is now checked, and the scratch file is removed on every path instead of being left beside the user's document. - The PDF Content-Security-Policy was injected by matching `"<head>\n"` in the rendered string; the template now takes head content directly, so the policy cannot silently stop applying. Speed - The tree walk built a Swift `String` of every node's type name; it now compares the cmark enum. - Resource embedding rescanned and recopied the whole document per image, each pass over a body already inflated by base64. One scan now. - Splicing derived content used `String.Index(utf16Offset:in:)` per boundary — quadratic in contribution count. One UTF-16 pass now. - The body was rescanned per contribution to pick a sentinel, and the body's UTF-16 length was recounted per contribution. Both are measured once. - A repeated image reference was re-read and re-hashed every time; the resolver caches by reference, and reports each broken reference once. - The bundled stylesheet is read once per process, and the PDF path base64-encodes off the main actor. User experience - `ExportError` was not `LocalizedError`, so every failure showed macOS's "operation couldn't be completed". Errors now carry prose and a recovery suggestion, and the alert shows both. - Choosing PDF kept the `.html` filename and wrote a PDF into it. The save panel's filename and content type now follow the format picker. - Composition warnings were discarded; they are now reported before Finder takes focus. - Neutralised link/image schemes were emptied with no diagnostic. - `` never resolved: reference paths are now percent-decoded. - The structural sheet's `color-scheme` overrode the theme's, so dark exports rendered light-mode UA widgets. - The task-list rule matched a class cmark-gfm never emits, so task items kept their bullet next to the checkbox. - Code-block and rule colours were blended the wrong way, giving code a near-full-strength syntax colour as its backdrop. - Added print rules: a readable print palette (a dark theme printed as light text on white paper), full-width text block, heading and block break control, orphans/widows, and underlined links. - Added a visible focus ring to exported documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
…ders Second review pass over #49. Document metadata was one line — the front-matter `title` string, or nothing — where the architecture fixes four behaviours. All four now hold: - a non-empty scalar front-matter title becomes the browser `<title>` and one visible `<h1>`; - a saved document with no front-matter title takes its browser title from the filename stem, so exporting `Meeting Notes.md` no longer opens a tab showing a file path; - a filename fallback never becomes a visible heading, because it is not a heading the author wrote; - a non-scalar title (a YAML list or mapping) is reported as a warning and then treated as absent, instead of being silently dropped. `ExportRequest.documentDirectory` becomes `documentURL`. The resource root and the title fallback are both derived from that one URL, so they cannot disagree, and it matches the architecture's "resource root is only the canonical parent of a saved file URL". A resource-free export no longer creates an empty `report.assets` directory beside the HTML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
Third review pass over #49. - `ExportURLPolicy.scheme(of:)` accepted any characters before the first colon and read the URL exactly as authored. It now follows RFC 3986's scheme grammar, and skips the leading whitespace and embedded tab/newline/return that a URL parser removes before reading a scheme — so a target a browser would run as `javascript:` cannot be classed as a scheme-less relative path. It also reads the string in one pass instead of materialising an array of scalars. - An unresolved-resource failure listed every affected reference in the alert. It now lists five and counts the rest. - Export revealed the primary file plus every companion file, so an image-heavy export opened Finder with two dozen items highlighted across two folders. It reveals the document. - The SHA-256 digest was hexed with `String(format:)` per byte. - `ExportManifest.referenceMap` documented itself as the mechanism the HTML writer uses to rewrite references; references are rewritten in the cmark tree during composition, and the map is a record of what happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
`ExportError.description` had become a switch whose every case was a single expression, which SwiftFormat rewrites into switch-expression form. Binding the summarised list keeps the shape the file already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
7 tasks
CI lint run against the branch. Three mechanical rules: single-line property bodies and single-line `if` bodies are wrapped, and `throws` is dropped from three tests that never throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
SwiftLint's `trailing_comma` is stricter than SwiftFormat's: it wants the comma on any multi-line collection literal, not only ones whose closing bracket sits on its own line. Binding the contribution first reads better than an inline literal here anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
…r-file check Addresses the pre-merge review of #50: two documents exported into the same folder shared one `report.assets` directory and one `report.css`, so exporting a second document could silently overwrite or expose the first's linked stylesheet and images. Every export's companion directory is now named after its own primary file (`Foo.html` -> `Foo.assets/`), computed once at composition time and threaded through the resolver, the rendered body, and the file writer so the name baked into `<img src>`/`<link href>` always matches the directory actually written. The linked stylesheet moves into that directory too, as a content-addressed resource alongside images, replacing the old top-level `report.css`. A front-matter title that duplicates the document's own opening `# Heading` no longer renders two `<h1>` elements: the browser `<title>` still comes from front matter, but the synthesized visible heading is suppressed when the body already opens with one. `ExportResourceResolver` now checks `.isRegularFileKey` instead of `exists && !isDirectory`, so a device file, socket, or FIFO referenced by an authored path is rejected before ever reaching `Data(contentsOf:)`; a symlink to a regular file inside the resource root still resolves, since the key follows symlinks to their target by consulting `stat` rather than `lstat`. Added: two-document isolation test exercising the full compose-and-write path, symlink/directory resolver tests, and three title-duplication tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
CI lint caught what static review couldn't: try Set(x) instead of Set(try x). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
CI's real macOS run disagreed with a comment I wrote: .isRegularFileKey does not traverse a symlink's target the way lstat vs. stat suggests — it reports on the symlink itself, not what it points to. A referenced symlink to a real image was rejected outright with "not a readable file", where the prior exists+isDirectory check had accepted it. containedFileURL now returns the fully symlink-resolved URL rather than the as-authored one. Root containment already computed that resolved path to decide containment; every later step (isRegularFile, size, the read itself, and MIME sniffing from the extension) now operates on that same resolved URL, so there is one notion of "the file" a reference names rather than two that could disagree across a symlink hop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
…tion The `___llvm_profile_runtime` undefined-symbol failure chased across several CI runs on this PR turns out to be a known, already-documented issue: planning/epic-10-implementation.md's evidence table names it directly — Xcode 26's `build-for-testing` injects `-fprofile-instr-generate` into every target of the scheme's Test action regardless of the scheme's own coverage setting, and the profiling runtime never gets linked into the pure-C SwiftPM targets (tree-sitter, cmark, Yams), so the link fails. That epic recorded `-enableCodeCoverage NO` as the fix and applied it locally, but noted CI's macos-26 runner "may not exhibit this toolchain quirk" and left no project/CI change for it. It does exhibit it, reproducibly, so the same documented flag now applies in CI too. No coverage report is read anywhere in this pipeline, so there is nothing downstream to lose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
ExportCoordinator's post-export alert filtered to .warning severity only. A derived contribution that fails composition is forwarded as an .error diagnostic without being fatal to the export (only unresolved resources are), so a document with a broken math/diagram block and no other problems exported "successfully" with zero user-facing indication — Finder just opened the file. Renamed to presentDiagnosticsIfNeeded and show every diagnostic that reaches a completed export. Also fixes two doc-comment drifts caught during the same pass: PreparedExportDocument.visibleTitle didn't mention the authored-heading suppression, and BuiltInExportTemplate.document's parameter docs were out of order.
Four review passes over the Epic 12 export pipeline: contract divergences against planning/epic-12-implementation.md (root containment, resource budgets, title policy, self-contained raw-HTML rejection), per-document companion-directory isolation, title/heading duplication, symlink-resolution hardening, and a CI build-for-testing coverage-instrumentation link failure. CI green on 92 files across both jobs.
Joncallim
marked this pull request as ready for review
August 21, 2026 10:19
7 tasks
Joncallim
pushed a commit
that referenced
this pull request
Sep 2, 2026
ExportService.renderMarkdownFragment renders a standalone Markdown fragment to HTML, independent of the whole-document metadata/theme/ resource pipeline - the one missing piece an export-side adapter needed (no existing public entry point did this). Deliberately omits CMARK_OPT_UNSAFE: a contribution-generated fragment has no legitimate need to embed raw HTML, so this is more conservative than the main document pipeline. ExportContributionAdapter turns contribution results into ExportDerivedContribution, the type E12's already-shipped DerivedContentComposer has accepted (unused) since #49. Its switch over ContributionRepresentation has no default: case, so a third representation variant fails to compile here until this adapter decides what it means; .html specifically constructs an empty-html contribution plus a diagnostic rather than silently dropping it, so DerivedContentComposer's existing empty-html rejection preserves authored source while the diagnostic stays visible. ExportCoordinator.performExport now computes contributions via its own extra parse of the same text ExportComposer.prepare will parse again moments later internally - one redundant parse per export, accepted as negligible next to export's other costs, and avoids threading Preview's live parse session into the export path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv
Joncallim
added a commit
that referenced
this pull request
Sep 6, 2026
…54) * docs: mark E13 (Settings) done in the epic index PR #52 merged; the table still said "open". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * docs: write EPIC-14 implementation architecture (contributions + text filters) Reconciles issue #15 against live master (bbfb010): E12's contribution seam (ExportDerivedContribution/DerivedContentComposer) and Preview's pre-computed-blocks parameter are both already built and unused, so the new Contributions/TextFilters modules and their app-layer adapters are the only new surface needed. Covers all 18 EPIC_STANDARD.md sections, including the renderer-neutral markdown/html result shape, the text-filter process safety contract, and eight dependency-ordered implementation slices. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * EPIC-14 Slice 1: contribution protocol, registry, deterministic test contribution New Contributions SwiftPM target (depends only on MarkdownEngine, per epic-14-implementation.md §5): the renderer-neutral result types (ContributionResult/Content/Representation/Diagnostic), the Contributing protocol, and ContributionRegistry, which runs every registered contribution and isolates one contribution's thrown error from the rest while letting cancellation propagate. DeterministicTestContribution makes that isolation/cancellation behaviour deterministically testable without depending on the real TOC contribution, which lands in the next slice. ContributionRegistry.standard is empty until then. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * EPIC-14 Slice 2: TOC contribution TOCContribution replaces every line consisting only of [TOC] with a Markdown list nested from document.headings, using the same level-stack algorithm most TOC generators use for skipped heading levels. Registered into ContributionRegistry.standard. Corrects the architecture doc's original claim that export would produce "real anchor links": verified directly that no part of the export pipeline generates a stable id for a heading, and that Preview hands every link (anchor or not) to NSWorkspace.shared.open rather than scrolling to it - both pre-existing gaps, not something a link generated by this slice could paper over. TOC's list is plain text for now, with the anchor work named as explicit follow-up in epic-14-implementation.md Section 18 rather than attempted speculatively. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * Fix Slice 1 CI failures: explicit switch returns, wrap property body Build: a switch used as run(...)'s implicit return mixed throwing cases with an empty [] literal, which the compiler could not infer a type for without context ("empty collection literal requires an explicit type"). Made every non-throwing case an explicit `return` instead of relying on switch-expression inference. Lint: DeterministicTestContributionError.errorDescription's single-line body needed wrapping onto its own line (wrapPropertyBodies), the same SwiftFormat rule EPIC-13 hit for the same reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * EPIC-14 Slice 3: Preview contribution adapter DocumentEditorSplitView now runs the standard contribution registry alongside its existing reparse (a new .task(id: parseSession.document), cancelled and restarted automatically by SwiftUI on every reparse rather than a hand-rolled generation counter) and merges placeable results into the blocks TextualMarkdownPreview already accepts as a pre-computed parameter - no change to the Preview package itself. PreviewContributionAdapter is a standalone, stateless type rather than an extension on DocumentEditorSplitView: that view's body was already at 238 effective lines against SwiftLint's 250-line type_body_length budget, with too little headroom for this feature's glue code. Bought back real margin by extracting the pre-existing divider(in:) drag handler into DocumentEditorSplitView+Divider.swift too (dragOriginFraction, currentSplitFraction and coordinator are internal rather than private so that extension can reach them, matching how DocumentEditorSplitView+AppSettings.swift already split out unrelated content for the same budget reason). Substitution works at PreviewBlock granularity: a contribution's marker occupies an entire base block by itself in the expected case (TOCContribution only recognises a marker on its own line); one sharing a multi-line paragraph with other text would replace that whole paragraph, a documented, accepted limitation of Preview having no inline-splicing mechanism. project.yml: added the new Contributions package product to the MacDown2 app target (needed to import it at all) and to MacDown2Tests, plus Preview to MacDown2Tests for the new adapter test's PreviewBlock fixtures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * Fix Slice 1/3 CI failures: trailing comma, hoistAwait ContributionRegistry.run: SwiftLint's trailing_comma is stricter than SwiftFormat's - it flags a multi-line collection literal with no trailing comma even when it holds a single element, if that element's own call spans multiple lines. Bound the diagnostic to a local let first, the same fix EPIC-13 used for the identical rule. PreviewContributionAdapter.results: SwiftFormat's hoistAwait rejected (try? await foo()) ?? bar - await nested inside a parenthesized try? combined with ??. Replaced with an explicit do/catch, which is unambiguous and matches the try-await pattern already used everywhere else in this codebase, rather than relying on try?/await/?? precedence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * EPIC-14 Slice 4: Export contribution adapter ExportService.renderMarkdownFragment renders a standalone Markdown fragment to HTML, independent of the whole-document metadata/theme/ resource pipeline - the one missing piece an export-side adapter needed (no existing public entry point did this). Deliberately omits CMARK_OPT_UNSAFE: a contribution-generated fragment has no legitimate need to embed raw HTML, so this is more conservative than the main document pipeline. ExportContributionAdapter turns contribution results into ExportDerivedContribution, the type E12's already-shipped DerivedContentComposer has accepted (unused) since #49. Its switch over ContributionRepresentation has no default: case, so a third representation variant fails to compile here until this adapter decides what it means; .html specifically constructs an empty-html contribution plus a diagnostic rather than silently dropping it, so DerivedContentComposer's existing empty-html rejection preserves authored source while the diagnostic stays visible. ExportCoordinator.performExport now computes contributions via its own extra parse of the same text ExportComposer.prepare will parse again moments later internally - one redundant parse per export, accepted as negligible next to export's other costs, and avoids threading Preview's live parse session into the export path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * Fix Slice 4 CI failure: hoist try/await out of ExportRequest's argument list SwiftFormat's hoistAwait/hoistTry flagged contributions: try await exportContributions(for: document) as one named argument buried inside the larger multi-line ExportRequest(...) call, rather than the throwing/async work being its own statement. Computed it into a local let first, matching every other try await call site in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pVm6rWERNoNgDBpAnoQXv * EPIC-14 remediation Slice 1: contract and producer correctness Generalizes ContributionResult.sourceGeneration's documentation to an opaque caller-selected snapshot token (no longer named as FileDocument.mutationGeneration specifically), adds a post-await and a final cancellation check to ContributionRegistry.run alongside the existing pre-await one, and makes TOCContribution.findMarkers require the marker line to be the entire content of exactly one top-level, parsed .paragraph block rather than a lexical [TOC] line anywhere — so a marker inside fenced/indented code, a list item, a block quote, a heading, front matter, or an HTML block is literal text in both Preview and Export. Per the architecture takeover hand-off on PR #54 (issuecomment-5538790625), findings/passes 1 (partial) and 3, plus half of pass 8. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * EPIC-14 remediation Slices 2-3: pure Preview composer + task ownership Replaces PreviewContributionAdapter.merged (whole-block substitution) with compose(...): a pure, fully-parameterized admission -> overlap/budget resolution -> source-ordered composition pipeline (PreviewContributionAdmission.swift, PreviewContributionComposer.swift). .inline splices in place; .block replaces a whole base block or complete physical line(s) inside a top-level paragraph. Malformed ranges are rejected before any SourceMap lookup, never clamped. Overlap resolution runs before the new explicit PreviewContributionBudget (64 placements / 64 KiB, replacing a silent prefix(64)). Affected/generated blocks get a deterministic ID from CryptoKit.SHA256 over their role/span/contribution-id; untouched blocks pass through with their original IDs. PreviewContributionSession (mirrors MarkdownParseSession's shape) owns one atomic PreviewContributionComposition and a PreviewContributionTaskID (document/tab identity + parsed revision) publish guard, wired into DocumentEditorSplitView in place of the former separate previewBlocks/contributionResults state and the task keyed on parseSession.document. A cancelled/superseded refresh never publishes; a non-text FileDocument mutation can no longer invalidate a valid composed TOC. PreviewContributionDiagnosticsBadge surfaces producer- and adapter-raised diagnostics beside the existing busy indicator, gated to the currently displayed parsed revision. Per the architecture takeover hand-off on PR #54 (issuecomment-5538790625): the shared Preview composition seam, findings/passes 1 (remainder), 2, 5, 6, 7, and the Preview half of 8 and 9. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * EPIC-14 remediation Slice 4: Export diagnostic side channel + CI fix Adds import Foundation to ExportContributionAdapterTests.swift, fixing the exact build-for-testing failure at this PR's pinned head (cannot find 'URL' in scope) without any production, project, package, or workflow change. Replaces ExportContributionAdapter.exportContributions(from:) with adapt(_:) -> Adaptation { contributions, standaloneDiagnostics }: a content == nil result has no sourceRange to anchor an ExportDerivedContribution to, so its diagnostics now become standaloneDiagnostics instead of being dropped; a content-bearing result's diagnostics stay attached only to its own contribution. ExportCoordinator.combinedDiagnostics(_:_:) merges standaloneDiagnostics before the export service's own diagnostics, identically for the HTML and PDF paths, so the two cannot drift through separately copy-pasted merge logic. Per the architecture takeover hand-off on PR #54 (issuecomment-5538790625), findings/passes 4 and the Export half of 9. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * EPIC-14 remediation Slice 5: reconcile architecture doc with shipped state Adds epic-14-implementation.md §19, reconciling the document with what actually shipped in the PR #54 remediation rather than restating the architecture takeover comment: the compose(...) pipeline shape, opaque sourceGeneration semantics, semantic TOC discovery, PreviewContributionSession's task ownership, the Export Adaptation side channel, and the explicit Preview budget. The two residual risks already named in §18 (unhandled .html, non-clickable TOC entries) are unchanged. Per the architecture takeover hand-off on PR #54 (issuecomment-5538790625), finding/pass 10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix CI SwiftFormat: remove redundant parens flagged by CI's tool version Simplifies the force-unwrap-avoidance construct SwiftFormat generated locally into a plain boolean comparison, which both the local and CI SwiftFormat installs agree is already formatted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix pre-existing NSEvent crash in CommandStateRefreshTests NSEvent.mouseEvent(with:...) asserts its type is an actual mouse event; .appKitDefined is a system-defined type and must be constructed via otherEvent(with:...) instead. macOS 26's AppKit enforces this assertion strictly (crashing the whole test process), where a prior macOS silently tolerated the mismatch. Discovered while running the full app test suite to verify the EPIC-14 remediation on this branch; unrelated to that work but fixed here per user request rather than split into a separate PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix pre-existing recovery ledger bug + flaky test wiring (E18) Production fix: RecoveryBuffer.migrateLegacyFenceLedger unconditionally swept every legacy (non-generation-encoded) recovery epoch into the durable `retired` set on ledger load, even when that epoch was still the recorded current owner for its document. Since isRetired's legacy branch checks only `retired` membership (never `currentByDocument`), this made a document's still-current crash-recovery snapshot permanently unrecoverable the next time the ledger loaded (e.g. app relaunch) — silent data loss for any document using a legacy, plain-UUID recovery epoch. Now skips retiring a legacy lifetime that is still its document's current owner. Test fixes (all pre-existing bugs, not product regressions): - ExternalFileControllerRecoveryTests: `ExternalFileController.model` is `weak`; the move-retry test constructed `WorkspaceModel` inline so it was deallocated before any assertion ran, making every isCurrentRecoveryAction check silently see `model == nil`. - ExternalFileControllerCloseRecoveryTests: pendingCleanupFixture() relied on TabStore's 300ms-debounced session-save timer firing before the assertion ran; now saves synchronously. - ScriptedRecoveryExecutor: persist/retire now perform real recovery-buffer IO (matching sibling fakes already in the suite) so tests asserting against the real on-disk buffer can actually pass; remove/migrate deliberately stay fully scripted since one shared test replays the same document identity/epoch across all four actions to exercise the controller's own retry bookkeeping in isolation. Discovered while running the full app test suite to verify the EPIC-14 remediation on this branch; unrelated to that work but fixed here per user request rather than split into a separate PR. Verified: full `MacDown2Tests` target (71/71), full MacDownKit package suite (1054/1054), swiftformat --lint and swiftlint --strict clean, app build green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Implements Epic 12 end-to-end: Markdown documents export to standalone HTML (embedded or linked CSS), self-contained HTML (CSS and images embedded), and PDF — all local and offline. This PR contains both the binding implementation architecture (
planning/epic-12-implementation.md, committed first) and the full implementation on top of it.A live editor snapshot is composed into a frozen
PreparedExportDocument, then written as HTML on disk or printed to PDF through the macOS print system. It also lands the renderer-neutral derived-content destination that later first-party math (E19) and diagrams (E20/E21) will consume, so those features extend this one pipeline instead of building a second exporter.Why
Export is the shared destination for future first-party technical content. A dependable offline path and a stable contribution contract must land before E19/E20/E21 build their own renderers, or each would invent a parallel exporter.
How it works
ParseExecutingparse → configured cmark-gfm tree → metadata/theme/resources/derived output → one built-in typed template →PreparedExportDocument→ HTML writer or isolated PDF adapter.swift-cmark0.8.0 is isolated toExportService.CMARK_OPT_UNSAFE + tagfilter; a singleExportURLPolicyrejects authoredjavascript:,vbscript:,data:andfile:schemes.FileDocument.mutationGenerationasUInt; exactIntconversion happens only at the parser call. Derived anchors use original-source UTF-16 ranges.ExportManifest.(full SHA-256(bytes), canonical MIME); companion filenames are exactly<64hex>.<canonical-extension>.report.assets; a versioned marker is required. Only E12's reserved namespace is mutable; non-reserved user files are never touched.WKWebView.printOperation(with:)/NSPrintOperation, and PDFKit validates before atomic promotion.What I should test
report.cssandreport.assets/are written next to the HTML.<title>.javascript:link is neutralised in the output.Risks and limits
page-break-inside: avoidonpreand needs a visual pass (searchable/selectable text, 100+ page behaviour).planning/epic-12-implementation.md§3.18.Verification
swift test --filter ExportServiceTests— 53 tests / 7 suites pass.swiftlint lint --strict MacDown2— 0 violations (330 files).swiftformat --lint MacDown2— clean.DocumentFileMonitor/JSONAnalysisSession/ oneWorkspaceModelsave-as case (none in export).Tracks #13.