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

html library org-mode swift

Commit e0ca3558d1

e0ca3558d104ba4ac2be8a05a1d430a455e4b241

parent: 7de7851fd9

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T18:31:13Z

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.
GAPS.md +18 −15
@@ -5,21 +5,12 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run
55 orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)`
66 so that only real rendering differences remain.
77
8Of the 12 corpus cases, **7 match orgo exactly (`table`, `timestamps`, `footnote`,
9`minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing
10capability, recorded in the `expectations` map in
11`Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching
12and the test fails until it is moved to `.matches` — that is how this list stays honest.
13
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. |
8Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is
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
11is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case
12that regresses, or `outofscope` starting to match, fails the test and forces the map to be
13updated — that is how this list stays honest.
2314
2415 Two behaviors were made configurable during extraction rather than left hardcoded, because
2516 they are presentation policy, not parser capability:
@@ -56,3 +47,15 @@ they are presentation policy, not parser capability:
5647 promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present.
5748 `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and
5849 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.
1010 ## Supported syntax
1111
1212 Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
13nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:`
14alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks,
13arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes),
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,
1517 `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME`
1618 figures, org links and images (`[[file:x.png]]``<img>`, `[[dest][label]]`
17link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive,
18times, and ranges → `<time>`), footnotes (references, inline, and definitions →
19a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`,
20`~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks).
19link, `[[id:target]]``#target`), horizontal rules, comments, timestamps
20(`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes
21(references, inline, and definitions → a `<section class="footnotes">`), and
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.
2125
2226 Output is sanitized: only `http`/`https`/`mailto` link schemes and
2327 `http`/`https` image schemes are allowed; everything else is dropped.
Sources/OrgSwift/Inline.swift +14
@@ -75,6 +75,20 @@ func processOrgInline(
7575 }
7676 }
7777
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
7892 // Bare URLs in running text become links. Bracketed `[[]]` links are already
7993 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is
8094 // left outside the link.
Sources/OrgSwift/Links.swift +16 −2
@@ -32,8 +32,13 @@ private func parseOrgLink(
3232 guard text[start...].hasPrefix("[[") else { return nil }
3333
3434 var index = text.index(start, offsetBy: 2)
35 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
36 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
35 let descSeparator = text[index...].range(of: "][")?.lowerBound
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 }
3742 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
3843 }
3944
@@ -75,6 +80,15 @@ private func renderOrgLink(
7580 imageURLResolver: ((String) -> String?)? = nil,
7681 linkURLResolver: ((String) -> String?)? = nil
7782 ) -> 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 }
7892 let destination = normalizeOrgLinkTarget(rawDestination)
7993 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
8094 let source = String(label.dropFirst(2).dropLast(2))
Sources/OrgSwift/Lists.swift +123 −85
@@ -10,118 +10,156 @@ func orderedListItem(in line: String) -> String? {
1010 return String(match.2)
1111 }
1212
13func renderOrgListItemBody(
14 _ lines: [String],
15 imageURLResolver: ((String) -> String?)? = nil,
16 linkURLResolver: ((String) -> String?)? = nil
17) -> String {
18 guard let firstLine = lines.first else { return "" }
19
20 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
21 var nestedLines: [String] = []
22
23 for line in lines.dropFirst() {
24 let trimmed = line.trimmingCharacters(in: .whitespaces)
25 if trimmed.isEmpty {
26 continue
27 }
28
29 if isIndentedListItemLine(line) {
30 nestedLines.append(outdentOrgListLine(line))
31 } else {
32 contentLines.append(trimmed)
13/// Render a whole list block (all its lines, at any nesting depth) to HTML. The block is a
14/// run of list lines the caller has collected top-level item markers plus every deeper or
15/// continuation line, including the blank lines between an item's paragraphs.
16func renderOrgList(_ lines: [String], inlineRenderer: (String) -> String) -> String {
17 // Outdent the block so this level's markers sit at column 0.
18 let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
19 .map(leadingSpaces).min() ?? 0
20 let norm = lines.map { dropLeadingSpaces($0, base) }
21
22 // Group into items: each begins at a column-0 marker; deeper/continuation/blank lines
23 // belong to the item above them.
24 var items: [[String]] = []
25 var current: [String] = []
26 for line in norm {
27 if isListMarkerLine(line) {
28 if !current.isEmpty { items.append(current) }
29 current = [line]
30 } else if !current.isEmpty {
31 current.append(line)
3332 }
3433 }
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 }
3538
36 var html = renderTaskListItem(
37 contentLines.joined(separator: " "),
38 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
39 )
40 if !nestedLines.isEmpty {
41 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
39 let ordered = orderedListItem(in: firstMarker) != nil
40 var html = ordered ? "<ol>\n" : "<ul>\n"
41 for item in items {
42 html += "<li>" + renderListItem(item, inlineRenderer: inlineRenderer) + "</li>\n"
4243 }
44 html += ordered ? "</ol>\n" : "</ul>\n"
4345 return html
4446 }
4547
46private func renderNestedOrgListHTML(
47 _ lines: [String],
48 imageURLResolver: ((String) -> String?)? = nil,
49 linkURLResolver: ((String) -> String?)? = nil
50) -> String {
51 var html = ""
52 var listType: OrgListType?
53 var currentItemLines: [String] = []
54
55 func flushNestedItem() {
56 guard !currentItemLines.isEmpty else { return }
57 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
58 currentItemLines = []
59 }
60
61 func closeNestedList() {
62 flushNestedItem()
63 switch listType {
64 case .unordered:
65 html += "</ul>\n"
66 case .ordered:
67 html += "</ol>\n"
68 case nil:
69 break
70 }
71 listType = nil
48/// A single item's lines (the marker line plus everything under it) the `<li>` body:
49/// the item's paragraph(s), then any nested list. Text is wrapped in `<p>` only when the
50/// item has more than one paragraph, matching org's exporter.
51private func renderListItem(_ lines: [String], inlineRenderer: (String) -> String) -> String {
52 let head = stripListMarker(lines[0])
53 let rest = lines.dropFirst()
54 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
55 .map(leadingSpaces).min() ?? 0
56 let outdented = rest.map { dropLeadingSpaces($0, childIndent) }
57
58 var paragraphs: [String] = []
59 var currentParagraph: [String] = [head]
60 var sublistLines: [String] = []
61 var inSublist = false
62
63 func flushParagraph() {
64 let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces)
65 if !joined.isEmpty { paragraphs.append(joined) }
66 currentParagraph = []
7267 }
7368
74 for line in lines {
69 for line in outdented {
7570 let trimmed = line.trimmingCharacters(in: .whitespaces)
76 if trimmed.hasPrefix("- ") {
77 if listType != .unordered {
78 closeNestedList()
79 html += "<ul>\n"
80 listType = .unordered
81 }
82 flushNestedItem()
83 currentItemLines = [String(trimmed.dropFirst(2))]
84 continue
71 if isListMarkerLine(line) || inSublist {
72 // The first marker begins a nested list; everything after belongs to it.
73 if !inSublist { flushParagraph() }
74 inSublist = true
75 sublistLines.append(line)
76 } else if trimmed.isEmpty {
77 flushParagraph()
78 } else {
79 currentParagraph.append(trimmed)
8580 }
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 }
8693
87 if let orderedItem = orderedListItem(in: trimmed) {
88 if listType != .ordered {
89 closeNestedList()
90 html += "<ol>\n"
91 listType = .ordered
92 }
93 flushNestedItem()
94 currentItemLines = [orderedItem]
95 continue
96 }
94 if !sublistLines.isEmpty {
95 body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer)
96 }
97 return body
98}
9799
98 if listType != nil {
99 currentItemLines.append(line)
100/// If the items are a description list (`term :: definition`), render `<dl>`; otherwise nil.
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"
100115 }
101116 }
102
103 closeNestedList()
117 html += "</dl>\n"
104118 return html
105119 }
106120
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
107137 func isIndentedContinuationLine(_ line: String) -> Bool {
108138 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
109139 guard let first = line.first else { return false }
110140 return first == " " || first == "\t"
111141 }
112142
113private func isIndentedListItemLine(_ line: String) -> Bool {
114 guard isIndentedContinuationLine(line) else { return false }
115 let trimmed = line.trimmingCharacters(in: .whitespaces)
116 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
143private func leadingSpaces(_ line: String) -> Int {
144 var count = 0
145 for ch in line {
146 if ch == " " { count += 1 }
147 else if ch == "\t" { count += 8 }
148 else { break }
149 }
150 return count
117151 }
118152
119private func outdentOrgListLine(_ line: String) -> String {
120 var result = line
121 while result.first == " " || result.first == "\t" {
122 result.removeFirst()
153private func dropLeadingSpaces(_ line: String, _ n: Int) -> String {
154 var dropped = 0
155 var index = line.startIndex
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)
123161 }
124 return result
162 return String(line[index...])
125163 }
126164
127165 func renderTaskListItem(
Sources/OrgSwift/OrgRenderer.swift +109 −49
@@ -167,7 +167,13 @@ func orgToHTML(
167167 var inExampleBlock = false
168168 var inCenterBlock = false
169169 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] = []
171177 var paragraph: [String] = []
172178 var tableRows: [[String]] = []
173179 var propertyRows: [(String, String)] = []
@@ -210,26 +216,14 @@ func orgToHTML(
210216 }
211217 }
212218
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
223219 func closeList() {
224 flushListItem()
225 switch listType {
226 case .unordered:
227 html += "</ul>\n"
228 case .ordered:
229 html += "</ol>\n"
230 case nil:
231 break
220 if !listBuffer.isEmpty {
221 html += renderOrgList(listBuffer) {
222 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
223 }
232224 }
225 listBuffer = []
226 pendingListBlanks = []
233227 listType = nil
234228 }
235229
@@ -276,7 +270,7 @@ func orgToHTML(
276270
277271 func closeExampleBlock() {
278272 if inExampleBlock {
279 html += "</code></pre>\n"
273 html += "</pre>\n"
280274 inExampleBlock = false
281275 closePendingBlockWrapper()
282276 }
@@ -295,10 +289,10 @@ func orgToHTML(
295289 if inVerseBlock {
296290 let content = verseLines
297291 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
298 .joined(separator: "\n")
299 html += #"<blockquote class="org-verse">"# + "\n"
292 .joined(separator: "<br>\n")
293 html += #"<p class="verse">"# + "\n"
300294 html += content + "\n"
301 html += "</blockquote>\n"
295 html += "</p>\n"
302296 verseLines = []
303297 inVerseBlock = false
304298 closePendingBlockWrapper()
@@ -356,6 +350,34 @@ func orgToHTML(
356350 continue
357351 }
358352
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
359381 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
360382 closeQuoteBlock()
361383 continue
@@ -387,10 +409,11 @@ func orgToHTML(
387409 case "attr_html":
388410 pendingAttrHtml = directive.value
389411 continue
390 case "options", "property":
391 continue
392412 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
394417 }
395418 }
396419
@@ -426,7 +449,8 @@ func orgToHTML(
426449 }
427450
428451 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.
430454 flushBlockState()
431455 beginPendingBlockWrapperIfNeeded()
432456 let language = trimmed
@@ -446,7 +470,7 @@ func orgToHTML(
446470 closeQuoteBlock()
447471 flushBlockState()
448472 beginPendingBlockWrapperIfNeeded()
449 html += "<pre><code>"
473 html += "<pre>"
450474 inExampleBlock = true
451475 continue
452476 }
@@ -477,6 +501,35 @@ func orgToHTML(
477501 continue
478502 }
479503
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
480533 if trimmed == ":PROPERTIES:" {
481534 closeQuoteBlock()
482535 flushBlockState()
@@ -543,38 +596,45 @@ func orgToHTML(
543596 continue
544597 }
545598
546 if listType != nil && isIndentedContinuationLine(line) {
547 currentListItemLines.append(line)
599 // Inside a list, hold blank lines instead of ending it: an item can have several
600 // paragraphs, separated by blanks, before the next item or the list's end.
601 if listType != nil, trimmed.isEmpty {
602 pendingListBlanks.append(line)
548603 continue
549604 }
550605
551 // List items: - item
552 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
553 flushParagraph()
554 flushPropertyDrawer()
555 if listType != .unordered {
606 // A list item marker at column 0 starts or continues a list. A different marker
607 // type (ordered vs unordered) at the top level begins a separate list.
608 if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) {
609 let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered
610 if listType == nil {
611 flushParagraph()
612 flushPropertyDrawer()
613 } else if listType != newType {
556614 closeList()
557 html += "<ul>\n"
558 listType = .unordered
615 flushParagraph()
616 flushPropertyDrawer()
559617 }
560 flushListItem()
561 currentListItemLines = [String(trimmed.dropFirst(2))]
618 listType = newType
619 listBuffer.append(contentsOf: pendingListBlanks)
620 pendingListBlanks = []
621 listBuffer.append(line)
562622 continue
563623 }
564624
565 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
566 flushParagraph()
567 flushPropertyDrawer()
568 if listType != .ordered {
569 closeList()
570 html += "<ol>\n"
571 listType = .ordered
572 }
573 flushListItem()
574 currentListItemLines = [orderedItem]
625 // A line indented under an open list is a continuation or a nested item.
626 if listType != nil, isIndentedContinuationLine(line) {
627 listBuffer.append(contentsOf: pendingListBlanks)
628 pendingListBlanks = []
629 listBuffer.append(line)
575630 continue
576631 }
577632
633 // Any other line ends an open list, then is processed normally below.
634 if listType != nil {
635 closeList()
636 }
637
578638 // Blank line
579639 if trimmed.isEmpty {
580640 if inQuoteBlock {
Tests/OrgSwiftTests/ConformanceTests.swift +4 −4
@@ -50,16 +50,16 @@ private enum Expectation {
5050
5151 private let expectations: [String: Expectation] = [
5252 "table": .matches,
53 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"),
53 "blocks": .matches,
5454 "core": .matches,
55 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"),
55 "elements": .matches,
5656 "footnote": .matches,
5757 "headings": .matches,
5858 "images": .matches,
59 "lists": .diverges("list nesting deeper than one level is not represented"),
59 "lists": .matches,
6060 "minimal": .matches,
6161 "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,
6363 "timestamps": .matches,
6464 ]
6565
Tests/OrgSwiftTests/OrgRendererTests.swift +106 −4
@@ -34,8 +34,9 @@ struct OrgRendererTests {
3434 func orgExampleBlock() {
3535 let html = render("#+begin_example\nhello world\n#+end_example")
3636
37 #expect(html.contains("<pre><code>"))
38 #expect(html.contains("hello world"))
37 // Example blocks are a bare <pre> (org's exporter uses no inner <code>).
38 #expect(html.contains("<pre>hello world"))
39 #expect(!html.contains("<pre><code>"))
3940 }
4041
4142 @Test
@@ -82,8 +83,9 @@ struct OrgRendererTests {
8283 #+end_verse
8384 """)
8485
85 #expect(html.contains(#"<blockquote class="org-verse">"#))
86 #expect(html.contains("There is a line."))
86 // Verse renders as <p class="verse"> with <br> between lines, matching org.
87 #expect(html.contains(#"<p class="verse">"#))
88 #expect(html.contains("There is a line.<br>"))
8789 #expect(html.contains("And an indented line."))
8890 #expect(!html.contains("#+begin_verse"))
8991 }
@@ -359,4 +361,104 @@ struct OrgRendererTests {
359361 #expect(html.contains("Figure 1: </span>First"))
360362 #expect(html.contains("Figure 2: </span>Second"))
361363 }
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 }
362464 }