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
55orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` —
66so that only real rendering differences remain.
77
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
99`scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and
1010LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state
1111is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case
@@ -54,6 +54,10 @@ they are presentation policy, not parser capability:
5454 description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's
5555 paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a
5656 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.
5761- **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported).
5862- **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through
5963 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
1717Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
1818arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes),
1919description lists (`term :: definition` → `<dl>`), multi-paragraph list items,
20list items holding block elements (a source block, table, or quote inside an item),
2021tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` /
2122`verse` / `export` blocks and `#+begin_<name>` special blocks,
2223`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME`
Sources/OrgSwift/AST/OrgDocument.swift +11 −6
@@ -93,17 +93,22 @@ public struct OrgListItem: Sendable, Equatable {
9393 public var checkbox: OrgCheckbox?
9494 /// The term of a description-list item (`term :: definition`).
9595 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]
100100
101101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102 content: [[OrgObject]], sublist: OrgList? = nil) {
102 content: [OrgElement]) {
103103 self.checkbox = checkbox
104104 self.term = term
105105 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 } }
107112 }
108113}
109114
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +20 −10
@@ -143,8 +143,8 @@ public struct OrgHTMLTreeRenderer {
143143 if let term = item.term {
144144 html += "<dt>" + renderInline(term, &notes) + "</dt>\n"
145145 }
146 if let first = item.content.first {
147 html += "<dd>" + renderInline(first, &notes) + "</dd>\n"
146 if !item.content.isEmpty {
147 html += "<dd>" + renderItemContent(item, &notes) + "</dd>\n"
148148 }
149149 }
150150 return html + "</dl>\n"
@@ -161,19 +161,29 @@ public struct OrgHTMLTreeRenderer {
161161 case .partial: html += "<code>[-]</code> "
162162 }
163163 }
164 if item.content.count <= 1 {
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 }
164 html += renderItemContent(item, &notes)
172165 html += "</li>\n"
173166 }
174167 return html + "</\(tag)>\n"
175168 }
176169
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
177187 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
178188 var html = "<table>\n"
179189 var wroteHeader = false
Sources/OrgSwift/AST/OrgParser.swift +12 −39
@@ -320,49 +320,22 @@ public enum OrgParser {
320320 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) }
321321 else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) }
322322
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.
323333 let rest = Array(lines.dropFirst())
324334 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
325335 .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")
359337
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)
366339 }
367340
368341 // MARK: - Helpers
Sources/OrgSwiftUI/OrgLayout.swift +3 −2
@@ -56,8 +56,9 @@ struct OrgListLayout {
5656 struct Item: Identifiable {
5757 var checkbox: OrgCheckbox?
5858 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]
6162 var id = UUID()
6263 }
6364}
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +1 −2
@@ -184,8 +184,7 @@ struct OrgLayoutBuilder {
184184 styled.font = .body.bold()
185185 return styled
186186 },
187 paragraphs: item.content.map { text($0, &notes) },
188 sublist: item.sublist.map { listLayout($0, &notes) }
187 blocks: item.content.flatMap { block($0, &notes) }
189188 ))
190189 }
191190 return OrgListLayout(kind: list.kind, items: items)
Sources/OrgSwiftUI/OrgListView.swift +13 −25
@@ -29,20 +29,15 @@ struct OrgListView: View {
2929 Text(marker)
3030 .monospacedDigit()
3131 .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)
4538 }
39 } else {
40 body(item)
4641 }
4742 }
4843 }
@@ -53,21 +48,14 @@ struct OrgListView: View {
5348 if let term = item.term {
5449 Text(term).fixedSize(horizontal: false, vertical: true)
5550 }
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)
6052 }
6153 }
6254
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)
7159 }
7260
7361 private static func symbol(for checkbox: OrgCheckbox) -> String {
Tests/OrgSwiftTests/ConformanceTests.swift +1
@@ -56,6 +56,7 @@ private let expectations: [String: Expectation] = [
5656 "footnote": .matches,
5757 "headings": .matches,
5858 "images": .matches,
59 "listblocks": .matches,
5960 "lists": .matches,
6061 "minimal": .matches,
6162 "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 {
430430 #expect(html.contains("<li>a plain sibling</li>"))
431431 }
432432
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
433464 @Test
434465 func exportBlockHtmlPassesThroughLatexDropped() {
435466 let html = render("""
Tests/OrgSwiftTests/OrgTreeTests.swift +8 −3
@@ -56,9 +56,14 @@ struct ASTParseTests {
5656 Issue.record("expected a list"); return
5757 }
5858 #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)
6267 }
6368
6469 @Test
Tests/OrgSwiftUITests/OrgLayoutTests.swift +38 −5
@@ -23,6 +23,18 @@ struct OrgLayoutTests {
2323
2424 private func plain(_ string: AttributedString) -> String { String(string.characters) }
2525
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
2638 @Test
2739 func blocksCoverTheElementTree() {
2840 let blocks = layout("""
@@ -90,10 +102,31 @@ struct OrgLayoutTests {
90102 Issue.record("expected a list"); return
91103 }
92104 #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")
97130 }
98131
99132 @Test
@@ -108,7 +141,7 @@ struct OrgLayoutTests {
108141 }
109142 #expect(described.kind == .description)
110143 #expect(plain(described.items[0].term ?? "") == "term")
111 #expect(plain(described.items[0].paragraphs[0]) == "definition")
144 #expect(firstParagraph(described.items[0]) == "definition")
112145 }
113146
114147 /// Rule rows are structure, not content: they set the header band and then disappear.