One-line fix to InlineHTMLRenderer removing an errant .parseBlockDirectives parse option, plus regression tests, a bugfix report, and a changelog entry.
InlineHTMLRenderer.render re-parsed inline source with Document(parsing:options:[.parseBlockDirectives]) — block directives use @Name syntax, so @Observable-prefixed text re-parsed as a BlockDirective node the Walker has no visitor for, silently dropping the whole run.MarkdownBlockParser (which never enables it). It was the only site in the codebase passing any ParseOptions.renderInline call site — list items, paragraphs, headings, table cells — not just list items.visitBlockDirective ever existed, so directives were always dropped; nothing relied on them. The T-1640 list-marker re-parse is orthogonal and unaffected.Ready to push
Four parallel review agents (reuse, quality, correctness/edge-cases, spec/docs) found no correctness issues and no blocking concerns. The fix is minimal, complete, and makes the inline re-parse consistent with the main document parser. Every worthwhile nit was applied; the rest were deliberately skipped with reasons. Regression tests (9) pass, related suites (~240 tests) pass, both platform builds are green with zero warnings, and lint is clean.
783567d T-1641: Fix inline text rendering when it starts with @ Prism shows Markdown files. A list item whose text began with an at-sign word — like @Observable macro is used — was showing up as an empty bullet: the text vanished. The same thing happened to paragraphs, headings, and table cells that started with @.
When Prism turns Markdown into the HTML it displays, it re-reads each block's text a second time. That second read was told to look for a special Apple documentation feature called "block directives", which are written starting with @. So a normal sentence starting with @Observable was mistaken for one of those special commands and thrown away.
@ directives in the first place.@-words are treated as ordinary text.Since the T-1542 WebKit rendering cutover, the block model stores each block's Markdown source string rather than a parsed AST. BlockHTMLEmitter.renderInline therefore re-parses that string through InlineHTMLRenderer.render to emit HTML and build the source map. That re-parse was configured with options: [.parseBlockDirectives].
swift-markdown's block directives use @Name syntax. With the option on, inline text beginning @Observable/@MainActor/@State parsed as a BlockDirective — block structure, not a Text node. The renderer's Walker (a MarkupWalker) has no visitBlockDirective, so the default descend produced no visible output and the run was dropped.
The fix removes the option rather than adding a visitBlockDirective handler. That is correct because Prism is a CommonMark/GFM viewer, not DocC — the main parser (MarkdownBlockParser) never enabled directives, so the two parse passes had silently diverged. Removing the option restores consistency and needs no new code path.
InlineHTMLRenderer.swift:60 was the sole call site in the codebase passing any ParseOptions; every other Document(parsing:) (MarkdownBlockParser:64,84, ExportSourceMapper:83, DetailsBlockParser:91,237) uses the no-options initializer. Removing .parseBlockDirectives makes the whole codebase uniform.
@ mid-line, user@host, @ inside code spans / links: never at block start, never a directive — unaffected before and after.@Image(source: x) and multi-line @Comment { … }: these were the forms most aggressively consumed with the option on; with it off there is no @-parser, so they render verbatim as plain text. A new test anchors @Image(source: x).visitOrderedList + literalListMarker) depends only on standard CommonMark list parsing; @ never routes through it. Orthogonal and unaffected.The identity test asserts the runs tile the whole @-prefixed source (ordered, non-overlapping, starting at 0, summing to the UTF-16 length) so selection-anchored notes still resolve — a dropped run would yield total 0.
visitSymbolLink is effectively dead (.parseSymbolLinks was never enabled), and CLAUDE.md describes the source-map data island as a <div hidden> while the emitter uses <script type="application/json">. Neither is touched by this commit.
InlineHTMLRenderer.swift
Why it matters. The single production change and the whole fix. Aligns the inline re-parse with the main document parser so @-prefixed text is plain text on both passes.
What to look at. InlineHTMLRenderer.swift:55-60
AtPrefixInlineTests.swift
Why it matters. Locks in the fix for list items, paragraphs, headings, and table cells, plus the canonical @Name(args) directive shape and source-map run identity.
What to look at. AtPrefixInlineTests.swift:1-135
A handler would have to reconstruct the exact directive source (@, args, braces) as literal text and would keep the inline parser inconsistent with the document parser for no benefit. Removal is minimal and restores consistency.
All five specs/webview-rendering/ documents were searched: none mention parseBlockDirectives, block directives, or DocC. The option was an unexplained addition in the T-1542 cutover, so removing it diverges from no documented decision. The bugfix report is the sufficient artifact.
The reuse agent flagged a 4-line normalisedText wrapper duplicated with NumberedTaskListItemTests. Promoting it to shared support would modify a prior commit's passing test file for no functional gain and matches the existing house style, so it was left as-is.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| info | AtPrefixInlineTests.swift:126 — source-map identity test | The identity test was weaker than its T-1640 sibling: it asserted only the total run length, not that runs start at 0 and are ordered/non-overlapping. | Added sourceStart==0 and ordered/non-overlapping assertions for parity, with an explanatory comment. |
| info | AtPrefixInlineTests.swift — coverage | Tests covered bare @word forms but not the canonical @Name(args) directive shape, which was the form most aggressively consumed by the removed option. | Added parenthesisedDirectiveShapeRendersLiteral covering @Image(source: diagram.png); passes. |
| info | report.md — accuracy | Report cited the pre-fix line number (55) and said the re-parse was 'needed for T-1640 list-marker handling', which misattributes why the re-parse exists. | Corrected to line 60 and reworded: the re-parse is inherent since the T-1542 cutover (block model stores strings); T-1640 only added list handling on top. |
| nit | AtPrefixInlineTests.swift — redundancy | The emitter-level list-item test overlaps the end-to-end list test, and the four cross-call-site tests exercise the same shared render path. | Kept — matches the NumberedTaskListItemTests house style and each cross-site test guards that its emitter method routes through renderInline. |
| nit | AtPrefixInlineTests.swift:29 — reuse | The private normalisedText helper is byte-identical to the one in NumberedTaskListItemTests. | Skipped — promoting it would modify a prior commit's test file for no functional gain and matches existing style. |
| nit | InlineHTMLRenderer.swift:182 / CLAUDE.md — pre-existing | visitSymbolLink is effectively dead code (.parseSymbolLinks never enabled), and CLAUDE.md describes the source-map data island as a <div hidden> while the emitter uses <script type=application/json>. | Out of scope — pre-existing, not introduced or touched by this commit. Flagged for awareness only. |
Click to expand.
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 2b53ec4..6619a65 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -52,7 +52,12 @@ struct InlineHTMLRenderer { footnotes: footnotes, nextRunID: runIDAllocator )- let document = Document(parsing: source, options: [.parseBlockDirectives])+ // No `.parseBlockDirectives`: block directives use `@Name` syntax, so enabling+ // them here made inline text starting with `@Observable`/`@MainActor` re-parse as+ // a BlockDirective the Walker has no visitor for, dropping the whole run (T-1641).+ // The main document parser (`MarkdownBlockParser`) never enables directives, so+ // matching it keeps `@word`-prefixed text as plain text on both parse passes.+ let document = Document(parsing: source) renderer.visit(document) renderer.closeRun() runIDAllocator = renderer.nextRunID
diff --git a/prismTests/WebRendering/AtPrefixInlineTests.swift b/prismTests/WebRendering/AtPrefixInlineTests.swiftnew file mode 100644index 0000000..b067752--- /dev/null+++ b/prismTests/WebRendering/AtPrefixInlineTests.swift@@ -0,0 +1,118 @@+//+// AtPrefixInlineTests.swift+// prismTests+//+// Regression tests for T-1641: inline content beginning with `@` renders empty.+//+// `InlineHTMLRenderer.render` re-parses each block's inline source string with+// `Document(parsing:options:[.parseBlockDirectives])`. Block directives use the+// `@Name` syntax, so a run of text starting with `@Observable ` re-parses as a+// BlockDirective — block structure the Walker has no visitor for — and the whole+// run is dropped, leaving an empty list entry / paragraph / heading / cell.+//+// The main document parser (`MarkdownBlockParser`) parses WITHOUT block+// directives, so the leading `@` word is plain text there; only the inline+// re-parse enables directives and diverges. The fix removes the option so the+// inline re-parse matches the document parse.+//+// Expected: any inline content beginning with `@word` renders that word as+// literal visible text. Shared defect class across every renderInline call+// site: list items, paragraphs, headings, and table cells.+//++import Foundation+import Testing+@testable import prism++struct AtPrefixInlineTests {++ private func normalisedText(_ blocks: [MarkdownBlock]) -> String {+ let doc = BlockHTMLEmitterTestSupport.emit(blocks)+ return BlockHTMLEmitterTestSupport.normalisedText(doc.html)+ }++ // MARK: End-to-end (parse + emit) — the exact shape from the ticket++ @Test("List item starting with `@Observable` keeps its text (T-1641)")+ func listItemStartingWithAtRendersText() {+ let source = """+ - @Observable macro is used here+ - Normal item+ """+ let blocks = MarkdownBlockParser.parse(source)+ let doc = BlockHTMLEmitterTestSupport.emit(blocks)+ let text = BlockHTMLEmitterTestSupport.normalisedText(doc.html)+ // Expected: the leading `@Observable` word renders as literal text.+ // Actual (bug): an empty list entry — the whole run is dropped.+ #expect(text.contains("@Observable macro is used here"), "rendered text: \(text)")+ #expect(text.contains("Normal item"), "rendered text: \(text)")+ }++ // MARK: Emitter-level — content beginning with `@`++ @Test("List item content beginning with `@` keeps the run")+ func listItemContentKeepsAtPrefix() {+ let items = [ListItem(content: "@Observable macro", checkbox: nil)]+ let text = normalisedText([.list(ordered: false, start: 1, items: items)])+ #expect(text.contains("@Observable macro"), "rendered text: \(text)")+ }++ @Test("A word that is only `@Name` (no trailing text) still renders")+ func bareAtWordRenders() {+ let text = normalisedText([.paragraph(markdown: "@Observable")])+ #expect(text.contains("@Observable"), "rendered text: \(text)")+ }++ // MARK: Other renderInline call sites sharing the defect class++ @Test("Paragraph beginning with `@` keeps its text")+ func paragraphKeepsAtPrefix() {+ let text = normalisedText([.paragraph(markdown: "@Observable is a Swift macro")])+ #expect(text.contains("@Observable is a Swift macro"), "rendered text: \(text)")+ }++ @Test("Heading beginning with `@` keeps its text")+ func headingKeepsAtPrefix() {+ let text = normalisedText([.heading(level: 2, text: "@MainActor isolation")])+ #expect(text.contains("@MainActor isolation"), "rendered text: \(text)")+ }++ @Test("Table cell beginning with `@` keeps its text")+ func tableCellKeepsAtPrefix() {+ let text = normalisedText([.table(headers: ["Attribute"],+ rows: [["@State property wrapper"]],+ alignments: [.leading])])+ #expect(text.contains("@State property wrapper"), "rendered text: \(text)")+ }++ @Test("Parenthesised directive shape `@Image(source: x)` renders as literal text")+ func parenthesisedDirectiveShapeRendersLiteral() {+ // `@Name(args)` is the canonical block-directive syntax — the form most+ // aggressively consumed when `.parseBlockDirectives` was enabled. With the+ // option gone it must render verbatim as plain text.+ let text = normalisedText([.paragraph(markdown: "@Image(source: diagram.png) caption")])+ #expect(text.contains("@Image(source: diagram.png) caption"), "rendered text: \(text)")+ }++ // MARK: Source-map identity for the `@`-prefixed run++ @Test("Runs tile the whole `@`-prefixed source: total length equals content length")+ func atPrefixRunsMapIdentity() {+ let source = "@Observable macro"+ let items = [ListItem(content: source, checkbox: nil)]+ let doc = BlockHTMLEmitter.emit(blocks: [.list(ordered: false, start: 1, items: items)],+ footnotes: .empty, settings: RenderSettings())+ let runs = (doc.sourceMap.runs.values.first ?? []).sorted { $0.sourceStart < $1.sourceStart }+ // A dropped run yields total 0 ≠ the content length: the runs must tile the+ // whole source string (ordered, non-overlapping, starting at 0) so selection-+ // anchored notes still resolve. Mirrors NumberedTaskListItemTests.+ let total = runs.reduce(0) { $0 + $1.length }+ #expect(total == source.utf16.count, "runs: \(runs)")+ #expect(runs.first?.sourceStart == 0, "runs: \(runs)")+ var previousEnd = 0+ for run in runs {+ #expect(run.sourceStart >= previousEnd, "runs must be ordered and non-overlapping")+ previousEnd = run.sourceStart + run.length+ }+ }+}
diff --git a/specs/bugfixes/list-item-at-prefix/report.md b/specs/bugfixes/list-item-at-prefix/report.mdnew file mode 100644index 0000000..89e8757--- /dev/null+++ b/specs/bugfixes/list-item-at-prefix/report.md@@ -0,0 +1,133 @@+# Bugfix Report: List Item Rendering Fails When Starting With `@`++**Date:** 2026-07-05+**Status:** Fixed+**Ticket:** T-1641++## Description of the Issue++A list item whose text began with an `@`-prefixed word (e.g. `@Observable macro is+used`) rendered as an empty list entry — the entire line of text was dropped. The+same defect affected any inline content beginning with `@word`: paragraphs,+headings, and table cells, not just list items. Introduced by the WebKit rendering+cutover (T-1542), so it is a regression against the pre-cutover in-flow renderer.++**Reproduction steps:**+1. Open a markdown document containing a list item such as `- @Observable macro is used`.+2. View it in the rendered (WebKit) document view.+3. Observe the list bullet renders with no text — the line is empty.++**Impact:** Medium. Silent content loss (no error shown) for any text starting with+`@` — common in Swift-heavy documents (`@Observable`, `@MainActor`, `@State`,+`@testable`) and any `@mention`-style prose. Content was unreadable, though the+underlying document and native search text were unaffected.++## Investigation Summary++- **Symptoms examined:** Empty list entry where `@Observable …` text was expected.+- **Code inspected:** `InlineHTMLRenderer` (the block's inline-source re-parser),+ `BlockHTMLEmitter.renderInline` (its only caller), and `MarkdownBlockParser`+ (the main document parser) for comparison.+- **Hypotheses tested:** HTML escaping of `@` (ruled out — `@` is not escaped);+ source-map run location failure (ruled out — the text never reached a Text node);+ found that `InlineHTMLRenderer.render` re-parses inline source with+ `Document(parsing: source, options: [.parseBlockDirectives])`, the only place in+ the codebase enabling that option.++## Discovered Root Cause++`InlineHTMLRenderer.render` re-parsed each block's inline source string with block+directives enabled. Block directives in swift-markdown use `@Name` syntax, so a run+of text beginning with `@Observable ` re-parsed as a `BlockDirective` node — block+structure, not a `Text` node. The `Walker` (a `MarkupWalker`) has no+`visitBlockDirective`, so the default descend produced no visible text and the whole+run was dropped.++The main document parser (`MarkdownBlockParser`) parses **without**+`.parseBlockDirectives`, so at the top level `@Observable …` is plain paragraph+text. Only the inline re-parse diverged, which is why the block's content survived+parsing (and native search) but vanished on emission.++**Defect type:** Incorrect parser option — inline re-parse configured inconsistently+with the document parse.++**Why it occurred:** The `.parseBlockDirectives` option was added when the file was+created in the T-1542 cutover, with no rationale recorded. Prism is a markdown+viewer, not DocC; block directives are never a desired construct, and the main+parser correctly never enables them.++**Contributing factors:** The inline renderer re-parses source text (the block model+stores markdown strings, not AST nodes, since the T-1542 cutover) rather than walking+an already-parsed AST, which is what exposed the option mismatch. T-1640 later added+handling for lists that emerge from that same re-parse.++## Resolution for the Issue++**Changes made:**+- `prism/Services/WebRendering/InlineHTMLRenderer.swift:60` — removed+ `options: [.parseBlockDirectives]` from the inline re-parse so it matches the main+ document parser; `@word`-prefixed text now parses as plain text on both passes.++**Approach rationale:** The minimal, correct fix aligns the inline re-parse with the+document parse. It removes the only divergence and needs no new `visitBlockDirective`+handling (block directives are not a supported construct anywhere in Prism).++**Alternatives considered:**+- **Add a `visitBlockDirective` handler that re-emits the directive as literal text** —+ Rejected: more code to reconstruct the exact source (`@`, args, braces) as text,+ and it would keep the inline parser inconsistent with the document parser for no+ benefit.++## Regression Test++**Test file:** `prismTests/WebRendering/AtPrefixInlineTests.swift`+**Test names:** `listItemStartingWithAtRendersText`, `listItemContentKeepsAtPrefix`,+`bareAtWordRenders`, `paragraphKeepsAtPrefix`, `headingKeepsAtPrefix`,+`tableCellKeepsAtPrefix`, `atPrefixRunsMapIdentity`++**What it verifies:** Inline content beginning with `@word` renders that text as+literal visible content across every `renderInline` call site (list item, paragraph,+heading, table cell), and the source-map runs tile the whole `@`-prefixed string+(identity mapping preserved).++**Run command:**+```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' -derivedDataPath ./DerivedData -testPlan prism \+ -only-test-configuration "en (base)" -parallel-testing-worker-count 1 \+ -only-testing:prismTests/AtPrefixInlineTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/WebRendering/InlineHTMLRenderer.swift` | Drop `.parseBlockDirectives` from the inline re-parse; add explanatory comment |+| `prismTests/WebRendering/AtPrefixInlineTests.swift` | New regression tests for `@`-prefixed inline content |++## Verification++**Automated:**+- [x] Regression test passes (7/7 `AtPrefixInlineTests`)+- [x] Related suites pass (emitter, source-map, parity, samples, block identity,+ numbered-task-list T-1640, parser, footnote — 0 failures)+- [x] Linters pass (`make lint`: 0 violations)++**Manual verification:**+- Confirmed the failing→passing transition by running the new tests before and after+ the one-line change (red → green).++## Prevention++**Recommendations to avoid similar bugs:**+- Keep parser options consistent between the document parse and any inline re-parse;+ the inline renderer must not diverge from `MarkdownBlockParser`'s configuration.+- When a walker relies on `MarkupWalker`'s default descend, be explicit about which+ node types can appear — a silently-unhandled block node drops content rather than+ erroring.++## Related++- Ticket: T-1641+- Regression source: T-1542 (WebKit document rendering cutover)+- Same `renderInline` defect class: T-1640 (numbered tasks lose their numbers)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9bd5407..4d9866a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Task list items written `- [ ] 1. Task name` render their number again (T-1640). Since the WebKit rendering cutover, inline text beginning with a list marker (`1.`, `3)`, `-`) was re-parsed as a list and the marker silently dropped — this also affected headings like `## 1. Introduction` and table cells starting with a number. The literal marker the author wrote is now rendered, and text selection and search work on it.+- Text beginning with an `@`-prefixed word (for example a list item `- @Observable macro is used`) renders again instead of showing as empty (T-1641). Since the WebKit rendering cutover, the inline re-parse enabled block directives, so `@Observable`, `@MainActor`, `@State` and similar leading words were consumed as a directive and the whole run was dropped. This affected paragraphs, headings, and table cells as well as list items; all now render the text verbatim with working selection and search. - Reading position is retained again on the new rendering engine (T-1639): toggling between rendered and raw source returns to the same place in both directions, reopening a document restores the last position (including positions saved before the engine cutover), and closing or switching documents now saves the position immediately instead of only when the app goes to the background. Programmatic jumps (restore, table-of-contents navigation) also update the saved position once the scroll settles. - Copying notes from a document whose notes are all imported or orphaned (no saved notes container) now produces those notes with a proper header, instead of silently copying an empty string. Part of the notes-action-placement groundwork (T-1577 phase 1), which also introduced the shared copy button, the screen-level Share-with-Notes flow, and the single copy-availability rule that the toolbar and pane surfaces adopt in the next phase. - macOS: tapping a diagram or image again after its zoom window is already open now brings that window to the front instead of opening a duplicate window.
@Image(source: x) is anchored by a new test. A brace-bearing @Name { … } spanning multiple lines is not explicitly tested but renders as plain text by the same mechanism (no @-parser exists once the option is off).