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

html library org-mode swift

Commit 242cd9f2c1

242cd9f2c12e0d082cd643d25cadf19d116161ac

parent: e2f8315a9c

Verified · cmc

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

OrgSwift: skeleton reduction, conformance harness, orgo-compatible options

Add OrgSkeleton (a Swift port of orgo's HTML→semantic-skeleton reducer) and a
conformance test that measures the renderer against the shared org-conformance
corpus. Add two OrgRenderOptions knobs, both defaulting to the prior behavior:
metadataHeader (suppress the #+TITLE/#+AUTHOR/#+DATE block) and headingLevelOffset
(shift * from h1 toward orgo's h2). GAPS.md records the per-case backlog the
corpus surfaces.
GAPS.md added +37
@@ -0,0 +1,37 @@
1# Conformance gaps
2
3OrgSwift is measured against the [org-conformance](../org-conformance) corpus, whose
4goldens come from orgo (validated against Emacs `ox-html`). The renderer is run in
5orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)`
6so that only real rendering differences remain.
7
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
11matching and the test fails until it is moved to `.matches` — that is how this list stays
12honest.
13
14This is the backlog the shared package exists to work through. Roughly in value order:
15
16| Case | Missing capability |
17|---|---|
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. |
20| `headings` | Heading `:tags:` are not stripped/rendered; property drawers render as a visible `<dl>`. |
21| `minimal` | Property drawers (`:PROPERTIES:``:END:`) render as a visible `<dl>` instead of being dropped. |
22| `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. |
23| `core` | Bare URLs in running text are not autolinked. |
24| `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. |
25| `lists` | List nesting deeper than one level is flattened. |
26| `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). |
27| `blocks` | Example blocks wrap in `<pre><code>`; orgo uses a bare `<pre>`. |
28| `outofscope` | Deliberately unsupported constructs. `scope: out` in the corpus — orgo may also differ, so treat this as documentation, not a target. |
29
30Two behaviors were made configurable during extraction rather than left hardcoded, because
31they are presentation policy, not parser capability:
32
33 **`metadataHeader`** — whether `#+TITLE`/`#+AUTHOR`/`#+DATE` render as a leading
34 `<div class="org-metadata">`. Apps that want a visible README title keep the default
35 (`true`); orgo carries the title in the page template, so conformance runs with `false`.
36 **`headingLevelOffset`** — added to a heading's star count. Default `0` renders `*` as
37 `<h1>`; orgo uses `1` (`*``<h2>`, leaving `<h1>` for the title).
README.md +21
@@ -50,6 +50,17 @@ let html = OrgRenderer.renderToHTML(
5050 `./images/badge.svg` then resolves to
5151 `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`.
5252
53### Title and heading level
54
55Two presentation knobs, both defaulting to how Hutch rendered:
56
57 `metadataHeader` (default `true`) — emit a leading `<div class="org-metadata">`
58 with the `#+TITLE`/`#+AUTHOR`/`#+DATE`. Set `false` to drop it (e.g. when the
59 surrounding UI shows the title itself).
60 `headingLevelOffset` (default `0`) — added to each heading's star count, clamped
61 to `1...6`. Default renders `*` as `<h1>`; use `1` to render `*` as `<h2>`,
62 leaving `<h1>` for a document title.
63
5364 ### Syntax highlighting
5465
5566 Code blocks are highlighted through a protocol so the library carries no
@@ -69,3 +80,13 @@ let html = OrgRenderer.renderToHTML(orgSource, highlighter: MyHighlighter())
6980 ```
7081
7182 A conformer returning `nil` for a given block gets the same escaped fallback.
83
84## Conformance
85
86OrgSwift is tested against the shared [org-conformance](../org-conformance)
87corpus, whose golden outputs come from `orgo` (itself validated against Emacs
88`ox-html`). `swift test` locates a sibling `../org-conformance` checkout, or set
89`ORG_CONFORMANCE_DIR`. The suite reduces both this renderer's HTML and orgo's to
90a semantic skeleton and compares them; the current pass/divergence state is
91recorded per case, so a closed gap or a regression both surface as a failing
92test. See [GAPS.md](GAPS.md) for the outstanding backlog.
Sources/OrgSwift/OrgRenderer.swift +22 −5
@@ -30,19 +30,32 @@ public struct OrgRenderOptions {
3030 public var ref: String
3131 /// Path of the README being rendered, used to resolve paths relative to it.
3232 public var readmePath: String?
33 /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`.
34 /// Apps that show a README title want this; orgo treats those keywords as document
35 /// metadata carried by the page template, not body content, so the conformance corpus
36 /// renders with this off.
37 public var metadataHeader: Bool
38 /// Added to every heading's star count before it becomes an `<hN>` level (clamped to
39 /// 1...6). Default 0 renders `*` as `<h1>`. orgo offsets by 1 (`*` `<h2>`) because the
40 /// document title occupies `<h1>`; set this to 1 to match orgo.
41 public var headingLevelOffset: Int
3342
3443 public init(
3544 host: String = "git.sr.ht",
3645 owner: String? = nil,
3746 repositoryName: String? = nil,
3847 ref: String = "HEAD",
39 readmePath: String? = nil
48 readmePath: String? = nil,
49 metadataHeader: Bool = true,
50 headingLevelOffset: Int = 0
4051 ) {
4152 self.host = host
4253 self.owner = owner
4354 self.repositoryName = repositoryName
4455 self.ref = ref
4556 self.readmePath = readmePath
57 self.metadataHeader = metadataHeader
58 self.headingLevelOffset = headingLevelOffset
4659 }
4760
4861 func imageURLResolver() -> ((String) -> String?)? {
@@ -91,7 +104,9 @@ public enum OrgRenderer {
91104 source,
92105 highlighter: highlighter,
93106 imageURLResolver: options.imageURLResolver(),
94 linkURLResolver: options.linkURLResolver()
107 linkURLResolver: options.linkURLResolver(),
108 metadataHeader: options.metadataHeader,
109 headingLevelOffset: options.headingLevelOffset
95110 )
96111 }
97112 }
@@ -102,7 +117,9 @@ func orgToHTML(
102117 _ text: String,
103118 highlighter: CodeHighlighter,
104119 imageURLResolver: ((String) -> String?)? = nil,
105 linkURLResolver: ((String) -> String?)? = nil
120 linkURLResolver: ((String) -> String?)? = nil,
121 metadataHeader: Bool = true,
122 headingLevelOffset: Int = 0
106123 ) -> String {
107124 let normalizedText = text
108125 .replacingOccurrences(of: "\r\n", with: "\n")
@@ -284,7 +301,7 @@ func orgToHTML(
284301 flushPropertyDrawer()
285302 }
286303
287 if title != nil || author != nil || date != nil {
304 if metadataHeader, title != nil || author != nil || date != nil {
288305 html += "<div class=\"org-metadata\">\n"
289306 if let title {
290307 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
@@ -462,7 +479,7 @@ func orgToHTML(
462479 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
463480 closeQuoteBlock()
464481 flushBlockState()
465 let level = match.1.count
482 let level = min(6, max(1, match.1.count + headingLevelOffset))
466483 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
467484 html += "<h\(level)>" + content + "</h\(level)>\n"
468485 continue
Sources/OrgSwift/Skeleton.swift added +218
@@ -0,0 +1,218 @@
1// HTML semantic skeleton reduction.
2//
3// A faithful port of orgo's `skeleton()` (src/skeleton.rs) and the contract described in
4// the org-conformance corpus (SKELETON.md). It exists so OrgSwift can be measured against
5// the same goldens every other org renderer is: reduce this renderer's HTML to a
6// skeleton, reduce orgo's reference HTML to a skeleton, and compare. The two renderers
7// wrap and class things completely differently; the skeleton is the part they can
8// meaningfully agree on.
9//
10// The reduction must be byte-identical to orgo's, or a comparison means nothing. The
11// package's conformance test self-checks that first, by feeding each corpus `.html`
12// through this port and requiring it to reproduce the checked-in `.skeleton`.
13
14import Foundation
15
16public enum OrgSkeleton {
17 /// Elements dropped entirely: `div` is layout, `span` is per-token highlighter noise.
18 private static let ignored: Set<String> = ["div", "span"]
19
20 /// The only content-bearing attributes; everything else is generated or cosmetic.
21 private static let keptAttrs = ["href", "src"]
22
23 /// HTML void elements never emit a close event.
24 private static let void: Set<String> = [
25 "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
26 ]
27
28 /// Reduce an HTML fragment to one line per element open, element close, or text run.
29 public static func skeleton(_ html: String) -> [String] {
30 var out: [String] = []
31 let chars = Array(html)
32 var i = 0
33 var text = ""
34
35 while i < chars.count {
36 if chars[i] != "<" {
37 text.append(chars[i])
38 i += 1
39 continue
40 }
41
42 // Comments and doctypes carry nothing.
43 if i + 1 < chars.count, chars[i + 1] == "!" {
44 if let end = findFrom(chars, i, ">") {
45 i = end + 1
46 } else {
47 break
48 }
49 continue
50 }
51 guard let end = findFrom(chars, i, ">") else { break }
52 let rawFull = String(chars[(i + 1)..<end])
53 i = end + 1
54
55 // trim strip trailing '/' trim
56 var raw = rawFull.trimmingCharacters(in: .whitespaces)
57 if raw.hasSuffix("/") { raw.removeLast() }
58 raw = raw.trimmingCharacters(in: .whitespaces)
59
60 if raw.hasPrefix("/") {
61 let name = raw.dropFirst().trimmingCharacters(in: .whitespaces).lowercased()
62 if !ignored.contains(name) && !void.contains(name) {
63 flushText(&text, &out)
64 out.append("</\(name)>")
65 }
66 continue
67 }
68
69 let (name, rest) = splitFirstWhitespace(raw)
70 let lname = name.lowercased()
71 if lname.isEmpty || ignored.contains(lname) {
72 continue
73 }
74 let attrs = keptAttributes(rest)
75 flushText(&text, &out)
76 out.append("<\(lname)\(attrs)>")
77 }
78 flushText(&text, &out)
79 return out
80 }
81
82 private static func flushText(_ text: inout String, _ out: inout [String]) {
83 let decoded = decodeEntities(text)
84 let collapsed = decoded.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
85 if !collapsed.isEmpty {
86 out.append(debugQuote(collapsed))
87 }
88 text = ""
89 }
90
91 /// Reproduce Rust's `format!("{:?}", s)` for whitespace-collapsed text: wrap in double
92 /// quotes and escape `\` and `"`. No control characters survive whitespace collapse,
93 /// so no further escaping is needed to match the goldens.
94 private static func debugQuote(_ s: String) -> String {
95 var escaped = ""
96 escaped.reserveCapacity(s.count + 2)
97 for c in s {
98 if c == "\\" || c == "\"" { escaped.append("\\") }
99 escaped.append(c)
100 }
101 return "\"\(escaped)\""
102 }
103
104 private static func findFrom(_ chars: [Character], _ from: Int, _ needle: Character) -> Int? {
105 var k = from
106 while k < chars.count {
107 if chars[k] == needle { return k }
108 k += 1
109 }
110 return nil
111 }
112
113 private static func splitFirstWhitespace(_ s: String) -> (String, String) {
114 guard let idx = s.firstIndex(where: { $0.isWhitespace }) else { return (s, "") }
115 let name = String(s[s.startIndex..<idx])
116 let rest = String(s[s.index(after: idx)...])
117 return (name, rest)
118 }
119
120 private static func keptAttributes(_ rest: String) -> String {
121 var result = ""
122 for attr in keptAttrs {
123 if let value = attributeValue(rest, attr) {
124 result += " \(attr)=\"\(decodeEntities(value))\""
125 }
126 }
127 return result
128 }
129
130 private static func attributeValue(_ rest: String, _ name: String) -> String? {
131 let chars = Array(rest)
132 let nameChars = Array(name)
133 var search = 0
134 while let pos = indexOf(chars, nameChars, from: search) {
135 let beforeOK = pos == 0 || chars[pos - 1].isWhitespace
136 var after = pos + nameChars.count
137 while after < chars.count && chars[after].isWhitespace { after += 1 }
138 if beforeOK && after < chars.count && chars[after] == "=" {
139 var value = after + 1
140 while value < chars.count && chars[value].isWhitespace { value += 1 }
141 guard value < chars.count else { return nil }
142 let quote = chars[value]
143 if quote == "\"" || quote == "'" {
144 var end = value + 1
145 while end < chars.count && chars[end] != quote { end += 1 }
146 guard end < chars.count else { return nil }
147 return String(chars[(value + 1)..<end])
148 }
149 var end = value
150 while end < chars.count && !chars[end].isWhitespace { end += 1 }
151 return String(chars[value..<end])
152 }
153 search = pos + nameChars.count
154 }
155 return nil
156 }
157
158 private static func indexOf(_ haystack: [Character], _ needle: [Character], from: Int) -> Int? {
159 if needle.isEmpty { return from }
160 var k = from
161 while k + needle.count <= haystack.count {
162 if Array(haystack[k..<(k + needle.count)]) == needle { return k }
163 k += 1
164 }
165 return nil
166 }
167
168 /// Decode the entities either exporter is likely to emit.
169 static func decodeEntities(_ s: String) -> String {
170 var out = ""
171 out.reserveCapacity(s.count)
172 let chars = Array(s)
173 var i = 0
174 while i < chars.count {
175 guard chars[i] == "&" else { out.append(chars[i]); i += 1; continue }
176 // find ';' within 12 chars
177 var semi: Int? = nil
178 var k = i + 1
179 while k < chars.count && k - i <= 12 {
180 if chars[k] == ";" { semi = k; break }
181 k += 1
182 }
183 guard let semiIdx = semi else { out.append("&"); i += 1; continue }
184 let entity = String(chars[(i + 1)..<semiIdx])
185 let decoded = decodeEntity(entity)
186 if let d = decoded {
187 out.append(d == "\u{a0}" ? " " : String(d))
188 i = semiIdx + 1
189 } else {
190 out.append("&")
191 i += 1
192 }
193 }
194 return out
195 }
196
197 private static func decodeEntity(_ entity: String) -> Character? {
198 switch entity {
199 case "amp": return "&"
200 case "lt": return "<"
201 case "gt": return ">"
202 case "quot": return "\""
203 case "apos": return "'"
204 case "nbsp": return "\u{a0}"
205 default:
206 guard entity.hasPrefix("#") else { return nil }
207 let num = String(entity.dropFirst())
208 let scalar: UInt32?
209 if num.hasPrefix("x") || num.hasPrefix("X") {
210 scalar = UInt32(num.dropFirst(), radix: 16)
211 } else {
212 scalar = UInt32(num)
213 }
214 guard let s = scalar, let u = Unicode.Scalar(s) else { return nil }
215 return Character(u)
216 }
217 }
218}
Tests/OrgSwiftTests/ConformanceTests.swift added +117
@@ -0,0 +1,117 @@
1import Foundation
2import Testing
3@testable import OrgSwift
4
5/// Locate the org-conformance corpus: env var first, then the sibling checkout that sits
6/// next to this package under a shared parent (/org-swift and /org-conformance).
7private func corpusCasesDir() -> URL? {
8 if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
9 let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")
10 if FileManager.default.fileExists(atPath: cases.path) { return cases }
11 }
12 // #filePath = /org-swift/Tests/OrgSwiftTests/ConformanceTests.swift
13 let pkgRoot = URL(fileURLWithPath: #filePath)
14 .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
15 let sibling = pkgRoot.deletingLastPathComponent()
16 .appendingPathComponent("org-conformance").appendingPathComponent("cases")
17 if FileManager.default.fileExists(atPath: sibling.path) { return sibling }
18 return nil
19}
20
21private func caseNames(_ dir: URL) -> [String] {
22 let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
23 return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted()
24}
25
26private func read(_ dir: URL, _ name: String, _ ext: String) -> String {
27 (try? String(contentsOf: dir.appendingPathComponent("\(name).\(ext)"), encoding: .utf8)) ?? ""
28}
29
30private func goldenSkeleton(_ dir: URL, _ name: String) -> [String] {
31 let raw = read(dir, name, "skeleton")
32 let trimmed = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw
33 return trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n")
34}
35
36/// orgo's `render()` produces body content only: `#+TITLE`/`#+AUTHOR`/`#+DATE` are document
37/// metadata carried by the page template, and a top-level `*` heading is `<h2>` because the
38/// title owns `<h1>`. The corpus goldens are that body content, so the renderer is measured
39/// in the matching configuration.
40private let orgoCompatibleOptions = OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)
41
42/// What each corpus case does against orgo today. This is a *reviewed* record, not a wish:
43/// a case that starts matching, or a matching case that regresses, both fail the test and
44/// demand this map be updated which is the point. Each `.diverges` reason names the
45/// missing capability, and together they are OrgSwift's conformance backlog (see GAPS.md).
46private enum Expectation {
47 case matches
48 case diverges(String)
49}
50
51private let expectations: [String: Expectation] = [
52 "table": .matches,
53 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"),
54 "core": .diverges("bare URLs are not autolinked"),
55 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"),
56 "footnote": .diverges("footnote references and definitions are not parsed"),
57 "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"),
58 "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"),
59 "lists": .diverges("list nesting deeper than one level is not represented"),
60 "minimal": .diverges("property drawers render as a visible <dl> instead of being dropped"),
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"),
63 "timestamps": .diverges("active/inactive timestamps are not parsed"),
64]
65
66struct ConformanceTests {
67 /// The skeleton port must be byte-identical to orgo's, or comparing renderers means
68 /// nothing. Prove it against the reference HTML first: feed each corpus `.html` through
69 /// the Swift port and require it to reproduce the checked-in `.skeleton`. A failure here
70 /// is a port bug, isolated from any renderer question.
71 @Test
72 func skeletonPortMatchesGoldens() throws {
73 guard let dir = corpusCasesDir() else {
74 print("org-conformance corpus not found — skipping (set ORG_CONFORMANCE_DIR)")
75 return
76 }
77 for name in caseNames(dir) {
78 let got = OrgSkeleton.skeleton(read(dir, name, "html"))
79 #expect(got == goldenSkeleton(dir, name), "skeleton port diverges from orgo on '\(name)'")
80 }
81 }
82
83 /// Render every case with OrgSwift and check the result against the reviewed
84 /// expectation map. Green when reality matches the record; a case that newly conforms
85 /// (a gap closed) or newly diverges (a regression) fails and points at the map.
86 @Test
87 func renderConformanceMatchesExpectation() throws {
88 guard let dir = corpusCasesDir() else {
89 print("org-conformance corpus not found — skipping (set ORG_CONFORMANCE_DIR)")
90 return
91 }
92 let dump = ProcessInfo.processInfo.environment["ORG_DUMP"] != nil
93 for name in caseNames(dir) {
94 let expected = goldenSkeleton(dir, name)
95 let got = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(read(dir, name, "org"), options: orgoCompatibleOptions))
96 let conforms = got == expected
97
98 switch expectations[name] {
99 case .matches:
100 #expect(conforms, "'\(name)' was expected to match orgo but diverged")
101 case .diverges(let reason):
102 #expect(!conforms, "'\(name)' now MATCHES orgo — gap closed (\(reason)). Move it to .matches in expectations.")
103 case nil:
104 Issue.record("'\(name)' has no entry in the expectations map")
105 }
106
107 if dump && (conforms != (expectations[name].map { if case .matches = $0 { true } else { false } } ?? false)) {
108 let maxN = max(got.count, expected.count)
109 print("---- DIFF \(name) ----")
110 for k in 0..<maxN where (k < expected.count ? expected[k] : "") != (k < got.count ? got[k] : "") {
111 print(" orgo[\(k)]=\(k < expected.count ? expected[k] : "")")
112 print(" ours[\(k)]=\(k < got.count ? got[k] : "")")
113 }
114 }
115 }
116 }
117}
Tests/OrgSwiftTests/OrgRendererTests.swift +30
@@ -161,4 +161,34 @@ struct OrgRendererTests {
161161 #expect(html.contains("text-align: center;"))
162162 #expect(html.contains("text-align: right;"))
163163 }
164
165 @Test
166 func metadataHeaderShownByDefault() {
167 let html = render("#+TITLE: My Doc\n\nBody.")
168 #expect(html.contains("<h1 class=\"org-title\">My Doc</h1>"))
169 }
170
171 @Test
172 func metadataHeaderSuppressed() {
173 let html = render("#+TITLE: My Doc\n\nBody.", options: OrgRenderOptions(metadataHeader: false))
174 #expect(!html.contains("org-title"))
175 #expect(!html.contains("My Doc"))
176 #expect(html.contains("<p>Body.</p>"))
177 }
178
179 @Test
180 func headingLevelOffsetShiftsAndClamps() {
181 let base = render("* Top\n\n** Sub")
182 #expect(base.contains("<h1>Top</h1>"))
183 #expect(base.contains("<h2>Sub</h2>"))
184
185 let shifted = render("* Top\n\n** Sub", options: OrgRenderOptions(headingLevelOffset: 1))
186 #expect(shifted.contains("<h2>Top</h2>"))
187 #expect(shifted.contains("<h3>Sub</h3>"))
188
189 // A six-star heading with a +1 offset clamps to h6, never h7.
190 let deep = render("****** Deep", options: OrgRenderOptions(headingLevelOffset: 1))
191 #expect(deep.contains("<h6>Deep</h6>"))
192 #expect(!deep.contains("<h7"))
193 }
164194 }