krz/org-swift

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

Commit eb2beb19c9

eb2beb19c98a731c7bd8eead5f0792e909c8a928

parent: fc52040bbf

Unsigned

cmc <hello@cleberg.net> · 2026-08-28 18:31 UTC

OrgSwift: recursive lists, export/special blocks, superscript, keyword drop

Closes the last four corpus gaps (11/12 now match orgo; only the deliberately
out-of-scope case remains):
- lists: recursive nesting to any depth, description lists (term :: def -> <dl>),
  multi-paragraph items; fixes a parseOrgLink bug where a no-description link
  swallowed a later link's ][.
- blocks: example -> bare <pre>; #+BEGIN_EXPORT html passthrough (other backends
  dropped); verse -> <p class=verse> with <br>; #+BEGIN_<name> -> <div class=name>;
  a source block inside a quote stays nested.
- tblfm: ^ superscript (N^2 -> N<sup>2</sup>).
- elements: unhandled #+KEYWORD: lines are consumed; [[id:x]] -> #x fragment link.
GAPS.md/README updated.

Layout: unified · split

GAPS.md +18 −15
@@ -5,21 +5,12 @@ 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, **7 match orgo exactly (`table`, `timestamps`, `footnote`, 8Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is
9`minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing 9`scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and
10capability, recorded in the `expectations` map in 10LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state
11`Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching 11is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case
12and the test fails until it is moved to `.matches` — that is how this list stays honest. 12that regresses, or `outofscope` starting to match, fails the test and forces the map to be
13 13updated — that is how this list stays honest.
14This is the backlog the shared package exists to work through. Roughly in value order:
15
16| Case | Missing capability |
17|---|---|
18| `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. |
19| `lists` | List nesting deeper than one level is flattened. |
20| `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). |
21| `blocks` | Example blocks wrap in `<pre><code>`; orgo uses a bare `<pre>`. |
22| `outofscope` | Deliberately unsupported constructs. `scope: out` in the corpus — orgo may also differ, so treat this as documentation, not a target. |
23 14
24Two behaviors were made configurable during extraction rather than left hardcoded, because 15Two behaviors were made configurable during extraction rather than left hardcoded, because
25they are presentation policy, not parser capability: 16they are presentation policy, not parser capability:
@@ -56,3 +47,15 @@ they are presentation policy, not parser capability:
56 promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present. 47 promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present.
57 `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and 48 `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and
58 a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image. 49 a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image.
50- **`elements`** — any unhandled `#+KEYWORD:` line (`#+FILETAGS`, `#+TBLFM`, …) is consumed
51 as document metadata instead of leaking into the body, and `[[id:target]]` links render
52 as in-page fragment links (`<a href="#target">`).
53- **`lists`** — the list renderer is now recursive: unordered/ordered nesting to any depth,
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
56 no-description link swallow a later link's `][` was fixed in passing.
57- **`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
59 verbatim while other export backends are dropped; verse renders as `<p class="verse">`
60 with `<br>`; a `#+BEGIN_<name>` special block becomes `<div class="<name>">` with org
61 content; and a source block inside a quote stays nested in the `<blockquote>`.
README.md +10 −6
@@ -10,14 +10,18 @@ shared across apps.
10## Supported syntax 10## Supported syntax
11 11
12Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with 12Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
13nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` 13arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes),
14alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, 14description lists (`term :: definition` → `<dl>`), multi-paragraph list items,
15tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` /
16`verse` / `export` blocks and `#+begin_<name>` special blocks,
15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` 17`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME`
16figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` → 18figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` →
17link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive, 19link, `[[id:target]]` → `#target`), horizontal rules, comments, timestamps
18times, and ranges → `<time>`), footnotes (references, inline, and definitions → 20(`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes
19a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`, 21(references, inline, and definitions → a `<section class="footnotes">`), and
20`~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks). 22inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`,
23`_underline_`, `x^2` superscript, bare-URL and email autolinks). Other
24`#+KEYWORD:` lines are consumed as document metadata.
21 25
22Output is sanitized: only `http`/`https`/`mailto` link schemes and 26Output is sanitized: only `http`/`https`/`mailto` link schemes and
23`http`/`https` image schemes are allowed; everything else is dropped. 27`http`/`https` image schemes are allowed; everything else is dropped.
Sources/OrgSwift/Inline.swift +14
@@ -75,6 +75,20 @@ func processOrgInline(
75 } 75 }
76 } 76 }
77 77
78 // Superscript: `x^2` or `x^{group}`. Must attach to a preceding character, so `3^rd`
79 // works but a lone `^` does not.
80 result = protectMatches(
81 in: result,
82 pattern: #"(?<=[A-Za-z0-9])\^(\{[^}]*\}|[A-Za-z0-9]+)"#,
83 protectedFragments: &protectedFragments
84 ) { match, nsText in
85 var inner = nsText.substring(with: match.range(at: 1))
86 if inner.hasPrefix("{") && inner.hasSuffix("}") {
87 inner = String(inner.dropFirst().dropLast())
88 }
89 return "<sup>\(inner)</sup>"
90 }
91
78 // Bare URLs in running text become links. Bracketed `[[…]]` links are already 92 // Bare URLs in running text become links. Bracketed `[[…]]` links are already
79 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is 93 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is
80 // left outside the link. 94 // left outside the link.
Sources/OrgSwift/Links.swift +16 −2
@@ -32,8 +32,13 @@ private func parseOrgLink(
32 guard text[start...].hasPrefix("[[") else { return nil } 32 guard text[start...].hasPrefix("[[") else { return nil }
33 33
34 var index = text.index(start, offsetBy: 2) 34 var index = text.index(start, offsetBy: 2)
35 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { 35 let descSeparator = text[index...].range(of: "][")?.lowerBound
36 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } 36 let plainClose = text[index...].range(of: "]]")?.lowerBound
37 // A `][` only starts a description when it comes before this link's closing `]]`;
38 // otherwise it belongs to a later link and this one has no description.
39 guard let destinationEnd = descSeparator,
40 plainClose == nil || destinationEnd < plainClose! else {
41 guard let end = plainClose else { return nil }
37 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) 42 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
38 } 43 }
39 44
@@ -75,6 +80,15 @@ private func renderOrgLink(
75 imageURLResolver: ((String) -> String?)? = nil, 80 imageURLResolver: ((String) -> String?)? = nil,
76 linkURLResolver: ((String) -> String?)? = nil 81 linkURLResolver: ((String) -> String?)? = nil
77) -> String { 82) -> String {
83 // `id:` links target a heading by its :ID:/:CUSTOM_ID:; org exports them as an
84 // in-page fragment link, with the id itself as the text when there is no description.
85 if rawDestination.hasPrefix("id:") {
86 let id = String(rawDestination.dropFirst(3))
87 let text = label.map {
88 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
89 } ?? escapeHTML(id)
90 return ##"<a href="#\##(escapeHTMLAttribute(id))">\##(text)</a>"##
91 }
78 let destination = normalizeOrgLinkTarget(rawDestination) 92 let destination = normalizeOrgLinkTarget(rawDestination)
79 if let label, label.hasPrefix("[["), label.hasSuffix("]]") { 93 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
80 let source = String(label.dropFirst(2).dropLast(2)) 94 let source = String(label.dropFirst(2).dropLast(2))
Sources/OrgSwift/Lists.swift +123 −85
@@ -10,118 +10,156 @@ func orderedListItem(in line: String) -> String? {
10 return String(match.2) 10 return String(match.2)
11} 11}
12 12
13func renderOrgListItemBody( 13/// Render a whole list block (all its lines, at any nesting depth) to HTML. The block is a
14 _ lines: [String], 14/// run of list lines the caller has collected — top-level item markers plus every deeper or
15 imageURLResolver: ((String) -> String?)? = nil, 15/// continuation line, including the blank lines between an item's paragraphs.
16 linkURLResolver: ((String) -> String?)? = nil 16func renderOrgList(_ lines: [String], inlineRenderer: (String) -> String) -> String {
17) -> String { 17 // Outdent the block so this level's markers sit at column 0.
18 guard let firstLine = lines.first else { return "" } 18 let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
19 19 .map(leadingSpaces).min() ?? 0
20 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] 20 let norm = lines.map { dropLeadingSpaces($0, base) }
21 var nestedLines: [String] = [] 21
22 22 // Group into items: each begins at a column-0 marker; deeper/continuation/blank lines
23 for line in lines.dropFirst() { 23 // belong to the item above them.
24 let trimmed = line.trimmingCharacters(in: .whitespaces) 24 var items: [[String]] = []
25 if trimmed.isEmpty { 25 var current: [String] = []
26 continue 26 for line in norm {
27 } 27 if isListMarkerLine(line) {
28 28 if !current.isEmpty { items.append(current) }
29 if isIndentedListItemLine(line) { 29 current = [line]
30 nestedLines.append(outdentOrgListLine(line)) 30 } else if !current.isEmpty {
31 } else { 31 current.append(line)
32 contentLines.append(trimmed)
33 } 32 }
34 } 33 }
34 if !current.isEmpty { items.append(current) }
35 guard let firstMarker = items.first?.first else { return "" }
36
37 if let dl = renderDescriptionList(items, inlineRenderer: inlineRenderer) { return dl }
35 38
36 var html = renderTaskListItem( 39 let ordered = orderedListItem(in: firstMarker) != nil
37 contentLines.joined(separator: " "), 40 var html = ordered ? "<ol>\n" : "<ul>\n"
38 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } 41 for item in items {
39 ) 42 html += "<li>" + renderListItem(item, inlineRenderer: inlineRenderer) + "</li>\n"
40 if !nestedLines.isEmpty {
41 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
42 } 43 }
44 html += ordered ? "</ol>\n" : "</ul>\n"
43 return html 45 return html
44} 46}
45 47
46private func renderNestedOrgListHTML( 48/// A single item's lines (the marker line plus everything under it) → the `<li>` body:
47 _ lines: [String], 49/// the item's paragraph(s), then any nested list. Text is wrapped in `<p>` only when the
48 imageURLResolver: ((String) -> String?)? = nil, 50/// item has more than one paragraph, matching org's exporter.
49 linkURLResolver: ((String) -> String?)? = nil 51private func renderListItem(_ lines: [String], inlineRenderer: (String) -> String) -> String {
50) -> String { 52 let head = stripListMarker(lines[0])
51 var html = "" 53 let rest = lines.dropFirst()
52 var listType: OrgListType? 54 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
53 var currentItemLines: [String] = [] 55 .map(leadingSpaces).min() ?? 0
54 56 let outdented = rest.map { dropLeadingSpaces($0, childIndent) }
55 func flushNestedItem() { 57
56 guard !currentItemLines.isEmpty else { return } 58 var paragraphs: [String] = []
57 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n" 59 var currentParagraph: [String] = [head]
58 currentItemLines = [] 60 var sublistLines: [String] = []
59 } 61 var inSublist = false
60 62
61 func closeNestedList() { 63 func flushParagraph() {
62 flushNestedItem() 64 let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces)
63 switch listType { 65 if !joined.isEmpty { paragraphs.append(joined) }
64 case .unordered: 66 currentParagraph = []
65 html += "</ul>\n"
66 case .ordered:
67 html += "</ol>\n"
68 case nil:
69 break
70 }
71 listType = nil
72 } 67 }
73 68
74 for line in lines { 69 for line in outdented {
75 let trimmed = line.trimmingCharacters(in: .whitespaces) 70 let trimmed = line.trimmingCharacters(in: .whitespaces)
76 if trimmed.hasPrefix("- ") { 71 if isListMarkerLine(line) || inSublist {
77 if listType != .unordered { 72 // The first marker begins a nested list; everything after belongs to it.
78 closeNestedList() 73 if !inSublist { flushParagraph() }
79 html += "<ul>\n" 74 inSublist = true
80 listType = .unordered 75 sublistLines.append(line)
81 } 76 } else if trimmed.isEmpty {
82 flushNestedItem() 77 flushParagraph()
83 currentItemLines = [String(trimmed.dropFirst(2))] 78 } else {
84 continue 79 currentParagraph.append(trimmed)
85 } 80 }
81 }
82 flushParagraph()
83
84 var body: String
85 if paragraphs.count <= 1 {
86 body = renderTaskListItem(paragraphs.first ?? "", inlineRenderer: inlineRenderer)
87 } else {
88 body = paragraphs.enumerated().map { index, para in
89 let rendered = index == 0 ? renderTaskListItem(para, inlineRenderer: inlineRenderer) : inlineRenderer(para)
90 return "<p>" + rendered + "</p>"
91 }.joined(separator: "\n")
92 }
86 93
87 if let orderedItem = orderedListItem(in: trimmed) { 94 if !sublistLines.isEmpty {
88 if listType != .ordered { 95 body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer)
89 closeNestedList() 96 }
90 html += "<ol>\n" 97 return body
91 listType = .ordered 98}
92 }
93 flushNestedItem()
94 currentItemLines = [orderedItem]
95 continue
96 }
97 99
98 if listType != nil { 100/// If the items are a description list (`term :: definition`), render `<dl>`; otherwise nil.
99 currentItemLines.append(line) 101private func renderDescriptionList(_ items: [[String]], inlineRenderer: (String) -> String) -> String? {
102 guard let first = items.first, stripListMarker(first[0]).contains(" :: ") else { return nil }
103 var html = "<dl>\n"
104 for item in items {
105 let head = stripListMarker(item[0])
106 let continuation = item.dropFirst()
107 .map { $0.trimmingCharacters(in: .whitespaces) }
108 .filter { !$0.isEmpty }
109 let full = ([head] + continuation).joined(separator: " ")
110 if let range = full.range(of: " :: ") {
111 html += "<dt>" + inlineRenderer(String(full[..<range.lowerBound])) + "</dt>\n"
112 html += "<dd>" + inlineRenderer(String(full[range.upperBound...])) + "</dd>\n"
113 } else {
114 html += "<dt>" + inlineRenderer(full) + "</dt>\n"
100 } 115 }
101 } 116 }
102 117 html += "</dl>\n"
103 closeNestedList()
104 return html 118 return html
105} 119}
106 120
121/// True if a line (already outdented to its level) starts a list item at column 0.
122func isListMarkerLine(_ line: String) -> Bool {
123 line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil
124}
125
126private func stripListMarker(_ line: String) -> String {
127 let trimmed = line.trimmingCharacters(in: .whitespaces)
128 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") {
129 return String(trimmed.dropFirst(2))
130 }
131 if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) {
132 return String(match.1)
133 }
134 return trimmed
135}
136
107func isIndentedContinuationLine(_ line: String) -> Bool { 137func isIndentedContinuationLine(_ line: String) -> Bool {
108 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } 138 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
109 guard let first = line.first else { return false } 139 guard let first = line.first else { return false }
110 return first == " " || first == "\t" 140 return first == " " || first == "\t"
111} 141}
112 142
113private func isIndentedListItemLine(_ line: String) -> Bool { 143private func leadingSpaces(_ line: String) -> Int {
114 guard isIndentedContinuationLine(line) else { return false } 144 var count = 0
115 let trimmed = line.trimmingCharacters(in: .whitespaces) 145 for ch in line {
116 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil 146 if ch == " " { count += 1 }
147 else if ch == "\t" { count += 8 }
148 else { break }
149 }
150 return count
117} 151}
118 152
119private func outdentOrgListLine(_ line: String) -> String { 153private func dropLeadingSpaces(_ line: String, _ n: Int) -> String {
120 var result = line 154 var dropped = 0
121 while result.first == " " || result.first == "\t" { 155 var index = line.startIndex
122 result.removeFirst() 156 while index < line.endIndex, dropped < n {
157 if line[index] == " " { dropped += 1 }
158 else if line[index] == "\t" { dropped += 8 }
159 else { break }
160 index = line.index(after: index)
123 } 161 }
124 return result 162 return String(line[index...])
125} 163}
126 164
127func renderTaskListItem( 165func renderTaskListItem(
Sources/OrgSwift/OrgRenderer.swift +109 −49
@@ -167,7 +167,13 @@ func orgToHTML(
167 var inExampleBlock = false 167 var inExampleBlock = false
168 var inCenterBlock = false 168 var inCenterBlock = false
169 var inVerseBlock = false 169 var inVerseBlock = false
170 var currentListItemLines: [String] = [] 170 var inExportBlock = false
171 var exportIsHTML = false
172 var exportLines: [String] = []
173 var inSpecialBlock = false
174 var specialBlockName = ""
175 var listBuffer: [String] = []
176 var pendingListBlanks: [String] = []
171 var paragraph: [String] = [] 177 var paragraph: [String] = []
172 var tableRows: [[String]] = [] 178 var tableRows: [[String]] = []
173 var propertyRows: [(String, String)] = [] 179 var propertyRows: [(String, String)] = []
@@ -210,26 +216,14 @@ func orgToHTML(
210 } 216 }
211 } 217 }
212 218
213 func flushListItem() {
214 guard !currentListItemLines.isEmpty else { return }
215 html += "<li>" + renderOrgListItemBody(
216 currentListItemLines,
217 imageURLResolver: imageURLResolver,
218 linkURLResolver: linkURLResolver
219 ) + "</li>\n"
220 currentListItemLines = []
221 }
222
223 func closeList() { 219 func closeList() {
224 flushListItem() 220 if !listBuffer.isEmpty {
225 switch listType { 221 html += renderOrgList(listBuffer) {
226 case .unordered: 222 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
227 html += "</ul>\n" 223 }
228 case .ordered:
229 html += "</ol>\n"
230 case nil:
231 break
232 } 224 }
225 listBuffer = []
226 pendingListBlanks = []
233 listType = nil 227 listType = nil
234 } 228 }
235 229
@@ -276,7 +270,7 @@ func orgToHTML(
276 270
277 func closeExampleBlock() { 271 func closeExampleBlock() {
278 if inExampleBlock { 272 if inExampleBlock {
279 html += "</code></pre>\n" 273 html += "</pre>\n"
280 inExampleBlock = false 274 inExampleBlock = false
281 closePendingBlockWrapper() 275 closePendingBlockWrapper()
282 } 276 }
@@ -295,10 +289,10 @@ func orgToHTML(
295 if inVerseBlock { 289 if inVerseBlock {
296 let content = verseLines 290 let content = verseLines
297 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } 291 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
298 .joined(separator: "\n") 292 .joined(separator: "<br>\n")
299 html += #"<blockquote class="org-verse">"# + "\n" 293 html += #"<p class="verse">"# + "\n"
300 html += content + "\n" 294 html += content + "\n"
301 html += "</blockquote>\n" 295 html += "</p>\n"
302 verseLines = [] 296 verseLines = []
303 inVerseBlock = false 297 inVerseBlock = false
304 closePendingBlockWrapper() 298 closePendingBlockWrapper()
@@ -356,6 +350,34 @@ func orgToHTML(
356 continue 350 continue
357 } 351 }
358 352
353 if inExportBlock {
354 if trimmed.lowercased() == "#+end_export" {
355 if exportIsHTML {
356 html += exportLines.joined(separator: "\n") + "\n"
357 }
358 inExportBlock = false
359 exportLines = []
360 } else if exportIsHTML {
361 // The `html` backend passes through verbatim; any other backend is dropped.
362 exportLines.append(line)
363 }
364 continue
365 }
366
367 if inSpecialBlock {
368 if trimmed.lowercased() == "#+end_\(specialBlockName)" {
369 flushParagraph()
370 html += "</div>\n"
371 inSpecialBlock = false
372 specialBlockName = ""
373 } else if trimmed.isEmpty {
374 flushParagraph()
375 } else {
376 paragraph.append(line)
377 }
378 continue
379 }
380
359 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { 381 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
360 closeQuoteBlock() 382 closeQuoteBlock()
361 continue 383 continue
@@ -387,10 +409,11 @@ func orgToHTML(
387 case "attr_html": 409 case "attr_html":
388 pendingAttrHtml = directive.value 410 pendingAttrHtml = directive.value
389 continue 411 continue
390 case "options", "property":
391 continue
392 default: 412 default:
393 break 413 // Any other `#+keyword:` line (OPTIONS, PROPERTY, FILETAGS, TBLFM, RESULTS,
414 // …) is document metadata, not body content — org's exporter consumes it.
415 // `#+begin_…`/`#+end_…` have no colon, so they are not matched here.
416 continue
394 } 417 }
395 } 418 }
396 419
@@ -426,7 +449,8 @@ func orgToHTML(
426 } 449 }
427 450
428 if trimmed.lowercased().hasPrefix("#+begin_src") { 451 if trimmed.lowercased().hasPrefix("#+begin_src") {
429 closeQuoteBlock() 452 // No closeQuoteBlock(): a source block can sit inside a quote, and org keeps it
453 // there. flushBlockState() still ends any open paragraph, list, or table.
430 flushBlockState() 454 flushBlockState()
431 beginPendingBlockWrapperIfNeeded() 455 beginPendingBlockWrapperIfNeeded()
432 let language = trimmed 456 let language = trimmed
@@ -446,7 +470,7 @@ func orgToHTML(
446 closeQuoteBlock() 470 closeQuoteBlock()
447 flushBlockState() 471 flushBlockState()
448 beginPendingBlockWrapperIfNeeded() 472 beginPendingBlockWrapperIfNeeded()
449 html += "<pre><code>" 473 html += "<pre>"
450 inExampleBlock = true 474 inExampleBlock = true
451 continue 475 continue
452 } 476 }
@@ -477,6 +501,35 @@ func orgToHTML(
477 continue 501 continue
478 } 502 }
479 503
504 if trimmed.lowercased().hasPrefix("#+begin_export") {
505 closeQuoteBlock()
506 flushBlockState()
507 let backend = trimmed
508 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
509 .dropFirst().first.map(String.init)?
510 .trimmingCharacters(in: .whitespaces).lowercased()
511 inExportBlock = true
512 exportIsHTML = backend == "html"
513 exportLines = []
514 continue
515 }
516
517 // Any other `#+begin_<name>` is a special block: org renders it as a `<div>` carrying
518 // the name as a class, with the contents parsed as ordinary org. Must stay last so
519 // the specific block types above win.
520 if trimmed.lowercased().hasPrefix("#+begin_") {
521 closeQuoteBlock()
522 flushBlockState()
523 let name = String(trimmed.lowercased().dropFirst("#+begin_".count))
524 .split(separator: " ").first.map(String.init) ?? ""
525 if !name.isEmpty {
526 html += #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n"
527 inSpecialBlock = true
528 specialBlockName = name
529 continue
530 }
531 }
532
480 if trimmed == ":PROPERTIES:" { 533 if trimmed == ":PROPERTIES:" {
481 closeQuoteBlock() 534 closeQuoteBlock()
482 flushBlockState() 535 flushBlockState()
@@ -543,38 +596,45 @@ func orgToHTML(
543 continue 596 continue
544 } 597 }
545 598
546 if listType != nil && isIndentedContinuationLine(line) { 599 // Inside a list, hold blank lines instead of ending it: an item can have several
547 currentListItemLines.append(line) 600 // paragraphs, separated by blanks, before the next item or the list's end.
601 if listType != nil, trimmed.isEmpty {
602 pendingListBlanks.append(line)
548 continue 603 continue
549 } 604 }
550 605
551 // List items: - item 606 // A list item marker at column 0 starts or continues a list. A different marker
552 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { 607 // type (ordered vs unordered) at the top level begins a separate list.
553 flushParagraph() 608 if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) {
554 flushPropertyDrawer() 609 let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered
555 if listType != .unordered { 610 if listType == nil {
611 flushParagraph()
612 flushPropertyDrawer()
613 } else if listType != newType {
556 closeList() 614 closeList()
557 html += "<ul>\n" 615 flushParagraph()
558 listType = .unordered 616 flushPropertyDrawer()
559 } 617 }
560 flushListItem() 618 listType = newType
561 currentListItemLines = [String(trimmed.dropFirst(2))] 619 listBuffer.append(contentsOf: pendingListBlanks)
620 pendingListBlanks = []
621 listBuffer.append(line)
562 continue 622 continue
563 } 623 }
564 624
565 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { 625 // A line indented under an open list is a continuation or a nested item.
566 flushParagraph() 626 if listType != nil, isIndentedContinuationLine(line) {
567 flushPropertyDrawer() 627 listBuffer.append(contentsOf: pendingListBlanks)
568 if listType != .ordered { 628 pendingListBlanks = []
569 closeList() 629 listBuffer.append(line)
570 html += "<ol>\n"
571 listType = .ordered
572 }
573 flushListItem()
574 currentListItemLines = [orderedItem]
575 continue 630 continue
576 } 631 }
577 632
633 // Any other line ends an open list, then is processed normally below.
634 if listType != nil {
635 closeList()
636 }
637
578 // Blank line 638 // Blank line
579 if trimmed.isEmpty { 639 if trimmed.isEmpty {
580 if inQuoteBlock { 640 if inQuoteBlock {
Tests/OrgSwiftTests/ConformanceTests.swift +4 −4
@@ -50,16 +50,16 @@ private enum Expectation {
50 50
51private let expectations: [String: Expectation] = [ 51private let expectations: [String: Expectation] = [
52 "table": .matches, 52 "table": .matches,
53 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), 53 "blocks": .matches,
54 "core": .matches, 54 "core": .matches,
55 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), 55 "elements": .matches,
56 "footnote": .matches, 56 "footnote": .matches,
57 "headings": .matches, 57 "headings": .matches,
58 "images": .matches, 58 "images": .matches,
59 "lists": .diverges("list nesting deeper than one level is not represented"), 59 "lists": .matches,
60 "minimal": .matches, 60 "minimal": .matches,
61 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), 61 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"),
62 "tblfm": .diverges("^ superscript is not rendered in table cells"), 62 "tblfm": .matches,
63 "timestamps": .matches, 63 "timestamps": .matches,
64] 64]
65 65
Tests/OrgSwiftTests/OrgRendererTests.swift +106 −4
@@ -34,8 +34,9 @@ struct OrgRendererTests {
34 func orgExampleBlock() { 34 func orgExampleBlock() {
35 let html = render("#+begin_example\nhello world\n#+end_example") 35 let html = render("#+begin_example\nhello world\n#+end_example")
36 36
37 #expect(html.contains("<pre><code>")) 37 // Example blocks are a bare <pre> (org's exporter uses no inner <code>).
38 #expect(html.contains("hello world")) 38 #expect(html.contains("<pre>hello world"))
39 #expect(!html.contains("<pre><code>"))
39 } 40 }
40 41
41 @Test 42 @Test
@@ -82,8 +83,9 @@ struct OrgRendererTests {
82 #+end_verse 83 #+end_verse
83 """) 84 """)
84 85
85 #expect(html.contains(#"<blockquote class="org-verse">"#)) 86 // Verse renders as <p class="verse"> with <br> between lines, matching org.
86 #expect(html.contains("There is a line.")) 87 #expect(html.contains(#"<p class="verse">"#))
88 #expect(html.contains("There is a line.<br>"))
87 #expect(html.contains("And an indented line.")) 89 #expect(html.contains("And an indented line."))
88 #expect(!html.contains("#+begin_verse")) 90 #expect(!html.contains("#+begin_verse"))
89 } 91 }
@@ -359,4 +361,104 @@ struct OrgRendererTests {
359 #expect(html.contains("Figure 1: </span>First")) 361 #expect(html.contains("Figure 1: </span>First"))
360 #expect(html.contains("Figure 2: </span>Second")) 362 #expect(html.contains("Figure 2: </span>Second"))
361 } 363 }
364
365 @Test
366 func threeDeepNestedList() {
367 let html = render("""
368 - outer
369 - inner
370 - deepest
371 - second inner
372 - second outer
373 """)
374 #expect(html.contains("<li>outer\n<ul>\n<li>inner\n<ul>\n<li>deepest</li>"))
375 #expect(html.contains("<li>second inner</li>"))
376 #expect(html.contains("<li>second outer</li>"))
377 }
378
379 @Test
380 func nestedOrderedList() {
381 let html = render("""
382 1. first
383 2. second
384 1. point one
385 2. point two
386 3. third
387 """)
388 #expect(html.contains("<li>second\n<ol>\n<li>point one</li>"))
389 #expect(html.contains("<li>third</li>"))
390 }
391
392 @Test
393 func descriptionList() {
394 let html = render("""
395 - term one :: first definition
396 - /marked/ term :: holds inline markup
397 """)
398 #expect(html.contains("<dl>"))
399 #expect(html.contains("<dt>term one</dt>"))
400 #expect(html.contains("<dd>first definition</dd>"))
401 #expect(html.contains("<dt><em>marked</em> term</dt>"))
402 }
403
404 @Test
405 func multiParagraphListItem() {
406 let html = render("""
407 - an item with two paragraphs
408
409 the second paragraph
410
411 - a plain sibling
412 """)
413 #expect(html.contains("<li><p>an item with two paragraphs</p>\n<p>the second paragraph</p></li>"))
414 #expect(html.contains("<li>a plain sibling</li>"))
415 }
416
417 @Test
418 func exportBlockHtmlPassesThroughLatexDropped() {
419 let html = render("""
420 #+BEGIN_EXPORT html
421 <aside>raw</aside>
422 #+END_EXPORT
423
424 #+BEGIN_EXPORT latex
425 \\emph{dropped}
426 #+END_EXPORT
427 """)
428 #expect(html.contains("<aside>raw</aside>"))
429 #expect(!html.contains("dropped"))
430 #expect(!html.contains("BEGIN_EXPORT"))
431 }
432
433 @Test
434 func specialBlockRendersAsNamedDiv() {
435 let html = render("""
436 #+BEGIN_NOTE
437 Contents are *org*, not literal.
438 #+END_NOTE
439 """)
440 #expect(html.contains(#"<div class="note">"#))
441 #expect(html.contains("<p>Contents are <strong>org</strong>, not literal.</p>"))
442 }
443
444 @Test
445 func superscriptRenders() {
446 #expect(render("N^2 here.").contains("N<sup>2</sup>"))
447 #expect(render("H^{2O} molecule.").contains("H<sup>2O</sup>"))
448 }
449
450 @Test
451 func idLinkBecomesFragment() {
452 let html = render("See [[id:abc-123]] and [[id:x][the x]].")
453 #expect(html.contains(##"<a href="#abc-123">abc-123</a>"##))
454 #expect(html.contains(##"<a href="#x">the x</a>"##))
455 }
456
457 @Test
458 func unknownKeywordLinesAreDropped() {
459 let html = render("#+FILETAGS: :sample:\n\nBody.")
460 #expect(!html.contains("FILETAGS"))
461 #expect(!html.contains("sample"))
462 #expect(html.contains("<p>Body.</p>"))
463 }
362} 464}