Commit 6c1f09c138
6c1f09c138d40fb92ad968ee4a0ef3d74b56c7da
parent: a5dc89631c
Unsigned
cmc <hello@cleberg.net> · 2026-08-31 23:49 UTC
A list item holds block elements, not just paragraphs (!7)
`#+begin_src` inside a list item rendered as literal prose — visible in the iOS
README view on any repo whose Contributing section puts commands under numbered
steps.
`OrgListItem.content` was `[[OrgObject]]`, paragraphs only, so the parser folded
every non-marker line into the running paragraph. An item's body is now parsed as
a document of its own and held as `[OrgElement]`; the `sublist` field is gone, a
nested list being just a `.list` in the content.
Paragraphs wrap in `<p>` only when the item has more than one, matching orgo;
blocks follow bare. The SwiftUI path renders item content through
`OrgBlockListView`, the same view the document body uses.
Breaking for anyone constructing an `OrgListItem` (only the parser and tests do).
Depends on krz/org-conformance!1 for the `listblocks` case. 89 tests green.
Layout: unified · split
GAPS.md
+5 −1
| @@ -5,7 +5,7 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run |
| 5 | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | 6 | so that only real rendering differences remain. |
| 7 | 7 | |
| 8 | | Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is |
| 8 | Of the 13 corpus cases, **12 match orgo exactly** — every case except `outofscope`, which is |
| 9 | 9 | `scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and |
| 10 | 10 | LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state |
| 11 | 11 | is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case |
| @@ -54,6 +54,10 @@ they are presentation policy, not parser capability: |
| 54 | 54 | description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's |
| 55 | 55 | paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a |
| 56 | 56 | no-description link swallow a later link's `][` was fixed in passing. |
| 57 | - **`listblocks`** — a list item's body is a block sequence, not a run of paragraphs, so an |
| 58 | item holds a source block, an example, a quote, or a table alongside its prose and its |
| 59 | sublist, in source order. Paragraphs wrap in `<p>` only when the item has more than one, |
| 60 | matching orgo; blocks follow bare. |
| 57 | 61 | - **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported). |
| 58 | 62 | - **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through |
| 59 | 63 | verbatim while other export backends are dropped; verse renders as `<p class="verse">` |
README.md
+1
| @@ -17,6 +17,7 @@ parsed once into a document model, and each output format walks it. See |
| 17 | 17 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 18 | 18 | arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), |
| 19 | 19 | description lists (`term :: definition` → `<dl>`), multi-paragraph list items, |
| 20 | list items holding block elements (a source block, table, or quote inside an item), |
| 20 | 21 | tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` / |
| 21 | 22 | `verse` / `export` blocks and `#+begin_<name>` special blocks, |
| 22 | 23 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` |
Sources/OrgSwift/AST/OrgDocument.swift
+11 −6
| @@ -93,17 +93,22 @@ public struct OrgListItem: Sendable, Equatable { |
| 93 | 93 | public var checkbox: OrgCheckbox? |
| 94 | 94 | /// The term of a description-list item (`term :: definition`). |
| 95 | 95 | public var term: [OrgObject]? |
| 96 | | /// The item's own content: one paragraph normally, several for a multi-paragraph item. |
| 97 | | public var content: [[OrgObject]] |
| 98 | | /// A nested list, when the item has one. |
| 99 | | public var sublist: OrgList? |
| 96 | /// The item's body, in document order. Usually one paragraph, but an item may hold any |
| 97 | /// block element — a nested list, a source block, a table — so this is a block sequence |
| 98 | /// rather than a list of paragraphs. |
| 99 | public var content: [OrgElement] |
| 100 | 100 | |
| 101 | 101 | public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil, |
| 102 | | content: [[OrgObject]], sublist: OrgList? = nil) { |
| 102 | content: [OrgElement]) { |
| 103 | 103 | self.checkbox = checkbox |
| 104 | 104 | self.term = term |
| 105 | 105 | self.content = content |
| 106 | | self.sublist = sublist |
| 106 | } |
| 107 | |
| 108 | /// The item's paragraphs, ignoring any other block it carries. Used where only prose is |
| 109 | /// meaningful — the `<dd>` of a description list, an item's plain-text reduction. |
| 110 | public var paragraphs: [[OrgObject]] { |
| 111 | content.compactMap { if case .paragraph(let objects) = $0 { objects } else { nil } } |
| 107 | 112 | } |
| 108 | 113 | } |
| 109 | 114 | |
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift
+20 −10
| @@ -143,8 +143,8 @@ public struct OrgHTMLTreeRenderer { |
| 143 | 143 | if let term = item.term { |
| 144 | 144 | html += "<dt>" + renderInline(term, ¬es) + "</dt>\n" |
| 145 | 145 | } |
| 146 | | if let first = item.content.first { |
| 147 | | html += "<dd>" + renderInline(first, ¬es) + "</dd>\n" |
| 146 | if !item.content.isEmpty { |
| 147 | html += "<dd>" + renderItemContent(item, ¬es) + "</dd>\n" |
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | 150 | return html + "</dl>\n" |
| @@ -161,19 +161,29 @@ public struct OrgHTMLTreeRenderer { |
| 161 | 161 | case .partial: html += "<code>[-]</code> " |
| 162 | 162 | } |
| 163 | 163 | } |
| 164 | | if item.content.count <= 1 { |
| 165 | | html += renderInline(item.content.first ?? [], ¬es) |
| 166 | | } else { |
| 167 | | html += item.content.map { "<p>" + renderInline($0, ¬es) + "</p>" }.joined(separator: "\n") |
| 168 | | } |
| 169 | | if let sublist = item.sublist { |
| 170 | | html += "\n" + renderList(sublist, ¬es) |
| 171 | | } |
| 164 | html += renderItemContent(item, ¬es) |
| 172 | 165 | html += "</li>\n" |
| 173 | 166 | } |
| 174 | 167 | return html + "</\(tag)>\n" |
| 175 | 168 | } |
| 176 | 169 | |
| 170 | /// A list item's body. A lone paragraph renders bare inside the `<li>`; two or more get |
| 171 | /// their own `<p>`, matching org's exporter. Anything that is not a paragraph — a nested |
| 172 | /// list, a source block, a table — renders as the block it is, in source order. |
| 173 | private func renderItemContent(_ item: OrgListItem, _ notes: inout OrgFootnotes) -> String { |
| 174 | let wrapParagraphs = item.paragraphs.count > 1 |
| 175 | var parts: [String] = [] |
| 176 | for child in item.content { |
| 177 | if case .paragraph(let objects) = child { |
| 178 | let text = renderInline(objects, ¬es) |
| 179 | parts.append(wrapParagraphs ? "<p>" + text + "</p>" : text) |
| 180 | } else { |
| 181 | parts.append(element(child, ¬es)) |
| 182 | } |
| 183 | } |
| 184 | return parts.joined(separator: "\n") |
| 185 | } |
| 186 | |
| 177 | 187 | private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { |
| 178 | 188 | var html = "<table>\n" |
| 179 | 189 | var wroteHeader = false |
Sources/OrgSwift/AST/OrgParser.swift
+12 −39
| @@ -320,49 +320,22 @@ public enum OrgParser { |
| 320 | 320 | else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } |
| 321 | 321 | else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } |
| 322 | 322 | |
| 323 | // `term :: definition` splits on the item's first line only, as org requires. |
| 324 | var term: [OrgObject]? |
| 325 | if kind == .description, let range = head.range(of: " :: ") { |
| 326 | term = parseInline(String(head[..<range.lowerBound])) |
| 327 | head = String(head[range.upperBound...]) |
| 328 | } |
| 329 | |
| 330 | // The item's body is outdented to column 0 and parsed as a document of its own, so an |
| 331 | // item holds whatever a document holds — several paragraphs, a nested list, a source |
| 332 | // block, a table — in source order. |
| 323 | 333 | let rest = Array(lines.dropFirst()) |
| 324 | 334 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } |
| 325 | 335 | .map(leadingWidth).min() ?? 0 |
| 326 | | let outdented = rest.map { dropLeading($0, childIndent) } |
| 327 | | |
| 328 | | var paragraphs: [String] = [] |
| 329 | | var currentParagraph = [head] |
| 330 | | var sublistLines: [String] = [] |
| 331 | | var inSublist = false |
| 332 | | |
| 333 | | func flush() { |
| 334 | | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) |
| 335 | | if !joined.isEmpty { paragraphs.append(joined) } |
| 336 | | currentParagraph = [] |
| 337 | | } |
| 338 | | |
| 339 | | for line in outdented { |
| 340 | | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| 341 | | if isListMarkerLine(line) || inSublist { |
| 342 | | if !inSublist { flush() } |
| 343 | | inSublist = true |
| 344 | | sublistLines.append(line) |
| 345 | | } else if trimmed.isEmpty { |
| 346 | | flush() |
| 347 | | } else { |
| 348 | | currentParagraph.append(trimmed) |
| 349 | | } |
| 350 | | } |
| 351 | | flush() |
| 352 | | |
| 353 | | var term: [OrgObject]? |
| 354 | | var content = paragraphs |
| 355 | | if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") { |
| 356 | | term = parseInline(String(first[..<range.lowerBound])) |
| 357 | | content[0] = String(first[range.upperBound...]) |
| 358 | | } |
| 336 | let body = ([head] + rest.map { dropLeading($0, childIndent) }).joined(separator: "\n") |
| 359 | 337 | |
| 360 | | return OrgListItem( |
| 361 | | checkbox: checkbox, |
| 362 | | term: term, |
| 363 | | content: content.map(parseInline), |
| 364 | | sublist: sublistLines.isEmpty ? nil : buildList(sublistLines) |
| 365 | | ) |
| 338 | return OrgListItem(checkbox: checkbox, term: term, content: parse(body).elements) |
| 366 | 339 | } |
| 367 | 340 | |
| 368 | 341 | // MARK: - Helpers |
Sources/OrgSwiftUI/OrgLayout.swift
+3 −2
| @@ -56,8 +56,9 @@ struct OrgListLayout { |
| 56 | 56 | struct Item: Identifiable { |
| 57 | 57 | var checkbox: OrgCheckbox? |
| 58 | 58 | var term: AttributedString? |
| 59 | | var paragraphs: [AttributedString] |
| 60 | | var sublist: OrgListLayout? |
| 59 | /// The item's body in source order — usually one paragraph, but an item may hold a |
| 60 | /// nested list, a source block, or a table. |
| 61 | var blocks: [OrgBlock] |
| 61 | 62 | var id = UUID() |
| 62 | 63 | } |
| 63 | 64 | } |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift
+1 −2
| @@ -184,8 +184,7 @@ struct OrgLayoutBuilder { |
| 184 | 184 | styled.font = .body.bold() |
| 185 | 185 | return styled |
| 186 | 186 | }, |
| 187 | | paragraphs: item.content.map { text($0, ¬es) }, |
| 188 | | sublist: item.sublist.map { listLayout($0, ¬es) } |
| 187 | blocks: item.content.flatMap { block($0, ¬es) } |
| 189 | 188 | )) |
| 190 | 189 | } |
| 191 | 190 | return OrgListLayout(kind: list.kind, items: items) |
Sources/OrgSwiftUI/OrgListView.swift
+13 −25
| @@ -29,20 +29,15 @@ struct OrgListView: View { |
| 29 | 29 | Text(marker) |
| 30 | 30 | .monospacedDigit() |
| 31 | 31 | .foregroundStyle(.secondary) |
| 32 | | VStack(alignment: .leading, spacing: 6) { |
| 33 | | if let checkbox = item.checkbox { |
| 34 | | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 35 | | Image(systemName: Self.symbol(for: checkbox)) |
| 36 | | .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) |
| 37 | | .accessibilityLabel(Self.label(for: checkbox)) |
| 38 | | paragraphs(item) |
| 39 | | } |
| 40 | | } else { |
| 41 | | paragraphs(item) |
| 42 | | } |
| 43 | | if let sublist = item.sublist { |
| 44 | | OrgListView(list: sublist) |
| 32 | if let checkbox = item.checkbox { |
| 33 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 34 | Image(systemName: Self.symbol(for: checkbox)) |
| 35 | .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) |
| 36 | .accessibilityLabel(Self.label(for: checkbox)) |
| 37 | body(item) |
| 45 | 38 | } |
| 39 | } else { |
| 40 | body(item) |
| 46 | 41 | } |
| 47 | 42 | } |
| 48 | 43 | } |
| @@ -53,21 +48,14 @@ struct OrgListView: View { |
| 53 | 48 | if let term = item.term { |
| 54 | 49 | Text(term).fixedSize(horizontal: false, vertical: true) |
| 55 | 50 | } |
| 56 | | paragraphs(item).padding(.leading, 16) |
| 57 | | if let sublist = item.sublist { |
| 58 | | OrgListView(list: sublist).padding(.leading, 16) |
| 59 | | } |
| 51 | body(item).padding(.leading, 16) |
| 60 | 52 | } |
| 61 | 53 | } |
| 62 | 54 | |
| 63 | | private func paragraphs(_ item: OrgListLayout.Item) -> some View { |
| 64 | | VStack(alignment: .leading, spacing: 6) { |
| 65 | | ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in |
| 66 | | Text(paragraph) |
| 67 | | .fixedSize(horizontal: false, vertical: true) |
| 68 | | .frame(maxWidth: .infinity, alignment: .leading) |
| 69 | | } |
| 70 | | } |
| 55 | /// The item's blocks, rendered by the same view the document body uses — a nested list, |
| 56 | /// a source block, and a table all come out looking as they would anywhere else. |
| 57 | private func body(_ item: OrgListLayout.Item) -> some View { |
| 58 | OrgBlockListView(blocks: item.blocks) |
| 71 | 59 | } |
| 72 | 60 | |
| 73 | 61 | private static func symbol(for checkbox: OrgCheckbox) -> String { |
Tests/OrgSwiftTests/ConformanceTests.swift
+1
| @@ -56,6 +56,7 @@ private let expectations: [String: Expectation] = [ |
| 56 | 56 | "footnote": .matches, |
| 57 | 57 | "headings": .matches, |
| 58 | 58 | "images": .matches, |
| 59 | "listblocks": .matches, |
| 59 | 60 | "lists": .matches, |
| 60 | 61 | "minimal": .matches, |
| 61 | 62 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
Tests/OrgSwiftTests/OrgRendererTests.swift
+31
| @@ -430,6 +430,37 @@ struct OrgRendererTests { |
| 430 | 430 | #expect(html.contains("<li>a plain sibling</li>")) |
| 431 | 431 | } |
| 432 | 432 | |
| 433 | @Test |
| 434 | func listItemHoldsBlockElements() { |
| 435 | let html = render(""" |
| 436 | 1. Fork the repository |
| 437 | 2. Create a new branch: |
| 438 | #+begin_src bash |
| 439 | git checkout -b my-feature |
| 440 | #+end_src |
| 441 | 3. Open a pull request |
| 442 | """) |
| 443 | #expect(html.contains("<li>Fork the repository</li>")) |
| 444 | #expect(html.contains("<li>Create a new branch:\n<pre>")) |
| 445 | #expect(html.contains("git checkout -b my-feature")) |
| 446 | #expect(!html.contains("begin_src")) |
| 447 | } |
| 448 | |
| 449 | /// A sublist and a block in the same item keep their source order. |
| 450 | @Test |
| 451 | func listItemKeepsSublistAndBlockInOrder() throws { |
| 452 | let html = render(""" |
| 453 | - outer |
| 454 | - inner |
| 455 | #+begin_example |
| 456 | example text |
| 457 | #+end_example |
| 458 | """) |
| 459 | let sublist = try #require(html.range(of: "<li>inner</li>")) |
| 460 | let block = try #require(html.range(of: "example text")) |
| 461 | #expect(sublist.lowerBound < block.lowerBound) |
| 462 | } |
| 463 | |
| 433 | 464 | @Test |
| 434 | 465 | func exportBlockHtmlPassesThroughLatexDropped() { |
| 435 | 466 | let html = render(""" |
Tests/OrgSwiftTests/OrgTreeTests.swift
+8 −3
| @@ -56,9 +56,14 @@ struct ASTParseTests { |
| 56 | 56 | Issue.record("expected a list"); return |
| 57 | 57 | } |
| 58 | 58 | #expect(list.items.count == 2) |
| 59 | | let inner = list.items[0].sublist |
| 60 | | #expect(inner != nil) |
| 61 | | #expect(inner?.items.first?.sublist?.items.count == 1) |
| 59 | guard case .list(let inner)? = list.items[0].content.last else { |
| 60 | Issue.record("expected a nested list"); return |
| 61 | } |
| 62 | #expect(inner.items.count == 1) |
| 63 | guard case .list(let deepest)? = inner.items[0].content.last else { |
| 64 | Issue.record("expected a twice-nested list"); return |
| 65 | } |
| 66 | #expect(deepest.items.count == 1) |
| 62 | 67 | } |
| 63 | 68 | |
| 64 | 69 | @Test |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+38 −5
| @@ -23,6 +23,18 @@ struct OrgLayoutTests { |
| 23 | 23 | |
| 24 | 24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
| 25 | 25 | |
| 26 | /// An item's first paragraph, as plain text. |
| 27 | private func firstParagraph(_ item: OrgListLayout.Item) -> String { |
| 28 | for case .paragraph(let text) in item.blocks { return plain(text) } |
| 29 | return "" |
| 30 | } |
| 31 | |
| 32 | /// The nested list an item carries, if any. |
| 33 | private func sublist(_ item: OrgListLayout.Item) -> OrgListLayout? { |
| 34 | for case .list(let list) in item.blocks { return list } |
| 35 | return nil |
| 36 | } |
| 37 | |
| 26 | 38 | @Test |
| 27 | 39 | func blocksCoverTheElementTree() { |
| 28 | 40 | let blocks = layout(""" |
| @@ -90,10 +102,31 @@ struct OrgLayoutTests { |
| 90 | 102 | Issue.record("expected a list"); return |
| 91 | 103 | } |
| 92 | 104 | #expect(list.kind == .unordered) |
| 93 | | #expect(plain(list.items[0].paragraphs[0]) == "outer") |
| 94 | | let inner = list.items[0].sublist |
| 95 | | #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner") |
| 96 | | #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest") |
| 105 | #expect(firstParagraph(list.items[0]) == "outer") |
| 106 | guard let inner = sublist(list.items[0]) else { |
| 107 | Issue.record("expected a nested list"); return |
| 108 | } |
| 109 | #expect(firstParagraph(inner.items[0]) == "inner") |
| 110 | #expect(sublist(inner.items[0]).map { firstParagraph($0.items[0]) } == "deepest") |
| 111 | } |
| 112 | |
| 113 | /// The native path renders an item's blocks as blocks; before, a `#+begin_src` inside a |
| 114 | /// list item came out as literal prose. |
| 115 | @Test |
| 116 | func listItemBlocksBecomeBlocks() { |
| 117 | guard case .list(let list) = layout(""" |
| 118 | 1. Create a new branch: |
| 119 | #+begin_src bash |
| 120 | git checkout -b my-feature |
| 121 | #+end_src |
| 122 | """).blocks.first else { |
| 123 | Issue.record("expected a list"); return |
| 124 | } |
| 125 | #expect(firstParagraph(list.items[0]) == "Create a new branch:") |
| 126 | guard case .code(let code)? = list.items[0].blocks.last else { |
| 127 | Issue.record("expected a code block in the item"); return |
| 128 | } |
| 129 | #expect(plain(code) == "git checkout -b my-feature") |
| 97 | 130 | } |
| 98 | 131 | |
| 99 | 132 | @Test |
| @@ -108,7 +141,7 @@ struct OrgLayoutTests { |
| 108 | 141 | } |
| 109 | 142 | #expect(described.kind == .description) |
| 110 | 143 | #expect(plain(described.items[0].term ?? "") == "term") |
| 111 | | #expect(plain(described.items[0].paragraphs[0]) == "definition") |
| 144 | #expect(firstParagraph(described.items[0]) == "definition") |
| 112 | 145 | } |
| 113 | 146 | |
| 114 | 147 | /// Rule rows are structure, not content: they set the header band and then disappear. |