Skip to content

[EPIC-12] Export: HTML + PDF pipeline — architecture + implementation - #49

Merged
Joncallim merged 17 commits into
masterfrom
epic/12-export
Aug 21, 2026
Merged

[EPIC-12] Export: HTML + PDF pipeline — architecture + implementation#49
Joncallim merged 17 commits into
masterfrom
epic/12-export

Conversation

@Joncallim

@Joncallim Joncallim commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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

  • One composition pipeline: live editor snapshot → fresh ParseExecuting parse → configured cmark-gfm tree → metadata/theme/resources/derived output → one built-in typed template → PreparedExportDocument → HTML writer or isolated PDF adapter.
  • No second Markdown renderer: exact swift-cmark 0.8.0 is isolated to ExportService.
  • Correct cmark lifecycle: GFM extensions are attached before parser feed/finish; C trees/iterators/render buffers are invocation-local and freed on every path.
  • Raw-HTML fidelity without unsafe Markdown links: ordinary HTML/PDF uses CMARK_OPT_UNSAFE + tagfilter; a single ExportURLPolicy rejects authored javascript:, vbscript:, data: and file: schemes.
  • Invalid states are unrepresentable: the target owns its URL/options; self-contained cannot request linked CSS; PDF cannot carry HTML options.
  • Generation identity matches the repo: export carries FileDocument.mutationGeneration as UInt; exact Int conversion happens only at the parser call. Derived anchors use original-source UTF-16 ranges.
  • Inline and block derived output: successful ranges become deterministic sentinels before cmark parse, then map to cmark custom inline/block nodes — no renderer-language branch in E12.
  • Derived failure preserves source: stale/invalid/overlapping/out-of-bounds contributions are not spliced; their authored Markdown stays and an error diagnostic is recorded. Silent omission is impossible.
  • Theme reuse: one bundled structural stylesheet plus Theme→CSS variables; no export palette or per-theme CSS copies.
  • One resource truth: a transient manifest builder freezes once into one immutable ExportManifest.
  • Typed deterministic resources: identity is (full SHA-256(bytes), canonical MIME); companion filenames are exactly <64hex>.<canonical-extension>.
  • Explicit filesystem ownership: the companion directory is exactly report.assets; a versioned marker is required. Only E12's reserved namespace is mutable; non-reserved user files are never touched.
  • Primary-last durability: resources exist before the primary HTML is atomically promoted.
  • Real self-contained contract: E12-visible resources embed; authored raw HTML and unresolved/remote rendering resources fail rather than producing a falsely self-contained artifact.
  • PDF remains an adapter: the same prepared HTML loads into locked-down ephemeral WebKit (JavaScript disabled + restrictive CSP), prints through WKWebView.printOperation(with:) / NSPrintOperation, and PDFKit validates before atomic promotion.
  • One built-in template: no catalog, Handlebars or custom-template loader.
  • Export UI: File ▸ Export… (⌘⇧E), gated to Markdown documents; the save-panel accessory selects format (HTML / self-contained HTML / PDF) and CSS embedding.

What I should test

  1. Export an ordinary Markdown document to self-contained HTML and open it with no network and no MacDown 2 installed — CSS and images must be embedded.
  2. Export the same document to PDF — headings, lists, code blocks, images and links should paginate readably, with code blocks kept together.
  3. Export with the linked CSS option and confirm report.css and report.assets/ are written next to the HTML.
  4. Confirm the front-matter title lands in the HTML <title>.
  5. Confirm a missing local image produces a clear "cannot be self-contained" error for self-contained/PDF, and a warning (not a failure) for standalone HTML.
  6. Confirm export still works offline, and that an authored javascript: link is neutralised in the output.

Risks and limits

  • PDF pagination is not yet dogfooded on a Release build; it relies on the macOS print system plus page-break-inside: avoid on pre and needs a visual pass (searchable/selectable text, 100+ page behaviour).
  • Traversal/symlink/resource-budget tests are not yet added; the resolver does canonical containment but the adversarial corpus does not yet cover those paths.
  • Export panel strings are English-only and not yet localised (E16).
  • No executed UI test covers the Export… menu item.
  • Recorded residual risks also include cmark-vs-swift-markdown parity drift, block-directive semantics, and raw-HTML trust (tagfilter is not a full sanitizer). The full list is in 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.
  • App builds in Debug and Release configuration.
  • Full package suite: 942 tests; 25–28 unrelated pre-existing timing failures in DocumentFileMonitor / JSONAnalysisSession / one WorkspaceModel save-as case (none in export).

Tracks #13.

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.

Copy link
Copy Markdown
Owner Author

First implementation handoff

Start with Slice 0 only from planning/epic-12-implementation.md.

The first worker should implement the dependency/type-contract gate and nothing beyond it:

  • add the explicit exact swift-cmark 0.8.0 dependency and only the cmark-gfm / cmark-gfm-extensions products to ExportService;
  • establish the typed export request/destination/packaging/metadata/result/diagnostic/resource-budget/output-layout contracts;
  • establish the generic derived-content value contract, without any math/diagram renderer types;
  • add the internal cmark wrapper seam and repeated parse/render/free smoke tests;
  • prove invalid destination states are not representable and arbitrary companion paths are rejected;
  • run cd MacDown2/Packages/MacDownKit && swift build && swift test.

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.
@Joncallim Joncallim changed the title [EPIC-12] Architecture: scalable HTML/PDF export pipeline [EPIC-12] Export: HTML + PDF pipeline — architecture + implementation Aug 18, 2026
Jon Callim and others added 4 commits August 18, 2026 02:36
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.
- `![](my%20image.png)` 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
Jon Callim and others added 8 commits August 18, 2026 04:07
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
Joncallim marked this pull request as ready for review August 21, 2026 10:19
@Joncallim
Joncallim merged commit 7227149 into master Aug 21, 2026
2 checks passed
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
Joncallim deleted the epic/12-export branch September 4, 2026 01:45
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants