krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

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
5orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — 5orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` —
6so that only real rendering differences remain. 6so that only real rendering differences remain.
7 7
8Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is 8Of the 13 corpus cases, **12 match orgo exactly** — every case except `outofscope`, which is
9`scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and 9`scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and
10LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state 10LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state
11is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case 11is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case
@@ -54,6 +54,10 @@ they are presentation policy, not parser capability:
54 description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's 54 description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's
55 paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a 55 paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a
56 no-description link swallow a later link's `][` was fixed in passing. 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- **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported). 61- **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported).
58- **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through 62- **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through
59 verbatim while other export backends are dropped; verse renders as `<p class="verse">` 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
17Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with 17Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
18arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), 18arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes),
19description lists (`term :: definition` → `<dl>`), multi-paragraph list items, 19description lists (`term :: definition` → `<dl>`), multi-paragraph list items,
20list items holding block elements (a source block, table, or quote inside an item),
20tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` / 21tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` /
21`verse` / `export` blocks and `#+begin_<name>` special blocks, 22`verse` / `export` blocks and `#+begin_<name>` special blocks,
22`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` 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 public var checkbox: OrgCheckbox? 93 public var checkbox: OrgCheckbox?
94 /// The term of a description-list item (`term :: definition`). 94 /// The term of a description-list item (`term :: definition`).
95 public var term: [OrgObject]? 95 public var term: [OrgObject]?
96 /// The item's own content: one paragraph normally, several for a multi-paragraph item. 96 /// The item's body, in document order. Usually one paragraph, but an item may hold any
97 public var content: [[OrgObject]] 97 /// block element — a nested list, a source block, a table — so this is a block sequence
98 /// A nested list, when the item has one. 98 /// rather than a list of paragraphs.
99 public var sublist: OrgList? 99 public var content: [OrgElement]
100 100
101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil, 101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102 content: [[OrgObject]], sublist: OrgList? = nil) { 102 content: [OrgElement]) {
103 self.checkbox = checkbox 103 self.checkbox = checkbox
104 self.term = term 104 self.term = term
105 self.content = content 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 if let term = item.term { 143 if let term = item.term {
144 html += "<dt>" + renderInline(term, &notes) + "</dt>\n" 144 html += "<dt>" + renderInline(term, &notes) + "</dt>\n"
145 } 145 }
146 if let first = item.content.first { 146 if !item.content.isEmpty {
147 html += "<dd>" + renderInline(first, &notes) + "</dd>\n" 147 html += "<dd>" + renderItemContent(item, &notes) + "</dd>\n"
148 } 148 }
149 } 149 }
150 return html + "</dl>\n" 150 return html + "</dl>\n"
@@ -161,19 +161,29 @@ public struct OrgHTMLTreeRenderer {
161 case .partial: html += "<code>[-]</code> " 161 case .partial: html += "<code>[-]</code> "
162 } 162 }
163 } 163 }
164 if item.content.count <= 1 { 164 html += renderItemContent(item, &notes)
165 html += renderInline(item.content.first ?? [], &notes)
166 } else {
167 html += item.content.map { "<p>" + renderInline($0, &notes) + "</p>" }.joined(separator: "\n")
168 }
169 if let sublist = item.sublist {
170 html += "\n" + renderList(sublist, &notes)
171 }
172 html += "</li>\n" 165 html += "</li>\n"
173 } 166 }
174 return html + "</\(tag)>\n" 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, &notes)
179 parts.append(wrapParagraphs ? "<p>" + text + "</p>" : text)
180 } else {
181 parts.append(element(child, &notes))
182 }
183 }
184 return parts.joined(separator: "\n")
185 }
186
177 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { 187 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
178 var html = "<table>\n" 188 var html = "<table>\n"
179 var wroteHeader = false 189 var wroteHeader = false
Sources/OrgSwift/AST/OrgParser.swift +12 −39
@@ -320,49 +320,22 @@ public enum OrgParser {
320 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } 320 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) }
321 else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } 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 let rest = Array(lines.dropFirst()) 333 let rest = Array(lines.dropFirst())
324 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } 334 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
325 .map(leadingWidth).min() ?? 0 335 .map(leadingWidth).min() ?? 0
326 let outdented = rest.map { dropLeading($0, childIndent) } 336 let body = ([head] + rest.map { dropLeading($0, childIndent) }).joined(separator: "\n")
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 }
359 337
360 return OrgListItem( 338 return OrgListItem(checkbox: checkbox, term: term, content: parse(body).elements)
361 checkbox: checkbox,
362 term: term,
363 content: content.map(parseInline),
364 sublist: sublistLines.isEmpty ? nil : buildList(sublistLines)
365 )
366 } 339 }
367 340
368 // MARK: - Helpers 341 // MARK: - Helpers
Sources/OrgSwiftUI/OrgLayout.swift +3 −2
@@ -56,8 +56,9 @@ struct OrgListLayout {
56 struct Item: Identifiable { 56 struct Item: Identifiable {
57 var checkbox: OrgCheckbox? 57 var checkbox: OrgCheckbox?
58 var term: AttributedString? 58 var term: AttributedString?
59 var paragraphs: [AttributedString] 59 /// The item's body in source order — usually one paragraph, but an item may hold a
60 var sublist: OrgListLayout? 60 /// nested list, a source block, or a table.
61 var blocks: [OrgBlock]
61 var id = UUID() 62 var id = UUID()
62 } 63 }
63} 64}
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +1 −2
@@ -184,8 +184,7 @@ struct OrgLayoutBuilder {
184 styled.font = .body.bold() 184 styled.font = .body.bold()
185 return styled 185 return styled
186 }, 186 },
187 paragraphs: item.content.map { text($0, &notes) }, 187 blocks: item.content.flatMap { block($0, &notes) }
188 sublist: item.sublist.map { listLayout($0, &notes) }
189 )) 188 ))
190 } 189 }
191 return OrgListLayout(kind: list.kind, items: items) 190 return OrgListLayout(kind: list.kind, items: items)
Sources/OrgSwiftUI/OrgListView.swift +13 −25
@@ -29,20 +29,15 @@ struct OrgListView: View {
29 Text(marker) 29 Text(marker)
30 .monospacedDigit() 30 .monospacedDigit()
31 .foregroundStyle(.secondary) 31 .foregroundStyle(.secondary)
32 VStack(alignment: .leading, spacing: 6) { 32 if let checkbox = item.checkbox {
33 if let checkbox = item.checkbox { 33 HStack(alignment: .firstTextBaseline, spacing: 6) {
34 HStack(alignment: .firstTextBaseline, spacing: 6) { 34 Image(systemName: Self.symbol(for: checkbox))
35 Image(systemName: Self.symbol(for: checkbox)) 35 .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary)
36 .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) 36 .accessibilityLabel(Self.label(for: checkbox))
37 .accessibilityLabel(Self.label(for: checkbox)) 37 body(item)
38 paragraphs(item)
39 }
40 } else {
41 paragraphs(item)
42 }
43 if let sublist = item.sublist {
44 OrgListView(list: sublist)
45 } 38 }
39 } else {
40 body(item)
46 } 41 }
47 } 42 }
48 } 43 }
@@ -53,21 +48,14 @@ struct OrgListView: View {
53 if let term = item.term { 48 if let term = item.term {
54 Text(term).fixedSize(horizontal: false, vertical: true) 49 Text(term).fixedSize(horizontal: false, vertical: true)
55 } 50 }
56 paragraphs(item).padding(.leading, 16) 51 body(item).padding(.leading, 16)
57 if let sublist = item.sublist {
58 OrgListView(list: sublist).padding(.leading, 16)
59 }
60 } 52 }
61 } 53 }
62 54
63 private func paragraphs(_ item: OrgListLayout.Item) -> some View { 55 /// The item's blocks, rendered by the same view the document body uses — a nested list,
64 VStack(alignment: .leading, spacing: 6) { 56 /// a source block, and a table all come out looking as they would anywhere else.
65 ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in 57 private func body(_ item: OrgListLayout.Item) -> some View {
66 Text(paragraph) 58 OrgBlockListView(blocks: item.blocks)
67 .fixedSize(horizontal: false, vertical: true)
68 .frame(maxWidth: .infinity, alignment: .leading)
69 }
70 }
71 } 59 }
72 60
73 private static func symbol(for checkbox: OrgCheckbox) -> String { 61 private static func symbol(for checkbox: OrgCheckbox) -> String {
Tests/OrgSwiftTests/ConformanceTests.swift +1
@@ -56,6 +56,7 @@ private let expectations: [String: Expectation] = [
56 "footnote": .matches, 56 "footnote": .matches,
57 "headings": .matches, 57 "headings": .matches,
58 "images": .matches, 58 "images": .matches,
59 "listblocks": .matches,
59 "lists": .matches, 60 "lists": .matches,
60 "minimal": .matches, 61 "minimal": .matches,
61 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), 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 #expect(html.contains("<li>a plain sibling</li>")) 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 @Test 464 @Test
434 func exportBlockHtmlPassesThroughLatexDropped() { 465 func exportBlockHtmlPassesThroughLatexDropped() {
435 let html = render(""" 466 let html = render("""
Tests/OrgSwiftTests/OrgTreeTests.swift +8 −3
@@ -56,9 +56,14 @@ struct ASTParseTests {
56 Issue.record("expected a list"); return 56 Issue.record("expected a list"); return
57 } 57 }
58 #expect(list.items.count == 2) 58 #expect(list.items.count == 2)
59 let inner = list.items[0].sublist 59 guard case .list(let inner)? = list.items[0].content.last else {
60 #expect(inner != nil) 60 Issue.record("expected a nested list"); return
61 #expect(inner?.items.first?.sublist?.items.count == 1) 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 @Test 69 @Test
Tests/OrgSwiftUITests/OrgLayoutTests.swift +38 −5
@@ -23,6 +23,18 @@ struct OrgLayoutTests {
23 23
24 private func plain(_ string: AttributedString) -> String { String(string.characters) } 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 @Test 38 @Test
27 func blocksCoverTheElementTree() { 39 func blocksCoverTheElementTree() {
28 let blocks = layout(""" 40 let blocks = layout("""
@@ -90,10 +102,31 @@ struct OrgLayoutTests {
90 Issue.record("expected a list"); return 102 Issue.record("expected a list"); return
91 } 103 }
92 #expect(list.kind == .unordered) 104 #expect(list.kind == .unordered)
93 #expect(plain(list.items[0].paragraphs[0]) == "outer") 105 #expect(firstParagraph(list.items[0]) == "outer")
94 let inner = list.items[0].sublist 106 guard let inner = sublist(list.items[0]) else {
95 #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner") 107 Issue.record("expected a nested list"); return
96 #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest") 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 @Test 132 @Test
@@ -108,7 +141,7 @@ struct OrgLayoutTests {
108 } 141 }
109 #expect(described.kind == .description) 142 #expect(described.kind == .description)
110 #expect(plain(described.items[0].term ?? "") == "term") 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 /// Rule rows are structure, not content: they set the header band and then disappear. 147 /// Rule rows are structure, not content: they set the header band and then disappear.