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

html library org-mode swift

Commit 59a47caccb

59a47caccb2c9ca074c4a5369ea29c987d96d63c

parent: 542fdaea80

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-29T01:26:23Z

AST prototype: reconcile render options with the shipped renderer

OrgHTMLTreeRenderer now takes the same OrgRenderOptions and implements all of
it — metadata header, heading offset, repository-relative resolution and URL
sanitising. Resolution is simpler on the tree: targets stay typed, so it applies
to the .file case in the renderer instead of a closure threaded through the parse.

Adds equivalence tests against the shipped renderer (every corpus case, the
option combinations, URL resolution, unsafe schemes, and a 28-construct battery
from the shipped renderer's own inputs). That battery found three gaps, now
closed: #+CAPTION/#+NAME wrapping a non-image block in <figure class=org-block>
via a new .captioned element, the nested [[dest][[img]]] badge form, and bare
email autolinks.

One intentional difference is documented and asserted rather than matched: the
tree joins inactive timestamp ranges ([a]--[b]) as orgo does, where the shipped
renderer leaves --.
AST-PROTOTYPE.md +35 −9
@@ -78,19 +78,45 @@ to a particular output:
7878 - `OrgTimestamp` carries `endTime` and `endDate`, so a range is *one* timestamp with an end —
7979 matching how orgo models it — rather than two stamps with punctuation between them.
8080
81## What a migration would look like
81## Options are reconciled — the swap is ready
8282
83The conformance gap is closed, so the remaining steps are integration rather than parsing:
83`OrgHTMLTreeRenderer` now takes the same `OrgRenderOptions` the shipped renderer takes, and
84implements all of it: `metadataHeader`, `headingLevelOffset`, repository-relative resolution
85(`host`, `owner`, `repositoryName`, `ref`, `readmePath`, `imagePathSegment`/`linkPathSegment`)
86and URL sanitising.
87
88Resolution came out *simpler* on the tree. Because targets stay typed — `.external`, `.file`,
89`.id` — it applies to the `.file` case in the renderer, so no resolver closure has to be
90threaded through the parse. An unsafe or unresolvable target degrades to its text instead of
91becoming a bad anchor, as before.
92
93`ASTShippedEquivalenceTests` is the evidence, comparing shipped against tree by semantic
94skeleton (which keeps `href`/`src`, so resolved URLs are genuinely checked):
95
96 every corpus case, in the corpus configuration;
97 a source exercising the options, under repository options, with metadata off, and with no
98 repository context at all;
99 identical URL resolution (image → `raw`, link → `blob`);
100 unsafe schemes rejected by both;
101 a 28-construct battery drawn from the shipped renderer's own test inputs.
102
103That battery found three real gaps, since closed: `#+CAPTION:`/`#+NAME:` on a non-image block
104now wraps it in `<figure class="org-block">` (a new `.captioned` element), the nested
105`[[dest][[img]]]` badge form parses again, and bare email addresses autolink.
106
107**One intentional difference remains.** A range whose halves are *inactive* timestamps,
108`[a]--[b]`, is joined by the tree and left as `--` by the shipped renderer. orgo joins both
109bracket kinds, so the tree is the more correct of the two; it is asserted directly in
110`joinsInactiveTimestampRangesWhereShippedDoesNot` rather than degraded to match. It shows up
111in the corpus only inside `outofscope`'s LOGBOOK drawer.
112
113## Remaining steps
84114
85115 1. ~~Close the gaps until the tree renderer also scores 11/12.~~ **Done.**
862. Reconcile the render-time options the shipped renderer carries and this one does not yet:
87 `metadataHeader`, the repository-relative link/image resolution (`host`, `owner`,
88 `imagePathSegment`/`linkPathSegment`), and URL sanitising. The tree keeps link targets
89 typed (`.external` / `.file` / `.id`), so resolution becomes a renderer concern applied to
90 `.file` targets rather than a closure threaded through the parse — a simplification, but
91 it is the real work left before a swap.
1162. ~~Reconcile the render options.~~ **Done.**
92117 3. Point `OrgRenderer.renderToHTML` at `parse` + `OrgHTMLTreeRenderer` internally, keeping the
93 public API identical. The corpus test is the proof it is safe; consumers do not change.
118 public API identical — a one-line body change, with the equivalence tests as the proof it
119 is safe. Consumers do not change.
94120 4. Delete the single-pass renderer.
95121 5. Add `OrgSwiftUI` as a **separate product** depending on the core, so the parser and HTML
96122 renderer stay Foundation-only and consumers who want HTML never import SwiftUI.
Sources/OrgSwift/AST/OrgDocument.swift +4
@@ -45,6 +45,10 @@ public indirect enum OrgElement: Sendable, Equatable {
4545 case specialBlock(name: String, content: [OrgElement])
4646 case exportBlock(backend: String, raw: String)
4747 case figure(OrgFigure)
48 /// A block carrying an affiliated `#+CAPTION:` / `#+NAME:`, which org's exporter wraps in
49 /// a `<figure>`. Images take the `.figure` case instead; this is for everything else
50 /// (a captioned source block, example, or table).
51 case captioned(name: String?, caption: [OrgObject]?, content: OrgElement)
4852 case horizontalRule
4953 case footnoteDefinition(label: String, content: [OrgObject])
5054 }
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +75 −21
@@ -6,27 +6,68 @@ import Foundation
66 /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second
77 /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it.
88 public struct OrgHTMLTreeRenderer: Sendable {
9 public var headingLevelOffset: Int
9 /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
10 public var options: OrgRenderOptions
1011 public var highlighter: CodeHighlighter
1112
12 public init(headingLevelOffset: Int = 0, highlighter: CodeHighlighter = PlainCodeHighlighter()) {
13 self.headingLevelOffset = headingLevelOffset
13 public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) {
14 self.options = options
1415 self.highlighter = highlighter
1516 }
1617
1718 public func render(_ document: OrgDocument) -> String {
1819 var footnotes = FootnoteNumbering(document: document)
19 var html = document.elements.map { element( $0, &footnotes) }.joined()
20 var html = metadataHeader(document)
21 html += document.elements.map { element($0, &footnotes) }.joined()
2022 html += footnotes.renderSection(self)
2123 return html
2224 }
2325
26 /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats
27 /// these as document metadata carried by a page template, so the corpus renders with this
28 /// off; an app showing a README title turns it on.
29 private func metadataHeader(_ document: OrgDocument) -> String {
30 guard options.metadataHeader else { return "" }
31 let title = document.keyword("title")
32 let author = document.keyword("author")
33 let date = document.keyword("date")
34 guard title != nil || author != nil || date != nil else { return "" }
35
36 var html = "<div class=\"org-metadata\">\n"
37 if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" }
38 if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" }
39 if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" }
40 return html + "</div>\n"
41 }
42
43 // MARK: - URL resolution
44
45 /// Resolve a link target to a safe `href`, or nil when it cannot be made safe.
46 ///
47 /// Because the tree keeps targets typed, resolution is a renderer concern applied to the
48 /// `.file` case only no resolver closure has to be threaded through the parse.
49 private func href(for target: OrgLinkTarget) -> String? {
50 switch target {
51 case .id(let identifier):
52 return sanitizedReadmeLinkURLString("#\(identifier)")
53 case .external(let url):
54 return sanitizedReadmeLinkURLString(url)
55 case .file(let path):
56 return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
57 }
58 }
59
60 /// Resolve an image source to a safe `src`, or nil when it cannot be made safe.
61 private func imageSource(_ source: String) -> String? {
62 sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
63 }
64
2465 // MARK: - Blocks
2566
2667 private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
2768 switch element {
2869 case .heading(let heading):
29 let level = min(6, max(1, heading.level + headingLevelOffset))
70 let level = min(6, max(1, heading.level + options.headingLevelOffset))
3071 var inner = ""
3172 if let todo = heading.todo {
3273 inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "#
@@ -75,12 +116,15 @@ public struct OrgHTMLTreeRenderer: Sendable {
75116 return backend == "html" ? raw + "\n" : ""
76117
77118 case .figure(let figure):
119 guard let tag = imageTag(figure, caption: figure.caption) else {
120 return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n"
121 }
78122 // A bare image is a paragraph; a caption or explicit attributes promote it to a
79123 // <figure>, matching org's exporter.
80124 guard figure.caption != nil || !figure.attributes.isEmpty else {
81 return "<p>" + imageTag(figure, caption: nil, &notes) + "</p>\n"
125 return "<p>" + tag + "</p>\n"
82126 }
83 var html = "<figure>" + imageTag(figure, caption: figure.caption, &notes)
127 var html = "<figure>" + tag
84128 if let caption = figure.caption {
85129 notes.figureNumber += 1
86130 html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"#
@@ -88,6 +132,15 @@ public struct OrgHTMLTreeRenderer: Sendable {
88132 }
89133 return html + "</figure>\n"
90134
135 case .captioned(let name, let caption, let content):
136 let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
137 var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n"
138 html += self.element(content, &notes)
139 if let caption {
140 html += "<figcaption>" + renderInline(caption, &notes) + "</figcaption>\n"
141 }
142 return html + "</figure>\n"
143
91144 case .horizontalRule:
92145 return "<hr>\n"
93146
@@ -162,9 +215,12 @@ public struct OrgHTMLTreeRenderer: Sendable {
162215 return html + "</table>\n"
163216 }
164217
165 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?, _ notes: inout FootnoteNumbering) -> String {
218 /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL
219 /// in which case the caller falls back to text rather than pointing at something unsafe.
220 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
221 guard let source = imageSource(figure.source) else { return nil }
166222 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
167 var html = #"<img src="\#(escapeHTMLAttribute(figure.source))" alt="\#(escapeHTMLAttribute(alt))""#
223 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""#
168224 for (key, value) in figure.attributes where key != "alt" {
169225 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
170226 }
@@ -185,7 +241,8 @@ public struct OrgHTMLTreeRenderer: Sendable {
185241 case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>"
186242 case .superscript(let children): html += "<sup>" + renderInline(children, &notes) + "</sup>"
187243 case .lineBreak: html += "<br>"
188 case .image(let figure): html += imageTag(figure, caption: nil, &notes)
244 case .image(let figure):
245 html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source)
189246 case .timestamp(let stamp):
190247 let cssClass = stamp.active ? "timestamp" : "timestamp inactive"
191248 func time(_ machine: String, _ display: String) -> String {
@@ -200,22 +257,19 @@ public struct OrgHTMLTreeRenderer: Sendable {
200257 let number = notes.number(for: label, inline: inline)
201258 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
202259 case .link(let link):
203 let href = escapeHTMLAttribute(hrefValue(link.target))
204 let text = link.description.map { renderInline($0, &notes) } ?? escapeHTML(displayValue(link.target))
205 html += #"<a href="\#(href)">\#(text)</a>"#
260 let text = link.description.map { renderInline($0, &notes) }
261 ?? escapeHTML(displayValue(link.target))
262 // An unsafe or unresolvable target degrades to its text, never a bad anchor.
263 if let href = href(for: link.target) {
264 html += #"<a href="\#(href)">\#(text)</a>"#
265 } else {
266 html += text
267 }
206268 }
207269 }
208270 return html
209271 }
210272
211 private func hrefValue(_ target: OrgLinkTarget) -> String {
212 switch target {
213 case .external(let url): return url
214 case .file(let path): return path
215 case .id(let identifier): return "#\(identifier)"
216 }
217 }
218
219273 private func displayValue(_ target: OrgLinkTarget) -> String {
220274 switch target {
221275 case .external(let url): return url
Sources/OrgSwift/AST/OrgInlineParser.swift +37 −5
@@ -74,6 +74,15 @@ extension OrgParser {
7474 continue
7575 }
7676
77 // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token.
78 if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]),
79 let email = scanEmail(chars, from: i) {
80 flushPlain()
81 objects.append(email.object)
82 i = email.next
83 continue
84 }
85
7786 // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~
7887 if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i),
7988 let span = scanEmphasis(chars, from: i, marker: chars[i]) {
@@ -183,13 +192,18 @@ extension OrgParser {
183192 private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject {
184193 let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget
185194
186 // A description that is itself an image makes the image the link's content.
195 // A description that is itself an image makes the image the link's content the
196 // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any
197 // balanced brackets before deciding.
187198 if let description {
188 let inner = description.hasPrefix("[[") && description.hasSuffix("]]")
189 ? String(description.dropFirst(2).dropLast(2)) : description
199 var inner = description
200 while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 {
201 inner = String(inner.dropFirst().dropLast())
202 }
203 let wasWrapped = inner != description
190204 let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner
191 if isImagePath(imageSource), inner != description || inner.hasPrefix("file:")
192 || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
205 if isImagePath(imageSource),
206 wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
193207 return .link(OrgLink(target: linkTarget(target),
194208 description: [.image(OrgFigure(source: imageSource))]))
195209 }
@@ -272,6 +286,24 @@ extension OrgParser {
272286 return (.link(OrgLink(target: .external(url), description: nil)), start + url.count)
273287 }
274288
289 /// Characters that keep an email from starting here, mirroring the shipped renderer's
290 /// `(?<![\w.%+\-])` guard.
291 private static func isEmailBoundaryCharacter(_ c: Character) -> Bool {
292 c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-"
293 }
294
295 private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
296 let rest = String(chars[start...])
297 guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
298 return nil
299 }
300 let address = String(rest[match.range])
301 // A trailing character that cannot end an address belongs to the sentence, not the link.
302 guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil }
303 return (.link(OrgLink(target: .external("mailto:\(address)"),
304 description: [.text(address)])), start + address.count)
305 }
306
275307 private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? {
276308 var j = start + 1
277309 guard j < chars.count else { return nil }
Sources/OrgSwift/AST/OrgParser.swift +19 −2
@@ -15,13 +15,29 @@ public enum OrgParser {
1515 .components(separatedBy: "\n")
1616 var index = 0
1717 var pendingCaption: String?
18 var pendingName: String?
1819 var pendingAttrs: [(key: String, value: String)] = []
1920
2021 func flushPending() {
2122 pendingCaption = nil
23 pendingName = nil
2224 pendingAttrs = []
2325 }
2426
27 /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or
28 /// `#+NAME:` precedes it org's exporter turns that pairing into a `<figure>`.
29 func append(_ element: OrgElement) {
30 if pendingCaption != nil || pendingName != nil {
31 document.elements.append(.captioned(
32 name: pendingName,
33 caption: pendingCaption.map(parseInline),
34 content: element
35 ))
36 } else {
37 document.elements.append(element)
38 }
39 }
40
2541 while index < lines.count {
2642 let line = lines[index]
2743 let trimmed = line.trimmingCharacters(in: .whitespaces)
@@ -46,6 +62,7 @@ public enum OrgParser {
4662 if let directive = orgKeywordDirective(in: trimmed) {
4763 switch directive.keyword {
4864 case "caption": pendingCaption = directive.value
65 case "name": pendingName = directive.value
4966 case "attr_html": pendingAttrs = parseAttributes(directive.value)
5067 default: document.keywords.append((directive.keyword, directive.value))
5168 }
@@ -56,7 +73,7 @@ public enum OrgParser {
5673 // Blocks: #+begin_ / #+end_
5774 if trimmed.lowercased().hasPrefix("#+begin_") {
5875 let (element, next) = parseBlock(lines, from: index)
59 if let element { document.elements.append(element) }
76 if let element { append(element) }
6077 index = next
6178 flushPending()
6279 continue
@@ -101,7 +118,7 @@ public enum OrgParser {
101118 // Table.
102119 if isTableLine(trimmed) {
103120 let (table, next) = parseTable(lines, from: index)
104 document.elements.append(.table(table))
121 append(.table(table))
105122 index = next
106123 flushPending()
107124 continue
Tests/OrgSwiftTests/ASTTests.swift +206 −1
@@ -232,7 +232,11 @@ struct ASTConformanceTests {
232232 print("org-conformance corpus not found — skipping")
233233 return
234234 }
235 let renderer = OrgHTMLTreeRenderer(headingLevelOffset: 1)
235 // The same configuration the shipped renderer is measured in: body content only,
236 // with `*` rendering as <h2> because orgo's title owns <h1>.
237 let renderer = OrgHTMLTreeRenderer(
238 options: OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)
239 )
236240 var matched: [String] = []
237241 var diverged: [String] = []
238242
@@ -266,6 +270,207 @@ struct ASTConformanceTests {
266270 }
267271 }
268272
273/// Equivalence with the shipped renderer under the render options, which the corpus does not
274/// exercise (it renders with no repository context). This is the evidence that pointing
275/// `OrgRenderer.renderToHTML` at the tree would be a no-op for callers.
276///
277/// Comparison is by semantic skeleton, which keeps `href` and `src` so resolved URLs are
278/// actually checked while ignoring the class-name and whitespace differences that carry no
279/// meaning.
280struct ASTShippedEquivalenceTests {
281
282 /// gitbay's configuration: images resolve against `raw`, links against `blob`.
283 private static let repositoryOptions = OrgRenderOptions(
284 host: "gitbay.org",
285 owner: "krz",
286 repositoryName: "gitbay",
287 ref: "HEAD",
288 readmePath: "README.org",
289 metadataHeader: true,
290 headingLevelOffset: 0,
291 imagePathSegment: "raw",
292 linkPathSegment: "blob"
293 )
294
295 private static let source = """
296 #+TITLE: My Doc
297 #+AUTHOR: Someone
298 #+DATE: 2026-08-28
299
300 * Heading
301
302 A paragraph with *bold*, ~code~, and a bare URL https://example.com here.
303
304 A relative link to [[docs/DESIGN.org][the design]] and an absolute one to
305 [[https://example.org][elsewhere]], plus an in-page [[id:anchor]] link.
306
307 [[file:docs/logo.png]]
308
309 | Name | Score |
310 |:------+------:|
311 | alpha | 10 |
312 """
313
314 private func skeletons(_ options: OrgRenderOptions) -> (shipped: [String], tree: [String]) {
315 let shipped = OrgRenderer.renderToHTML(Self.source, options: options)
316 let tree = OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(Self.source))
317 return (OrgSkeleton.skeleton(shipped), OrgSkeleton.skeleton(tree))
318 }
319
320 @Test
321 func agreesWithShippedRendererUnderRepositoryOptions() {
322 let (shipped, tree) = skeletons(Self.repositoryOptions)
323 #expect(tree == shipped, firstDifference(shipped, tree))
324 }
325
326 @Test
327 func agreesWithShippedRendererWithMetadataOff() {
328 var options = Self.repositoryOptions
329 options.metadataHeader = false
330 options.headingLevelOffset = 1
331 let (shipped, tree) = skeletons(options)
332 #expect(tree == shipped, firstDifference(shipped, tree))
333 }
334
335 @Test
336 func agreesWithShippedRendererWithNoRepositoryContext() {
337 // owner/repo nil disables resolution: relative targets must survive untouched in both.
338 let (shipped, tree) = skeletons(OrgRenderOptions())
339 #expect(tree == shipped, firstDifference(shipped, tree))
340 }
341
342 /// Both renderers must resolve repository-relative targets to the same URLs, against the
343 /// segment each kind belongs to.
344 @Test
345 func resolvesRelativeURLsIdenticallyToShipped() {
346 let shipped = OrgRenderer.renderToHTML(Self.source, options: Self.repositoryOptions)
347 let tree = OrgHTMLTreeRenderer(options: Self.repositoryOptions).render(OrgParser.parse(Self.source))
348 for expected in [
349 "https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png", // image raw
350 "https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org", // link blob
351 "https://example.org",
352 ] {
353 #expect(shipped.contains(expected), "shipped lost \(expected)")
354 #expect(tree.contains(expected), "tree lost \(expected)")
355 }
356 }
357
358 /// An unsafe scheme must not become an anchor in either renderer.
359 @Test
360 func rejectsUnsafeSchemesLikeShipped() {
361 let hostile = "[[javascript:alert(1)][click]]"
362 let shipped = OrgRenderer.renderToHTML(hostile)
363 let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(hostile))
364 #expect(!shipped.lowercased().contains("javascript:"))
365 #expect(!tree.lowercased().contains("javascript:"))
366 #expect(tree.contains("click"))
367 }
368
369 /// Shipped and tree must agree on every corpus case, so swapping does not change what a
370 /// consumer renders including `outofscope`, where both diverge from orgo but must still
371 /// diverge the same way.
372 ///
373 /// One documented exception: `outofscope` contains a LOGBOOK clock entry whose halves are
374 /// *inactive* timestamps, `[]--[]`. The tree joins that into one range (orgo does the
375 /// same `try_timestamp` applies the `--` rule to both bracket kinds, requiring only that
376 /// the halves agree on activeness), while the shipped renderer joins active ranges only
377 /// and leaves `--` as text. The tree is the more correct of the two, so this is recorded
378 /// as the single intentional behaviour change a swap would introduce rather than degraded
379 /// to match.
380 @Test
381 func agreesWithShippedRendererAcrossEveryCorpusCase() throws {
382 guard let dir = astCorpusCasesDir() else {
383 print("org-conformance corpus not found — skipping")
384 return
385 }
386 let knownDifferences = ["outofscope"]
387 let options = OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)
388 var mismatched: [String] = []
389 for name in astCaseNames(dir) where !knownDifferences.contains(name) {
390 let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? ""
391 let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source, options: options))
392 let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(source)))
393 if shipped != tree {
394 mismatched.append("\(name) [\(firstDifference(shipped, tree))]")
395 }
396 }
397 #expect(mismatched.isEmpty, "shipped and tree disagree on: \(mismatched.joined(separator: "; "))")
398 }
399
400 /// The inactive-range difference above, asserted directly so it cannot change unnoticed.
401 @Test
402 func joinsInactiveTimestampRangesWhereShippedDoesNot() {
403 let source = "CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]"
404 let shipped = OrgRenderer.renderToHTML(source)
405 let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(source))
406 #expect(shipped.contains("--"))
407 #expect(!tree.contains("--"))
408 #expect(tree.contains("&#8211;"))
409 }
410
411 /// A battery drawn from the shipped renderer's own test inputs, to catch behaviour the
412 /// corpus does not reach. Every case must render identically, or the swap would regress a
413 /// consumer.
414 @Test
415 func agreesWithShippedRendererAcrossConstructBattery() {
416 let cases: [(name: String, source: String)] = [
417 ("comment", "# This is a comment\nNormal text"),
418 ("strike", "+deleted text+"),
419 ("example", "#+begin_example\nhello world\n#+end_example"),
420 ("bare image link", "[[https://example.com/path/to/file.png]]"),
421 ("wrapped bullet", "- First line\n continues here"),
422 ("directives", "#+OPTIONS: toc:nil\n#+PROPERTY: header-args :results output\nBody text"),
423 ("verse", "#+begin_verse\nThere is a line.\n And an indented line.\n#+end_verse"),
424 ("named block", "#+CAPTION: Build output\n#+NAME: build-log\n#+begin_example\nhello world\n#+end_example"),
425 ("linked image", "[[https://example.com][[https://img.example.net/a.jpg]]]"),
426 ("table alignment", "| Left | Center | Right |\n|:-----+:-----:+------:|\n| a | b | c |"),
427 ("nested list", "- Bullet with nested list\n - Nested child one\n - Nested child two"),
428 ("timestamps", "An active <2024-01-15 Mon> and inactive [2024-01-15 Mon]."),
429 ("time", "At <2024-01-15 Mon 10:30>."),
430 ("same-day range", "Range <2024-01-15 Mon 10:00-11:45>."),
431 ("multi-day range", "Span <2024-01-15 Mon>--<2024-01-20 Sat>."),
432 ("repeater", "Repeats <2024-01-15 Mon +1w>."),
433 ("non-timestamp", "Compare 3 < 4 and [not a stamp]."),
434 ("inline footnote", "See here.[fn:x:defined inline]"),
435 ("footnotes", "A claim.[fn:1] Another.[fn:2]\n\n[fn:1] First note.\n[fn:2] Second with /emphasis/."),
436 ("heading tags", "* Write the parser :work:rust:"),
437 ("bare url", "See https://example.com now."),
438 ("email", "Mail someone@example.com today."),
439 ("deep heading", "****** Deep"),
440 ("checkboxes", "- [ ] todo\n- [X] done\n- [-] partial"),
441 ("description list", "- term one :: the first definition"),
442 ("quote", "#+begin_quote\nA quoted paragraph.\n#+end_quote"),
443 ("src", "#+begin_src swift\nlet x = 1\n#+end_src"),
444 ("superscript", "N^2 and H^{2O}."),
445 ]
446
447 var mismatched: [String] = []
448 for (name, source) in cases {
449 let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source))
450 let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer().render(OrgParser.parse(source)))
451 if shipped != tree {
452 mismatched.append(name)
453 if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil {
454 print("BATTERY-DIFF \(name): \(firstDifference(shipped, tree))")
455 }
456 }
457 }
458 #expect(mismatched.isEmpty, "tree diverges from shipped on: \(mismatched.joined(separator: ", "))")
459 }
460
461 private func firstDifference(_ shipped: [String], _ tree: [String]) -> Comment {
462 let index = (0..<max(shipped.count, tree.count)).first {
463 ($0 < shipped.count ? shipped[$0] : "") != ($0 < tree.count ? tree[$0] : "")
464 }
465 guard let index else { return "identical" }
466 return """
467 diverges at \(index): \
468 shipped=\(index < shipped.count ? shipped[index] : "") \
469 tree=\(index < tree.count ? tree[index] : "")
470 """
471 }
472}
473
269474 private func astCorpusCasesDir() -> URL? {
270475 if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
271476 let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")