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

html library org-mode swift

Commit 256ee33237

256ee33237adb700b5813a2935ef3198ff5a5aee

parent: 242cd9f2c1

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T16:22:12Z

OrgSwift: render timestamps and footnotes

Timestamps: active/inactive, times, same-day and multi-day ranges render as
<time datetime> elements; day names and repeater/warning cookies dropped;
non-timestamps stay literal. Footnotes: references, inline footnotes, and
reference-style definitions render as <sup> anchors plus a trailing
<section class="footnotes">, numbered in first-reference order. Both corpus
cases now match orgo (3/12); GAPS.md updated.
GAPS.md +15 −5
@@ -5,9 +5,9 @@ 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, **1 matches orgo exactly (`table`)** and 11 diverge. Each
9divergence below is a missing capability, recorded in the `expectations` map in
10`Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to
8Of the 12 corpus cases, **3 match orgo exactly (`table`, `timestamps`, `footnote`)** and 9
9diverge. Each divergence below is a missing capability, recorded in the `expectations` map
10in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to
1111 matching and the test fails until it is moved to `.matches` — that is how this list stays
1212 honest.
1313
@@ -15,8 +15,6 @@ This is the backlog the shared package exists to work through. Roughly in value
1515
1616 | Case | Missing capability |
1717 |---|---|
18| `timestamps` | Active/inactive timestamps (`<2024-01-15 Mon>`, `[…]`) are left as literal text; orgo emits `<time datetime>`. |
19| `footnote` | Footnote references (`[fn:1]`) and definitions are not parsed. |
2018 | `headings` | Heading `:tags:` are not stripped/rendered; property drawers render as a visible `<dl>`. |
2119 | `minimal` | Property drawers (`:PROPERTIES:``:END:`) render as a visible `<dl>` instead of being dropped. |
2220 | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. |
@@ -35,3 +33,15 @@ they are presentation policy, not parser capability:
3533 (`true`); orgo carries the title in the page template, so conformance runs with `false`.
3634 - **`headingLevelOffset`** — added to a heading's star count. Default `0` renders `*` as
3735 `<h1>`; orgo uses `1` (`*``<h2>`, leaving `<h1>` for the title).
36
37## Closed
38
39 **`timestamps`** — active/inactive timestamps, times, same-day and multi-day ranges now
40 render as `<time class="timestamp" datetime>` elements matching orgo; day names and
41 repeater/warning cookies are dropped, non-timestamps (`3 < 4`, `[not a stamp]`) stay
42 literal.
43 **`footnote`** — references (`[fn:1]`), inline footnotes (`[fn:label:text]`), and
44 reference-style definitions now render as `<sup>` anchors plus a `<section
45 class="footnotes">` at the end, numbered in first-reference order. Footnote references
46 are collected from paragraphs and headings; a reference inside a list item or table cell
47 is not yet collected (it degrades to literal text).
README.md +5 −3
@@ -13,9 +13,11 @@ Headings, paragraphs, ordered/unordered lists (with nesting, wrapped lines, and
1313 `[ ]`/`[x]` task checkboxes), tables (with `:---:` alignment), `#+begin_src` /
1414 `example` / `quote` / `center` / `verse` blocks, property drawers,
1515 `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links
16and linked images (`[[dest][label]]`), horizontal rules, comments, and inline
17markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, `_underline_`,
18email autolinks).
16and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps
17(`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes
18(references, inline, and definitions → a `<section class="footnotes">`), and
19inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`,
20`_underline_`, email autolinks).
1921
2022 Output is sanitized: only `http`/`https`/`mailto` link schemes and
2123 `http`/`https` image schemes are allowed; everything else is dropped.
Sources/OrgSwift/Footnotes.swift added +76
@@ -0,0 +1,76 @@
1import Foundation
2
3// Org footnotes: references `[fn:1]` in running text, inline footnotes that define at the
4// point of use `[fn:label:text]`, and reference-style definitions on their own line
5// `[fn:1] the definition`. References become `<sup>` anchors; the definitions are
6// collected and emitted as a `<section class="footnotes">` at the end of the document, in
7// order of first reference matching orgo's `ox-html` shape.
8
9/// Accumulates footnote references and definitions across a document render. A single
10/// instance lives for one `orgToHTML` call; references register as paragraphs flush, and
11/// the section is rendered once at the end.
12final class FootnoteCollector {
13 private(set) var order: [String] = []
14 private var numbers: [String: Int] = [:]
15 private var inlineText: [String: String] = [:]
16 private var definitions: [String: String] = [:]
17
18 var hasEntries: Bool { !order.isEmpty }
19
20 /// Register a reference and return its display number. `inline` is the text of an
21 /// inline footnote (`[fn:label:inline]`), nil for a plain reference.
22 func reference(label: String, inline: String?) -> Int {
23 let number: Int
24 if let existing = numbers[label] {
25 number = existing
26 } else {
27 number = order.count + 1
28 numbers[label] = number
29 order.append(label)
30 }
31 if let inline, inlineText[label] == nil {
32 inlineText[label] = inline
33 }
34 return number
35 }
36
37 /// Record a reference-style definition line. A definition can arrive before or after
38 /// its reference; the number is assigned by reference order regardless.
39 func define(label: String, text: String) {
40 if numbers[label] == nil {
41 numbers[label] = order.count + 1
42 order.append(label)
43 }
44 definitions[label] = text
45 }
46
47 /// Render the footnotes section, or "" if there are none. `inlineRenderer` renders a
48 /// block definition's own inline markup (it must not itself collect footnotes). Inline
49 /// footnote text was captured mid-inline and is already escaped, so it is emitted
50 /// directly running it through `inlineRenderer` again would double-escape it.
51 func renderSection(inlineRenderer: (String) -> String) -> String {
52 guard hasEntries else { return "" }
53 var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
54 for label in order {
55 let n = numbers[label] ?? 0
56 let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"##
57 if let inline = inlineText[label] {
58 html += "<li id=\"fn-\(n)\">\(inline) \(back)</li>\n"
59 } else {
60 let body = definitions[label].map(inlineRenderer) ?? ""
61 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
62 }
63 }
64 html += "</ol>\n</section>\n"
65 return html
66 }
67}
68
69/// Detect a reference-style footnote definition line: `[fn:label] text`. Returns the
70/// label and the definition text, or nil if the line is not one.
71func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? {
72 guard let match = line.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)\]\s+(.+)$/) else {
73 return nil
74 }
75 return (String(match.1), String(match.2))
76}
Sources/OrgSwift/Inline.swift +41 −1
@@ -3,7 +3,8 @@ import Foundation
33 func processOrgInline(
44 _ text: String,
55 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil
6 linkURLResolver: ((String) -> String?)? = nil,
7 footnotes: FootnoteCollector? = nil
78 ) -> String {
89 var result = escapeHTML(text)
910 var protectedFragments: [String: String] = [:]
@@ -35,6 +36,45 @@ func processOrgInline(
3536 imageURLResolver: imageURLResolver,
3637 linkURLResolver: linkURLResolver
3738 )
39
40 result = protectTimestamps(in: result, protectedFragments: &protectedFragments)
41
42 if let footnotes {
43 // Assign footnote numbers in textual (forward) order first. The protect passes
44 // below replace right-to-left, so registering there would number references
45 // backwards and reverse the definitions section; this pre-scan fixes the order,
46 // and the passes then just look up the already-assigned number.
47 if let scan = try? NSRegularExpression(pattern: #"\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]"#) {
48 let ns = result as NSString
49 for m in scan.matches(in: result, range: NSRange(location: 0, length: ns.length)) {
50 let label = ns.substring(with: m.range(at: 1))
51 let textRange = m.range(at: 2)
52 let inline = textRange.location == NSNotFound ? nil : ns.substring(with: textRange)
53 _ = footnotes.reference(label: label, inline: inline)
54 }
55 }
56 // Inline footnote `[fn:label:text]` first (more specific), then a plain reference.
57 result = protectMatches(
58 in: result,
59 pattern: #"\[fn:([A-Za-z0-9_-]+):([^\]]*)\]"#,
60 protectedFragments: &protectedFragments
61 ) { match, nsText in
62 let label = nsText.substring(with: match.range(at: 1))
63 let inline = nsText.substring(with: match.range(at: 2))
64 let n = footnotes.reference(label: label, inline: inline)
65 return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"##
66 }
67 result = protectMatches(
68 in: result,
69 pattern: #"\[fn:([A-Za-z0-9_-]+)\]"#,
70 protectedFragments: &protectedFragments
71 ) { match, nsText in
72 let label = nsText.substring(with: match.range(at: 1))
73 let n = footnotes.reference(label: label, inline: nil)
74 return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"##
75 }
76 }
77
3878 result = protectMatches(
3979 in: result,
4080 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
Sources/OrgSwift/OrgRenderer.swift +16 −2
@@ -165,6 +165,7 @@ func orgToHTML(
165165 var pendingBlockCaption: String?
166166 var activeBlockCaption: String?
167167 var isWrappingBlockFigure = false
168 let footnotes = FootnoteCollector()
168169
169170 func beginPendingBlockWrapperIfNeeded() {
170171 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
@@ -191,7 +192,7 @@ func orgToHTML(
191192 let normalizedParagraph = paragraph
192193 .map { $0.trimmingCharacters(in: .whitespaces) }
193194 .joined(separator: " ")
194 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
195 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) + "</p>\n"
195196 paragraph = []
196197 }
197198 }
@@ -475,12 +476,21 @@ func orgToHTML(
475476 continue
476477 }
477478
479 // Reference-style footnote definition: [fn:label] text. Collected out of the body
480 // flow and emitted in the footnotes section at the end.
481 if let definition = orgFootnoteDefinition(in: trimmed) {
482 closeQuoteBlock()
483 flushBlockState()
484 footnotes.define(label: definition.label, text: definition.text)
485 continue
486 }
487
478488 // Org headings: * heading, ** heading, *** heading
479489 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
480490 closeQuoteBlock()
481491 flushBlockState()
482492 let level = min(6, max(1, match.1.count + headingLevelOffset))
483 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
493 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes)
484494 html += "<h\(level)>" + content + "</h\(level)>\n"
485495 continue
486496 }
@@ -542,5 +552,9 @@ func orgToHTML(
542552 closeQuoteBlock()
543553 flushBlockState()
544554
555 html += footnotes.renderSection { text in
556 processOrgInline(text, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
557 }
558
545559 return html
546560 }
Sources/OrgSwift/Timestamps.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2
3// Org timestamps: active `<2024-01-15 Mon>`, inactive `[2024-01-15 Mon]`, with a time
4// `< 10:30>`, a same-day time range `< 10:00-11:45>`, and a multi-day range
5// `<>--<>`. Rendered as `<time>` elements the way orgo does: the date is data a
6// browser can act on, day names and repeater/warning cookies are dropped, and a range
7// becomes two `<time>`s joined by an en-dash. The interior arrives already
8// HTML-escaped, so these operate on `&lt;&gt;` / `[]`.
9
10/// Render one timestamp interior (no surrounding brackets) as one or two `<time>`
11/// elements. `interior` is e.g. "2024-01-15 Mon", "2024-01-15 Mon 10:30",
12/// "2024-01-15 Mon 10:00-11:45". A day name and any repeater/warning cookie are ignored.
13func renderTimestamp(interior: String, inactive: Bool) -> String {
14 let ns = interior as NSString
15 let full = NSRange(location: 0, length: ns.length)
16 let cssClass = inactive ? "timestamp inactive" : "timestamp"
17
18 guard let dateRegex = try? NSRegularExpression(pattern: #"\d{4}-\d{2}-\d{2}"#),
19 let dateMatch = dateRegex.firstMatch(in: interior, range: full) else {
20 // No date not really a timestamp; hand the text back untouched.
21 return interior
22 }
23 let date = ns.substring(with: dateMatch.range)
24
25 var startTime: String?
26 var endTime: String?
27 if let timeRegex = try? NSRegularExpression(pattern: #"(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?"#),
28 let timeMatch = timeRegex.firstMatch(in: interior, range: full) {
29 startTime = ns.substring(with: timeMatch.range(at: 1))
30 let endRange = timeMatch.range(at: 2)
31 if endRange.location != NSNotFound {
32 endTime = ns.substring(with: endRange)
33 }
34 }
35
36 func time(_ datetime: String, _ text: String) -> String {
37 #"<time class="\#(cssClass)" datetime="\#(datetime)">\#(text)</time>"#
38 }
39
40 if let startTime, let endTime {
41 return time("\(date)T\(startTime)", "\(date) \(startTime)")
42 + "&#8211;"
43 + time("\(date)T\(endTime)", endTime)
44 }
45 if let startTime {
46 return time("\(date)T\(startTime)", "\(date) \(startTime)")
47 }
48 return time(date, date)
49}
50
51/// Extract every timestamp in already-escaped inline text into protected fragments,
52/// leaving placeholders behind so later emphasis passes cannot touch a `<time>`.
53func protectTimestamps(in text: String, protectedFragments: inout [String: String]) -> String {
54 var result = text
55
56 // Multi-day range first, so its two `<>` are consumed as one unit before the
57 // single-timestamp pass sees them. `--` between them becomes the en-dash.
58 result = protectMatches(
59 in: result,
60 pattern: #"&lt;(\d{4}-\d{2}-\d{2}[^&]*?)&gt;--&lt;(\d{4}-\d{2}-\d{2}[^&]*?)&gt;"#,
61 protectedFragments: &protectedFragments
62 ) { match, nsText in
63 let a = nsText.substring(with: match.range(at: 1))
64 let b = nsText.substring(with: match.range(at: 2))
65 return renderTimestamp(interior: a, inactive: false)
66 + "&#8211;"
67 + renderTimestamp(interior: b, inactive: false)
68 }
69
70 // Active single.
71 result = protectMatches(
72 in: result,
73 pattern: #"&lt;(\d{4}-\d{2}-\d{2}[^&]*?)&gt;"#,
74 protectedFragments: &protectedFragments
75 ) { match, nsText in
76 renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: false)
77 }
78
79 // Inactive. Requires a date inside, so `[not a stamp]` is left alone.
80 result = protectMatches(
81 in: result,
82 pattern: #"\[(\d{4}-\d{2}-\d{2}[^\]]*?)\]"#,
83 protectedFragments: &protectedFragments
84 ) { match, nsText in
85 renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: true)
86 }
87
88 return result
89}
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
@@ -53,14 +53,14 @@ private let expectations: [String: Expectation] = [
5353 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"),
5454 "core": .diverges("bare URLs are not autolinked"),
5555 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"),
56 "footnote": .diverges("footnote references and definitions are not parsed"),
56 "footnote": .matches,
5757 "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"),
5858 "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"),
5959 "lists": .diverges("list nesting deeper than one level is not represented"),
6060 "minimal": .diverges("property drawers render as a visible <dl> instead of being dropped"),
6161 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"),
6262 "tblfm": .diverges("^ superscript is not rendered in table cells"),
63 "timestamps": .diverges("active/inactive timestamps are not parsed"),
63 "timestamps": .matches,
6464 ]
6565
6666 struct ConformanceTests {
Tests/OrgSwiftTests/OrgRendererTests.swift +63
@@ -191,4 +191,67 @@ struct OrgRendererTests {
191191 #expect(deep.contains("<h6>Deep</h6>"))
192192 #expect(!deep.contains("<h7"))
193193 }
194
195 @Test
196 func timestampActiveAndInactive() {
197 let html = render("An active <2024-01-15 Mon> and inactive [2024-01-15 Mon].")
198 #expect(html.contains(#"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#))
199 #expect(html.contains(#"<time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>"#))
200 }
201
202 @Test
203 func timestampWithTimeAndRanges() {
204 #expect(render("At <2024-01-15 Mon 10:30>.").contains(
205 #"<time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>"#))
206
207 let sameDay = render("Range <2024-01-15 Mon 10:00-11:45>.")
208 #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
209 #expect(sameDay.contains("&#8211;"))
210 #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
211
212 let multiDay = render("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")
213 #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
214 #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
215 #expect(multiDay.contains("&#8211;"))
216 }
217
218 @Test
219 func timestampRepeaterDroppedAndNonTimestampsLiteral() {
220 #expect(render("Repeats <2024-01-15 Mon +1w>.").contains(
221 #"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#))
222 let literal = render("Compare 3 < 4 and [not a stamp].")
223 #expect(!literal.contains("<time"))
224 #expect(literal.contains("[not a stamp]"))
225 }
226
227 @Test
228 func footnoteReferencesAndSection() {
229 let html = render("""
230 A claim.[fn:1] Another.[fn:2]
231
232 [fn:1] First note.
233 [fn:2] Second with /emphasis/.
234 """)
235 // References number in textual order and link to the section.
236 #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup>"##))
237 #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-2" href="#fn-2">2</a></sup>"##))
238 // The section renders once, in order, with the definitions and back-links.
239 #expect(html.contains(#"<section class="footnotes""#))
240 #expect(html.contains(#"<li id="fn-1"><p>First note.</p>"#))
241 #expect(html.contains("Second with <em>emphasis</em>"))
242 #expect(html.contains(##"href="#fnr-2""##))
243 // fn-1 must appear before fn-2 in the section.
244 let firstIndex = html.range(of: #"id="fn-1""#)!.lowerBound
245 let secondIndex = html.range(of: #"id="fn-2""#)!.lowerBound
246 #expect(firstIndex < secondIndex)
247 }
248
249 @Test
250 func inlineFootnoteNotWrappedInParagraph() {
251 let html = render("See here.[fn:x:defined inline]")
252 #expect(html.contains(##"href="#fn-1">1</a>"##))
253 // Inline footnote content sits directly in the <li>, not wrapped in <p>.
254 #expect(html.contains(#"<li id="fn-1">defined inline "#))
255 #expect(!html.contains("<li id=\"fn-1\"><p>defined inline"))
256 }
194257 }