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

html library org-mode swift

Commit 120040d8c7

120040d8c75be00c0a167f549cafecad9a6a0c50

parent: 59a47caccb

Verified · cmc

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

Swap in the tree renderer and delete the single-pass one

OrgRenderer.renderToHTML is now a façade over OrgParser.parse plus
OrgHTMLTreeRenderer; the public API is unchanged, so consumers do not move.

Deletes the single-pass renderer it replaces — orgToHTML and its inline,
list, table, footnote and timestamp machinery — keeping the helpers the tree
parser shares (escaping, URL sanitising and resolution, line predicates).
Net ~1350 lines removed against ~1480 added for the tree.

The migration-scaffolding equivalence tests, now tautological, are replaced by
direct assertions on the render options: URL resolution per segment, relative
targets without repository context, the metadata header, unsafe schemes, and the
one intentional behaviour change (inactive timestamp ranges now join, as orgo
does). AST-PROTOTYPE.md becomes ARCHITECTURE.md.
ARCHITECTURE.md added +79
@@ -0,0 +1,79 @@
1# Architecture
2
3OrgSwift parses org into an element tree and renders that tree. Parsing happens once; each
4output format is a walk over the result.
5
6```
7Sources/OrgSwift/
8 AST/
9 OrgDocument.swift the element tree
10 OrgParser.swift source → OrgDocument (blocks)
11 OrgInlineParser.swift text → [OrgObject] (inlines)
12 OrgHTMLTreeRenderer.swift OrgDocument → HTML
13 OrgAttributedStringRenderer.swift [OrgObject] → AttributedString
14 OrgRenderer.swift the public entry point, plus OrgRenderOptions
15 Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and
16 repository-relative resolution, line predicates
17 Skeleton.swift the HTML → semantic-skeleton reduction used by tests
18```
19
20`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`.
21It, `OrgRenderOptions`, and `CodeHighlighter` are the whole public API most callers need.
22
23## Why a tree
24
25The renderer this replaced went from source straight to an HTML string in a single pass,
26doing inline work by regex-substituting markup into escaped text and protecting the results
27with placeholder tokens. That worked, but it baked HTML into the parse: any second output
28format would have meant re-deriving the parse rather than reusing it.
29
30With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse
31entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI,
32anywhere; a SwiftUI layer would sit on top of it for the inline runs inside each block.
33
34The types mirror orgo's `model.rs``OrgElement`/`OrgObject` against orgo's
35`Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same
36`ListKind`/`Checkbox` vocabulary. That makes future *tree-level* conformance possible: today
37the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy for
38"do these two agree on structure", and matching trees would let that question be asked
39directly.
40
41Resolution is simpler on a tree, too. Link targets stay typed — `.external`, `.file`, `.id`
42— so repository-relative resolution applies to the `.file` case in the renderer instead of a
43resolver closure threaded through the parse. An unsafe or unresolvable target degrades to its
44text rather than becoming a bad anchor.
45
46## Conformance
47
48Measured against the [org-conformance](../org-conformance) corpus, whose goldens come from
49orgo (itself diffed against Emacs `ox-html`), reduced by the shared skeleton algorithm:
50
51**11 / 12** — every case except `outofscope`, which is `scope: out` in the corpus
52(deliberately unsupported constructs, where orgo itself may differ).
53
54`ConformanceTests` gates this: a regression fails, and `outofscope` starting to match fails
55too, forcing the record in its expectations map to be updated. `GAPS.md` carries the detail.
56
57## Notes from the migration
58
59The tree renderer replaced the single-pass one only after it was shown to be equivalent:
60every corpus case, each option combination, identical URL resolution, unsafe-scheme
61rejection, and a 28-construct battery drawn from the old renderer's own test inputs. That
62battery earned its keep — it caught three behaviours the corpus never reaches
63(`#+CAPTION:`/`#+NAME:` wrapping a non-image block in `<figure class="org-block">`, the
64nested `[[dest][[img]]]` badge form, and bare email autolinks), each of which would otherwise
65have regressed a consumer.
66
67One behaviour changed on purpose. A range whose halves are *inactive* timestamps, `[a]--[b]`,
68is now joined into one range; the old renderer joined active ranges only and left `--` as
69text. orgo applies the `--` rule to both bracket kinds, requiring only that the halves agree
70on activeness, so the new behaviour is the more correct one. It is asserted directly in
71`joinsInactiveTimestampRanges`.
72
73## Next
74
75A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and
76HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are
77the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a horizontal
78`ScrollView` for phone-width overflow — the approach MarkdownUI uses. `OrgTable` already
79carries the rows, the rule position, and the per-column alignments that needs.
AST-PROTOTYPE.md deleted −127
@@ -1,127 +0,0 @@
1# AST split — prototype
2
3A working prototype of `source → OrgDocument → {renderers}`, on the `ast-prototype` branch.
4Nothing shipped changes: `OrgRenderer` (the single-pass source→HTML renderer hutch and
5gitbay-ios use) is untouched, and its 11/12 corpus conformance is unaffected. This is a
6parallel pipeline built to answer one question — *is a second output format a walk over a
7tree, or a second parser?*
8
9## What's here
10
11```
12Sources/OrgSwift/AST/
13 OrgDocument.swift the element tree
14 OrgParser.swift source → OrgDocument (blocks)
15 OrgInlineParser.swift text → [OrgObject] (inlines)
16 OrgHTMLTreeRenderer.swift OrgDocument → HTML
17 OrgAttributedStringRenderer.swift [OrgObject] → AttributedString
18```
19
20The types deliberately mirror orgo's `model.rs``OrgElement`/`OrgObject` against orgo's
21`Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same
22`ListKind`/`Checkbox` vocabulary. That is what makes future *tree-level* conformance possible:
23today the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy
24for "do these two agree on structure". With matching trees, that question can be asked
25directly.
26
27## The result
28
29Two renderers, one parse. `OrgAttributedStringRenderer` is **Foundation-only** — no SwiftUI —
30so it works server-side, in a CLI, anywhere, and a SwiftUI block renderer would sit on top of
31it for the inline runs inside each block.
32
33```swift
34let document = OrgParser.parse(source)
35let html = OrgHTMLTreeRenderer().render(document) // markup
36let text = OrgAttributedStringRenderer().inline(objects) // native, real attributes
37```
38
39The AttributedString path carries `inlinePresentationIntent` (`.stronglyEmphasized`,
40`.emphasized`, `.code`) and real `link` attributes — no markup in the string. Constructs
41`AttributedString` has no portable attribute for (superscript, timestamps, footnote refs,
42images, underline, strikethrough — the last two live only in the UIKit/AppKit scopes) travel
43as an `OrgRole` custom attribute the UI layer reads to decide presentation, so the renderer
44never needs to know about fonts or colors.
45
46## Conformance scorecard
47
48The tree-based HTML renderer, measured against the same `org-conformance` corpus and the same
49skeleton reduction the shipped renderer is held to:
50
51**11 / 12 — parity with the shipped renderer.** Everything matches except `outofscope`, which
52is `scope: out` in the corpus (deliberately unsupported constructs, where orgo itself may
53differ); the shipped renderer diverges there too.
54
55Because it is at parity, `ASTConformanceTests` now **asserts** rather than reports: a
56regression fails, and `outofscope` starting to match fails as well, forcing the record to be
57updated. Run with `ORG_DUMP=1` to print the first divergence per case.
58
59Getting from the first draft (8/12) to parity took three fixes, all small parser work rather
60than anything architectural — which is the useful signal about where the remaining effort
61sits:
62
63| Case | Was | Fix |
64|---|---|---|
65| `core` | an ordered list and the bullet list after it merged into one list | stop a list at a top-level marker of the other kind |
66| `footnote` | inline footnotes (`[fn:x:text]`) dropped their text | carry it on the reference; the notes section renders inline definitions without the `<p>` a reference-style definition gets |
67| `timestamps` | ranges rendered one `<time>`, losing the end | model an end (`endTime` same-day, `endDate` multi-day) and emit two `<time>` joined by an en-dash, as org exports it |
68
69Two more were closed earlier, each ~10 lines: property drawers (`:PROPERTIES:``:END:`) are
70dropped as heading metadata, and a bare image renders as `<p><img></p>` while a caption or
71`#+ATTR_HTML` promotes it to `<figure>`.
72
73Two model refinements came out of this, both making the tree more faithful to org rather than
74to a particular output:
75
76- `OrgObject.footnoteRef` carries `inline: [OrgObject]?` instead of a placeholder `number`.
77 Numbering is document-wide, so it belongs to the renderer, not the parse.
78- `OrgTimestamp` carries `endTime` and `endDate`, so a range is *one* timestamp with an end —
79 matching how orgo models it — rather than two stamps with punctuation between them.
80
81## Options are reconciled — the swap is ready
82
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
114
1151. ~~Close the gaps until the tree renderer also scores 11/12.~~ **Done.**
1162. ~~Reconcile the render options.~~ **Done.**
1173. Point `OrgRenderer.renderToHTML` at `parse` + `OrgHTMLTreeRenderer` internally, keeping the
118 public API identical — a one-line body change, with the equivalence tests as the proof it
119 is safe. Consumers do not change.
1204. Delete the single-pass renderer.
1215. Add `OrgSwiftUI` as a **separate product** depending on the core, so the parser and HTML
122 renderer stay Foundation-only and consumers who want HTML never import SwiftUI.
123
124Step 5 is where tables get built: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a
125horizontal `ScrollView` for phone-width overflow — the approach MarkdownUI uses, and the
126`OrgTable` node already carries the rows, the rule position, and per-column alignments it
127needs.
README.md +4 −2
@@ -4,8 +4,10 @@ A dependency-free Swift library that renders a practical subset of
44 [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no
55 SwiftUI, WebKit, UIKit, or third-party packages.
66
7Extracted from the hand-rolled renderer in the Hutch iOS client so it can be
8shared across apps.
7Originally extracted from the hand-rolled renderer in the Hutch iOS client so it
8could be shared across apps, and since rebuilt around an element tree: org is
9parsed once into a document model, and each output format walks it. See
10[ARCHITECTURE.md](ARCHITECTURE.md).
911
1012 ## Supported syntax
1113
Sources/OrgSwift/Escaping.swift −46
@@ -80,49 +80,3 @@ func matchesRegex(_ text: String, pattern: String) -> Bool {
8080 let range = NSRange(location: 0, length: (text as NSString).length)
8181 return regex.firstMatch(in: text, range: range) != nil
8282 }
83
84func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
85 guard range.location != NSNotFound else { return false }
86 let prefix = text.substring(to: range.location)
87 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
88 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
89 return lastOpen > lastClose
90}
91
92func protectMatches(
93 in text: String,
94 pattern: String,
95 protectedFragments: inout [String: String],
96 transform: (NSTextCheckingResult, NSString) -> String
97) -> String {
98 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
99 var result = text
100 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
101
102 for match in matches.reversed() {
103 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
104 let nsText = result as NSString
105 protectedFragments[token] = transform(match, nsText)
106 result = nsText.replacingCharacters(in: match.range, with: token)
107 }
108
109 return result
110}
111
112func replaceMatches(
113 in text: String,
114 pattern: String,
115 transform: (NSTextCheckingResult, NSString) -> String
116) -> String {
117 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
118 var result = text
119 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
120
121 for match in matches.reversed() {
122 let nsText = result as NSString
123 let replacement = transform(match, nsText)
124 result = nsText.replacingCharacters(in: match.range, with: replacement)
125 }
126
127 return result
128}
Sources/OrgSwift/Footnotes.swift −66
@@ -1,71 +1,5 @@
11 import Foundation
22
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
693 /// Detect a reference-style footnote definition line: `[fn:label] text`. Returns the
704 /// label and the definition text, or nil if the line is not one.
715 func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? {
Sources/OrgSwift/Inline.swift deleted −171
@@ -1,171 +0,0 @@
1import Foundation
2
3func processOrgInline(
4 _ text: String,
5 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil,
7 footnotes: FootnoteCollector? = nil
8) -> String {
9 var result = escapeHTML(text)
10 var protectedFragments: [String: String] = [:]
11
12 result = protectMatches(
13 in: result,
14 pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
15 protectedFragments: &protectedFragments
16 ) { match, nsText in
17 let destination = nsText.substring(with: match.range(at: 1))
18 let source = nsText.substring(with: match.range(at: 2))
19 guard let imageHTML = makeOrgImageHTML(
20 source: source,
21 alt: nil,
22 imageURLResolver: imageURLResolver
23 ) else {
24 return source
25 }
26 let resolvedDestination = linkURLResolver?(destination) ?? destination
27 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
28 return imageHTML
29 }
30 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
31 }
32
33 result = protectOrgLinks(
34 in: result,
35 protectedFragments: &protectedFragments,
36 imageURLResolver: imageURLResolver,
37 linkURLResolver: linkURLResolver
38 )
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
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
92 // Bare URLs in running text become links. Bracketed `[[]]` links are already
93 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is
94 // left outside the link.
95 result = protectMatches(
96 in: result,
97 pattern: #"https?://[^\s<>()\[\]]+"#,
98 protectedFragments: &protectedFragments
99 ) { match, nsText in
100 var url = nsText.substring(with: match.range)
101 var trailing = ""
102 while let last = url.last, ".,;:!?".contains(last) {
103 trailing = String(last) + trailing
104 url.removeLast()
105 }
106 guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing }
107 return #"<a href="\#(safe)">\#(url)</a>"# + trailing
108 }
109
110 result = protectMatches(
111 in: result,
112 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
113 protectedFragments: &protectedFragments
114 ) { match, nsText in
115 let tildeRange = match.range(at: 1)
116 let equalsRange = match.range(at: 2)
117 let codeText: String
118 if tildeRange.location != NSNotFound {
119 codeText = nsText.substring(with: tildeRange)
120 } else {
121 codeText = nsText.substring(with: equalsRange)
122 }
123 return "<code>\(codeText)</code>"
124 }
125 result = protectMatches(
126 in: result,
127 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
128 protectedFragments: &protectedFragments
129 ) { match, nsText in
130 let value = nsText.substring(with: match.range(at: 1))
131 return "<del>\(value)</del>"
132 }
133 result = protectMatches(
134 in: result,
135 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
136 protectedFragments: &protectedFragments
137 ) { match, nsText in
138 let value = nsText.substring(with: match.range(at: 1))
139 return "<u>\(value)</u>"
140 }
141
142 // Bold: *text*
143 result = result.replacingOccurrences(
144 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
145 with: "<strong>$1</strong>",
146 options: .regularExpression
147 )
148 // Italic: /text/
149 result = result.replacingOccurrences(
150 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
151 with: "<em>$1</em>",
152 options: .regularExpression
153 )
154 result = replaceMatches(
155 in: result,
156 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
157 ) { match, nsText in
158 guard !isInsideHTMLTag(nsText, range: match.range) else {
159 return nsText.substring(with: match.range)
160 }
161 let email = nsText.substring(with: match.range(at: 1))
162 let href = escapeHTMLAttribute("mailto:\(email)")
163 return #"<a href="\#(href)">\#(email)</a>"#
164 }
165
166 for (token, fragment) in protectedFragments {
167 result = result.replacingOccurrences(of: token, with: fragment)
168 }
169
170 return result
171}
Sources/OrgSwift/Links.swift −210
@@ -1,154 +1,11 @@
11 import Foundation
22
3func protectOrgLinks(
4 in text: String,
5 protectedFragments: inout [String: String],
6 imageURLResolver: ((String) -> String?)? = nil,
7 linkURLResolver: ((String) -> String?)? = nil
8) -> String {
9 var result = text
10
11 while let range = result.range(of: "[[") {
12 guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
13 break
14 }
15 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
16 protectedFragments[token] = renderOrgLink(
17 destination: parsed.destination,
18 label: parsed.label,
19 imageURLResolver: imageURLResolver,
20 linkURLResolver: linkURLResolver
21 )
22 result.replaceSubrange(parsed.range, with: token)
23 }
24
25 return result
26}
27
28private func parseOrgLink(
29 in text: String,
30 from start: String.Index
31) -> (range: Range<String.Index>, destination: String, label: String?)? {
32 guard text[start...].hasPrefix("[[") else { return nil }
33
34 var index = text.index(start, offsetBy: 2)
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 }
42 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
43 }
44
45 let destination = String(text[index..<destinationEnd])
46 index = text.index(destinationEnd, offsetBy: 2)
47 let labelStart = index
48 var depth = 0
49
50 while index < text.endIndex {
51 if text[index...].hasPrefix("[[") {
52 depth += 1
53 index = text.index(index, offsetBy: 2)
54 continue
55 }
56 if text[index...].hasPrefix("]]") {
57 if depth == 0 {
58 let end = text.index(index, offsetBy: 2)
59 return (start..<end, destination, String(text[labelStart..<index]))
60 }
61 depth -= 1
62 index = text.index(index, offsetBy: 2)
63 continue
64 }
65 index = text.index(after: index)
66 }
67
68 return nil
69}
70
713 /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same
724 /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL.
735 func normalizeOrgLinkTarget(_ target: String) -> String {
746 target.hasPrefix("file:") ? String(target.dropFirst(5)) : target
757 }
768
77/// If a link *description* is itself an image reference `[[img]]`, a `file:` image, or an
78/// image URL return its source, so the image becomes the link's content. A bare relative
79/// string (`img.png`) is a plain text description, not an image.
80private func descriptionImageSource(_ label: String) -> String? {
81 if label.hasPrefix("[["), label.hasSuffix("]]") {
82 return String(label.dropFirst(2).dropLast(2))
83 }
84 if label.hasPrefix("file:") || label.hasPrefix("http://") || label.hasPrefix("https://") {
85 return label
86 }
87 return nil
88}
89
90private func renderOrgLink(
91 destination rawDestination: String,
92 label: String?,
93 imageURLResolver: ((String) -> String?)? = nil,
94 linkURLResolver: ((String) -> String?)? = nil
95) -> String {
96 // `id:` links target a heading by its :ID:/:CUSTOM_ID:; org exports them as an
97 // in-page fragment link, with the id itself as the text when there is no description.
98 if rawDestination.hasPrefix("id:") {
99 let id = String(rawDestination.dropFirst(3))
100 let text = label.map {
101 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
102 } ?? escapeHTML(id)
103 return ##"<a href="#\##(escapeHTMLAttribute(id))">\##(text)</a>"##
104 }
105 let destination = normalizeOrgLinkTarget(rawDestination)
106 // A description that is itself an image link (`[[url][file:badge.svg]]`, the common
107 // build-badge form, or the double-bracketed `[[url][[badge.svg]]]`) makes the image the
108 // clickable content of the link. A plain-text description like `img.png` stays text.
109 if let label, let source = descriptionImageSource(label),
110 let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
111 let resolvedDestination = linkURLResolver?(destination) ?? destination
112 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
113 return imageHTML
114 }
115 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
116 }
117
118 // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a
119 // link whose text happens to point at an image org renders it as a link, not an
120 // image, so only take the image path when there is no description.
121 if label == nil, let imageHTML = makeOrgImageHTML(
122 source: destination,
123 alt: nil,
124 imageURLResolver: imageURLResolver
125 ) {
126 return imageHTML
127 }
128
129 let resolvedDestination = linkURLResolver?(destination) ?? destination
130 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
131 return label ?? destination
132 }
133
134 let renderedLabel = label.map {
135 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
136 } ?? destination
137 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
138}
139
140func makeOrgImageHTML(
141 source rawSource: String,
142 alt: String?,
143 imageURLResolver: ((String) -> String?)?
144) -> String? {
145 let source = normalizeOrgLinkTarget(rawSource)
146 guard isRenderableImageSource(source) else { return nil }
147 let resolvedSource = imageURLResolver?(source) ?? source
148 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
149 let altText = escapeHTMLAttribute(alt ?? "")
150 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
151}
1529
15310 private func isRenderableImageSource(_ source: String) -> Bool {
15411 let lowercased = source.lowercased()
@@ -168,73 +25,6 @@ func standaloneOrgImage(in line: String) -> String? {
16825 return path
16926 }
17027
171/// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup
172/// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining
173/// `#+ATTR_HTML` pairs become attributes.
174func makeFigureImageHTML(
175 path: String,
176 caption: String?,
177 attrHtml: String?,
178 imageURLResolver: ((String) -> String?)?
179) -> String {
180 let resolved = imageURLResolver?(path) ?? path
181 let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path)
182 let attrs = attrHtml.map(parseAttrHtml) ?? []
183
184 let alt: String
185 if let caption {
186 alt = stripOrgEmphasis(caption)
187 } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value {
188 alt = attrAlt
189 } else {
190 alt = ""
191 }
192
193 var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""#
194 for (key, value) in attrs where key != "alt" {
195 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
196 }
197 html += ">"
198 return html
199}
200
201/// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`).
202private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] {
203 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
204 return []
205 }
206 let ns = value as NSString
207 var result: [(String, String)] = []
208 for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) {
209 let key = ns.substring(with: m.range(at: 1)).lowercased()
210 var v = ns.substring(with: m.range(at: 2))
211 if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") {
212 v = String(v.dropFirst().dropLast())
213 }
214 result.append((key, v))
215 }
216 return result
217}
218
219/// Strip paired org emphasis markers for plain-text uses like an image `alt`.
220private func stripOrgEmphasis(_ s: String) -> String {
221 guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else {
222 return s
223 }
224 var result = s
225 for _ in 0..<3 {
226 let ns = result as NSString
227 let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length))
228 if matches.isEmpty { break }
229 for m in matches.reversed() {
230 let inner = ns.substring(with: m.range(at: 2))
231 result = (result as NSString).replacingCharacters(in: m.range, with: inner)
232 }
233 }
234 return result
235}
236
237// MARK: - Relative link/image resolution
23828
23929 func resolveRepositoryLinkURL(
24030 _ source: String,
Sources/OrgSwift/Lists.swift +1 −172
@@ -1,188 +1,17 @@
11 import Foundation
22
3enum OrgListType: Equatable {
4 case unordered
5 case ordered
6}
7
83 func orderedListItem(in line: String) -> String? {
94 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
105 return String(match.2)
116 }
127
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)
32 }
33 }
34 if !current.isEmpty { items.append(current) }
35 guard let firstMarker = items.first?.first else { return "" }
36
37 if let dl = renderDescriptionList(items, inlineRenderer: inlineRenderer) { return dl }
38
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"
43 }
44 html += ordered ? "</ol>\n" : "</ul>\n"
45 return html
46}
47
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 = []
67 }
68
69 for line in outdented {
70 let trimmed = line.trimmingCharacters(in: .whitespaces)
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)
80 }
81 }
82 flushParagraph()
83
84 var body: String
85 if paragraphs.count <= 1 {
86 body = renderTaskListItem(paragraphs.first ?? "", inlineRenderer: inlineRenderer)
87 } else {
88 body = paragraphs.enumerated().map { index, para in
89 let rendered = index == 0 ? renderTaskListItem(para, inlineRenderer: inlineRenderer) : inlineRenderer(para)
90 return "<p>" + rendered + "</p>"
91 }.joined(separator: "\n")
92 }
93
94 if !sublistLines.isEmpty {
95 body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer)
96 }
97 return body
98}
99
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"
115 }
116 }
117 html += "</dl>\n"
118 return html
119}
120
1218 /// True if a line (already outdented to its level) starts a list item at column 0.
1229 func isListMarkerLine(_ line: String) -> Bool {
12310 line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil
12411 }
12512
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
13713 func isIndentedContinuationLine(_ line: String) -> Bool {
13814 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
13915 guard let first = line.first else { return false }
14016 return first == " " || first == "\t"
141}
142
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
151}
152
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)
161 }
162 return String(line[index...])
163}
164
165func renderTaskListItem(
166 _ text: String,
167 inlineRenderer: (String) -> String
168) -> String {
169 let trimmed = text.trimmingCharacters(in: .whitespaces)
170 guard trimmed.count >= 4 else {
171 return inlineRenderer(text)
172 }
173
174 let prefix = String(trimmed.prefix(4))
175 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
176
177 // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>.
178 switch prefix {
179 case "[ ] ":
180 return #"<code>[&nbsp;]</code> \#(inlineRenderer(remainder))"#
181 case "[x] ", "[X] ":
182 return #"<code>[X]</code> \#(inlineRenderer(remainder))"#
183 case "[-] ":
184 return #"<code>[-]</code> \#(inlineRenderer(remainder))"#
185 default:
186 return inlineRenderer(text)
187 }
188}
17}
\ No newline at end of file
Sources/OrgSwift/OrgRenderer.swift +2 −552
@@ -115,14 +115,8 @@ public enum OrgRenderer {
115115 options: OrgRenderOptions = .init(),
116116 highlighter: CodeHighlighter = PlainCodeHighlighter()
117117 ) -> String {
118 orgToHTML(
119 source,
120 highlighter: highlighter,
121 imageURLResolver: options.imageURLResolver(),
122 linkURLResolver: options.linkURLResolver(),
123 metadataHeader: options.metadataHeader,
124 headingLevelOffset: options.headingLevelOffset
125 )
118 OrgHTMLTreeRenderer(options: options, highlighter: highlighter)
119 .render(OrgParser.parse(source))
126120 }
127121 }
128122
@@ -138,547 +132,3 @@ func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) {
138132 let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty }
139133 return (String(match.1).trimmingCharacters(in: .whitespaces), tags)
140134 }
141
142func orgToHTML(
143 _ text: String,
144 highlighter: CodeHighlighter,
145 imageURLResolver: ((String) -> String?)? = nil,
146 linkURLResolver: ((String) -> String?)? = nil,
147 metadataHeader: Bool = true,
148 headingLevelOffset: Int = 0
149) -> String {
150 let normalizedText = text
151 .replacingOccurrences(of: "\r\n", with: "\n")
152 .replacingOccurrences(of: "\r", with: "\n")
153 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
154 var title: String?
155 var author: String?
156 var date: String?
157 let lines = rawLines.filter { line in
158 let trimmed = line.trimmingCharacters(in: .whitespaces)
159 guard let directive = orgKeywordDirective(in: trimmed) else {
160 return true
161 }
162 switch directive.keyword {
163 case "title":
164 title = directive.value
165 return false
166 case "author":
167 author = directive.value
168 return false
169 case "date":
170 date = directive.value
171 return false
172 default:
173 return true
174 }
175 }
176 var html = ""
177 var listType: OrgListType?
178 var inQuoteBlock = false
179 var inPropertyDrawer = false
180 var srcLanguage: String?
181 var srcLines: [String] = []
182 var inExampleBlock = false
183 var inCenterBlock = false
184 var inVerseBlock = false
185 var inExportBlock = false
186 var exportIsHTML = false
187 var exportLines: [String] = []
188 var inSpecialBlock = false
189 var specialBlockName = ""
190 var listBuffer: [String] = []
191 var pendingListBlanks: [String] = []
192 var paragraph: [String] = []
193 var tableRows: [[String]] = []
194 var propertyRows: [(String, String)] = []
195 var verseLines: [String] = []
196 var pendingBlockName: String?
197 var pendingBlockCaption: String?
198 var pendingAttrHtml: String?
199 var activeBlockCaption: String?
200 var isWrappingBlockFigure = false
201 var figureNumber = 0
202 let footnotes = FootnoteCollector()
203
204 func beginPendingBlockWrapperIfNeeded() {
205 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
206 let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
207 html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
208 activeBlockCaption = pendingBlockCaption
209 isWrappingBlockFigure = true
210 pendingBlockName = nil
211 pendingBlockCaption = nil
212 }
213
214 func closePendingBlockWrapper() {
215 guard isWrappingBlockFigure else { return }
216 if let activeBlockCaption {
217 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
218 }
219 html += "</figure>\n"
220 activeBlockCaption = nil
221 isWrappingBlockFigure = false
222 }
223
224 func flushParagraph() {
225 if !paragraph.isEmpty {
226 let normalizedParagraph = paragraph
227 .map { $0.trimmingCharacters(in: .whitespaces) }
228 .joined(separator: " ")
229 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) + "</p>\n"
230 paragraph = []
231 }
232 }
233
234 func closeList() {
235 if !listBuffer.isEmpty {
236 html += renderOrgList(listBuffer) {
237 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
238 }
239 }
240 listBuffer = []
241 pendingListBlanks = []
242 listType = nil
243 }
244
245 func flushTable() {
246 guard !tableRows.isEmpty else { return }
247 beginPendingBlockWrapperIfNeeded()
248 html += renderHTMLTable(
249 rows: tableRows,
250 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
251 )
252 closePendingBlockWrapper()
253 tableRows = []
254 }
255
256 func flushPropertyDrawer() {
257 // Property drawers are heading metadata, not body content. org's HTML exporter
258 // drops them (CUSTOM_ID becomes the heading's anchor); we drop them too rather
259 // than render a stray <dl>. The lines were still consumed above, so they never
260 // fall through to become a paragraph.
261 propertyRows = []
262 }
263
264 func closeQuoteBlock() {
265 if inQuoteBlock {
266 flushParagraph()
267 html += "</blockquote>\n"
268 inQuoteBlock = false
269 }
270 }
271
272 func closeSourceBlock() {
273 if let language = srcLanguage {
274 let code = srcLines.joined(separator: "\n")
275 if !code.isEmpty {
276 let highlighted = highlighter.highlightedHTML(code: code, language: language.isEmpty ? nil : language)
277 html += (highlighted ?? escapeHTML(code)) + "\n"
278 }
279 html += "</code></pre>\n"
280 srcLanguage = nil
281 srcLines = []
282 closePendingBlockWrapper()
283 }
284 }
285
286 func closeExampleBlock() {
287 if inExampleBlock {
288 html += "</pre>\n"
289 inExampleBlock = false
290 closePendingBlockWrapper()
291 }
292 }
293
294 func closeCenterBlock() {
295 if inCenterBlock {
296 flushParagraph()
297 html += "</div>\n"
298 inCenterBlock = false
299 closePendingBlockWrapper()
300 }
301 }
302
303 func closeVerseBlock() {
304 if inVerseBlock {
305 let content = verseLines
306 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
307 .joined(separator: "<br>\n")
308 html += #"<p class="verse">"# + "\n"
309 html += content + "\n"
310 html += "</p>\n"
311 verseLines = []
312 inVerseBlock = false
313 closePendingBlockWrapper()
314 }
315 }
316
317 func flushBlockState() {
318 flushParagraph()
319 closeList()
320 flushTable()
321 flushPropertyDrawer()
322 }
323
324 if metadataHeader, title != nil || author != nil || date != nil {
325 html += "<div class=\"org-metadata\">\n"
326 if let title {
327 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
328 }
329 if let author {
330 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
331 }
332 if let date {
333 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
334 }
335 html += "</div>\n"
336 }
337
338 for line in lines {
339 let trimmed = line.trimmingCharacters(in: .whitespaces)
340
341 if srcLanguage != nil {
342 if trimmed.lowercased() == "#+end_src" {
343 closeSourceBlock()
344 } else {
345 srcLines.append(line)
346 }
347 continue
348 }
349
350 if inExampleBlock {
351 if trimmed.lowercased() == "#+end_example" {
352 closeExampleBlock()
353 } else {
354 html += escapeHTML(line) + "\n"
355 }
356 continue
357 }
358
359 if inVerseBlock {
360 if trimmed.lowercased() == "#+end_verse" {
361 closeVerseBlock()
362 } else {
363 verseLines.append(line)
364 }
365 continue
366 }
367
368 if inExportBlock {
369 if trimmed.lowercased() == "#+end_export" {
370 if exportIsHTML {
371 html += exportLines.joined(separator: "\n") + "\n"
372 }
373 inExportBlock = false
374 exportLines = []
375 } else if exportIsHTML {
376 // The `html` backend passes through verbatim; any other backend is dropped.
377 exportLines.append(line)
378 }
379 continue
380 }
381
382 if inSpecialBlock {
383 if trimmed.lowercased() == "#+end_\(specialBlockName)" {
384 flushParagraph()
385 html += "</div>\n"
386 inSpecialBlock = false
387 specialBlockName = ""
388 } else if trimmed.isEmpty {
389 flushParagraph()
390 } else {
391 paragraph.append(line)
392 }
393 continue
394 }
395
396 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
397 closeQuoteBlock()
398 continue
399 }
400
401 if inCenterBlock {
402 if trimmed.lowercased() == "#+end_center" {
403 closeCenterBlock()
404 } else if trimmed.isEmpty {
405 flushParagraph()
406 } else {
407 paragraph.append(line)
408 }
409 continue
410 }
411
412 if trimmed == "#" || trimmed.hasPrefix("# ") {
413 continue
414 }
415
416 if let directive = orgKeywordDirective(in: trimmed) {
417 switch directive.keyword {
418 case "caption":
419 pendingBlockCaption = directive.value
420 continue
421 case "name":
422 pendingBlockName = directive.value
423 continue
424 case "attr_html":
425 pendingAttrHtml = directive.value
426 continue
427 default:
428 // Any other `#+keyword:` line (OPTIONS, PROPERTY, FILETAGS, TBLFM, RESULTS,
429 // ) is document metadata, not body content org's exporter consumes it.
430 // `#+begin_`/`#+end_` have no colon, so they are not matched here.
431 continue
432 }
433 }
434
435 // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML
436 // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a
437 // description ([[file:x][label]]) is an ordinary link and falls through to the
438 // paragraph path instead.
439 if let imagePath = standaloneOrgImage(in: trimmed) {
440 closeQuoteBlock()
441 flushBlockState()
442 let img = makeFigureImageHTML(
443 path: imagePath,
444 caption: pendingBlockCaption,
445 attrHtml: pendingAttrHtml,
446 imageURLResolver: imageURLResolver
447 )
448 if pendingBlockCaption != nil || pendingAttrHtml != nil {
449 html += "<figure>" + img
450 if let caption = pendingBlockCaption {
451 figureNumber += 1
452 html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"#
453 + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
454 + "</figcaption>"
455 }
456 html += "</figure>\n"
457 } else {
458 html += "<p>" + img + "</p>\n"
459 }
460 pendingBlockCaption = nil
461 pendingBlockName = nil
462 pendingAttrHtml = nil
463 continue
464 }
465
466 if trimmed.lowercased().hasPrefix("#+begin_src") {
467 // No closeQuoteBlock(): a source block can sit inside a quote, and org keeps it
468 // there. flushBlockState() still ends any open paragraph, list, or table.
469 flushBlockState()
470 beginPendingBlockWrapperIfNeeded()
471 let language = trimmed
472 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
473 .dropFirst()
474 .first
475 .map(String.init)?
476 .trimmingCharacters(in: .whitespacesAndNewlines)
477 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
478 html += "<pre><code\(classAttribute)>"
479 srcLanguage = language ?? ""
480 srcLines = []
481 continue
482 }
483
484 if trimmed.lowercased() == "#+begin_example" {
485 closeQuoteBlock()
486 flushBlockState()
487 beginPendingBlockWrapperIfNeeded()
488 html += "<pre>"
489 inExampleBlock = true
490 continue
491 }
492
493 if trimmed.lowercased() == "#+begin_quote" {
494 flushBlockState()
495 beginPendingBlockWrapperIfNeeded()
496 html += "<blockquote>\n"
497 inQuoteBlock = true
498 continue
499 }
500
501 if trimmed.lowercased() == "#+begin_center" {
502 closeQuoteBlock()
503 flushBlockState()
504 beginPendingBlockWrapperIfNeeded()
505 html += "<div style=\"text-align:center\">\n"
506 inCenterBlock = true
507 continue
508 }
509
510 if trimmed.lowercased() == "#+begin_verse" {
511 closeQuoteBlock()
512 flushBlockState()
513 beginPendingBlockWrapperIfNeeded()
514 verseLines = []
515 inVerseBlock = true
516 continue
517 }
518
519 if trimmed.lowercased().hasPrefix("#+begin_export") {
520 closeQuoteBlock()
521 flushBlockState()
522 let backend = trimmed
523 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
524 .dropFirst().first.map(String.init)?
525 .trimmingCharacters(in: .whitespaces).lowercased()
526 inExportBlock = true
527 exportIsHTML = backend == "html"
528 exportLines = []
529 continue
530 }
531
532 // Any other `#+begin_<name>` is a special block: org renders it as a `<div>` carrying
533 // the name as a class, with the contents parsed as ordinary org. Must stay last so
534 // the specific block types above win.
535 if trimmed.lowercased().hasPrefix("#+begin_") {
536 closeQuoteBlock()
537 flushBlockState()
538 let name = String(trimmed.lowercased().dropFirst("#+begin_".count))
539 .split(separator: " ").first.map(String.init) ?? ""
540 if !name.isEmpty {
541 html += #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n"
542 inSpecialBlock = true
543 specialBlockName = name
544 continue
545 }
546 }
547
548 if trimmed == ":PROPERTIES:" {
549 closeQuoteBlock()
550 flushBlockState()
551 inPropertyDrawer = true
552 continue
553 }
554
555 if trimmed == ":END:", inPropertyDrawer {
556 flushPropertyDrawer()
557 inPropertyDrawer = false
558 continue
559 }
560
561 if inPropertyDrawer,
562 trimmed.hasPrefix(":"),
563 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
564 let keyStart = trimmed.index(after: trimmed.startIndex)
565 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
566 let valueStart = trimmed.index(after: secondColonIndex)
567 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
568 if !key.isEmpty {
569 propertyRows.append((key, value))
570 continue
571 }
572 }
573
574 if isTableLine(trimmed) {
575 closeQuoteBlock()
576 flushParagraph()
577 closeList()
578 tableRows.append(parseTableRow(trimmed))
579 continue
580 } else {
581 flushTable()
582 }
583
584 if isOrgHorizontalRule(trimmed) {
585 closeQuoteBlock()
586 flushBlockState()
587 html += "<hr>\n"
588 continue
589 }
590
591 // Reference-style footnote definition: [fn:label] text. Collected out of the body
592 // flow and emitted in the footnotes section at the end.
593 if let definition = orgFootnoteDefinition(in: trimmed) {
594 closeQuoteBlock()
595 flushBlockState()
596 footnotes.define(label: definition.label, text: definition.text)
597 continue
598 }
599
600 // Org headings: * heading, ** heading, *** heading
601 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
602 closeQuoteBlock()
603 flushBlockState()
604 let level = min(6, max(1, match.1.count + headingLevelOffset))
605 let (titleText, tags) = splitHeadingTags(String(match.2))
606 var content = processOrgInline(titleText, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes)
607 if !tags.isEmpty {
608 content += " " + tags.map { #"<span class="tag">\#(escapeHTML($0))</span>"# }.joined(separator: " ")
609 }
610 html += "<h\(level)>" + content + "</h\(level)>\n"
611 continue
612 }
613
614 // Inside a list, hold blank lines instead of ending it: an item can have several
615 // paragraphs, separated by blanks, before the next item or the list's end.
616 if listType != nil, trimmed.isEmpty {
617 pendingListBlanks.append(line)
618 continue
619 }
620
621 // A list item marker at column 0 starts or continues a list. A different marker
622 // type (ordered vs unordered) at the top level begins a separate list.
623 if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) {
624 let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered
625 if listType == nil {
626 flushParagraph()
627 flushPropertyDrawer()
628 } else if listType != newType {
629 closeList()
630 flushParagraph()
631 flushPropertyDrawer()
632 }
633 listType = newType
634 listBuffer.append(contentsOf: pendingListBlanks)
635 pendingListBlanks = []
636 listBuffer.append(line)
637 continue
638 }
639
640 // A line indented under an open list is a continuation or a nested item.
641 if listType != nil, isIndentedContinuationLine(line) {
642 listBuffer.append(contentsOf: pendingListBlanks)
643 pendingListBlanks = []
644 listBuffer.append(line)
645 continue
646 }
647
648 // Any other line ends an open list, then is processed normally below.
649 if listType != nil {
650 closeList()
651 }
652
653 // Blank line
654 if trimmed.isEmpty {
655 if inQuoteBlock {
656 flushParagraph()
657 } else {
658 flushBlockState()
659 }
660 continue
661 }
662
663 // Regular text
664 if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil {
665 pendingBlockName = nil
666 pendingBlockCaption = nil
667 pendingAttrHtml = nil
668 }
669 paragraph.append(line)
670 }
671
672 closeSourceBlock()
673 closeExampleBlock()
674 closeCenterBlock()
675 closeVerseBlock()
676 closeQuoteBlock()
677 flushBlockState()
678
679 html += footnotes.renderSection { text in
680 processOrgInline(text, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
681 }
682
683 return html
684}
Sources/OrgSwift/Tables.swift −48
@@ -51,51 +51,3 @@ func tableAlignment(for cell: String) -> String? {
5151 return ""
5252 }
5353 }
54
55func renderHTMLTable(
56 rows: [[String]],
57 inlineRenderer: (String) -> String
58) -> String {
59 guard !rows.isEmpty else { return "" }
60 let separatorCells: [String]
61 if rows.count > 1, rows[1].count == 1 {
62 separatorCells = parseOrgTableSeparatorRow(rows[1][0])
63 } else {
64 separatorCells = rows.count > 1 ? rows[1] : []
65 }
66 let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell)
67 let headerRow = rows.first ?? []
68 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
69 let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : []
70 var html = "<table>\n"
71
72 if hasHeaderSeparator {
73 html += "<thead><tr>"
74 for (index, cell) in headerRow.enumerated() {
75 html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>"
76 }
77 html += "</tr></thead>\n"
78 }
79
80 html += "<tbody>\n"
81 for row in bodyRows {
82 html += "<tr>"
83 for (index, cell) in row.enumerated() {
84 html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>"
85 }
86 html += "</tr>\n"
87 }
88 html += "</tbody>\n"
89 html += "</table>\n"
90 return html
91}
92
93private func columnAlignment(at index: Int, in alignments: [String?]) -> String? {
94 guard alignments.indices.contains(index) else { return nil }
95 return alignments[index]
96}
97
98private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
99 guard let alignment, !alignment.isEmpty else { return "" }
100 return #" style="text-align: \#(alignment);""#
101}
Sources/OrgSwift/Timestamps.swift deleted −89
@@ -1,89 +0,0 @@
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/ASTTests.swift deleted −489
@@ -1,489 +0,0 @@
1import Foundation
2import Testing
3@testable import OrgSwift
4
5/// Tests for the AST prototype: `source OrgDocument {HTML, AttributedString}`.
6///
7/// Two things are being proven. First, that the tree carries the structure faithfully (the
8/// parse tests). Second the actual argument for the split that a second output format is
9/// a walk over the same tree rather than a second parser (the AttributedString tests), and
10/// that the tree-based HTML renderer can be held to the same conformance corpus as the
11/// shipped single-pass renderer.
12struct ASTParseTests {
13
14 @Test
15 func headingCarriesTodoPriorityAndTags() {
16 let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")
17 guard case .heading(let heading) = doc.elements.first else {
18 Issue.record("expected a heading"); return
19 }
20 #expect(heading.level == 1)
21 #expect(heading.todo == "TODO")
22 #expect(heading.priority == "A")
23 #expect(heading.tags == ["work", "rust"])
24 #expect(OrgParser.plain(heading.title) == "Write the parser")
25 }
26
27 @Test
28 func emphasisNestsRatherThanFlattening() {
29 // The point of a tree: bold containing italic is structure, not a markup string.
30 let objects = OrgParser.parseInline("*bold /inner/ rest*")
31 guard case .bold(let children) = objects.first else {
32 Issue.record("expected bold"); return
33 }
34 #expect(children.contains { if case .italic = $0 { return true } else { return false } })
35 }
36
37 @Test
38 func documentKeywordsAreMetadataNotContent() {
39 let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.")
40 #expect(doc.keyword("title") == "My Doc")
41 #expect(doc.keyword("author") == "Someone")
42 // Metadata does not appear as a body element.
43 #expect(doc.elements.count == 1)
44 guard case .paragraph = doc.elements.first else {
45 Issue.record("expected a single paragraph"); return
46 }
47 }
48
49 @Test
50 func nestedListsBecomeNestedItems() {
51 let doc = OrgParser.parse("""
52 - outer
53 - inner
54 - deepest
55 - second
56 """)
57 guard case .list(let list) = doc.elements.first else {
58 Issue.record("expected a list"); return
59 }
60 #expect(list.items.count == 2)
61 let inner = list.items[0].sublist
62 #expect(inner != nil)
63 #expect(inner?.items.first?.sublist?.items.count == 1)
64 }
65
66 @Test
67 func tableKeepsRuleRowAndAlignments() {
68 let doc = OrgParser.parse("""
69 | Name | Score |
70 |:------+------:|
71 | alpha | 10 |
72 """)
73 guard case .table(let table) = doc.elements.first else {
74 Issue.record("expected a table"); return
75 }
76 #expect(table.rows.count == 3)
77 #expect(table.headerRowCount == 1)
78 #expect(table.alignments == [.left, .right])
79 if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") }
80 }
81
82 @Test
83 func timestampsAndLinksBecomeTypedObjects() {
84 let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]")
85 let hasTimestamp = objects.contains {
86 if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" }
87 return false
88 }
89 #expect(hasTimestamp)
90 let hasIDLink = objects.contains {
91 if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" }
92 return false
93 }
94 #expect(hasIDLink)
95 }
96}
97
98struct ASTRendererTests {
99
100 /// The payoff: one parse, two output formats, neither re-deriving the other's work.
101 @Test
102 func oneParseFeedsTwoRenderers() {
103 let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].")
104
105 let html = OrgHTMLTreeRenderer().render(document)
106 #expect(html.contains("<strong>bold</strong>"))
107 #expect(html.contains("<code>code</code>"))
108 #expect(html.contains(#"<a href="https://example.com">link</a>"#))
109
110 let attributed = OrgAttributedStringRenderer().inline({
111 if case .paragraph(let objects) = document.elements[0] { return objects }
112 return []
113 }())
114 // Same content, native representation: no markup, real attributes.
115 let plain = String(attributed.characters)
116 #expect(plain == "A bold claim with code and a link.")
117 #expect(!plain.contains("<"))
118
119 let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized }
120 #expect(boldRun != nil)
121 let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code }
122 #expect(codeRun != nil)
123 let linkRun = attributed.runs.first { $0.link != nil }
124 #expect(linkRun?.link?.absoluteString == "https://example.com")
125 }
126
127 @Test
128 func attributedStringCarriesRolesForNonStandardIntents() {
129 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
130 let attributed = OrgAttributedStringRenderer().inline(objects)
131 let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] }
132 #expect(roles.contains(.superscript))
133 #expect(roles.contains(.timestamp))
134 }
135
136 @Test
137 func consecutiveListsOfDifferentKindsStaySeparate() {
138 // An ordered list followed by a bullet list is two lists, not one with mixed items.
139 let doc = OrgParser.parse("""
140 1. first
141 2. second
142
143 - [ ] todo
144 - [X] done
145 """)
146 let lists = doc.elements.compactMap { element -> OrgList? in
147 if case .list(let list) = element { return list } else { return nil }
148 }
149 #expect(lists.count == 2)
150 #expect(lists.first?.kind == .ordered)
151 #expect(lists.last?.kind == .unordered)
152 #expect(lists.last?.items.first?.checkbox == .off)
153
154 let html = OrgHTMLTreeRenderer().render(doc)
155 #expect(html.contains("</ol>"))
156 #expect(html.contains("<ul>"))
157 }
158
159 @Test
160 func inlineFootnoteDefinesItsNoteAtTheReference() {
161 let doc = OrgParser.parse("A claim.[fn:x:defined right here]")
162 let html = OrgHTMLTreeRenderer().render(doc)
163 #expect(html.contains(##"href="#fn-1">1</a>"##))
164 // Inline note text sits directly in the item; only reference-style notes get a <p>.
165 #expect(html.contains(#"<li id="fn-1">defined right here "#))
166 #expect(!html.contains(#"<li id="fn-1"><p>"#))
167 }
168
169 @Test
170 func referenceStyleFootnoteKeepsItsParagraph() {
171 let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.")
172 let html = OrgHTMLTreeRenderer().render(doc)
173 #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#))
174 }
175
176 @Test
177 func timestampRangesRenderAsTwoTimeElements() {
178 // Same-day: the end shows only its time, since the start carries the date.
179 let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>."))
180 #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
181 #expect(sameDay.contains("&#8211;"))
182 #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
183
184 // Multi-day: one timestamp carrying an end date, rendered as two stamps.
185 let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>."))
186 #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
187 #expect(multiDay.contains("&#8211;"))
188 #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
189 // The `--` join is consumed, not left as stray text.
190 #expect(!multiDay.contains("--"))
191 }
192
193 @Test
194 func treeRendererProducesStructuralHTML() {
195 let document = OrgParser.parse("""
196 * Heading
197
198 | a | b |
199 |---+---|
200 | 1 | 2 |
201
202 - [ ] todo
203 - [X] done
204 """)
205 let html = OrgHTMLTreeRenderer().render(document)
206 #expect(html.contains("<h1>Heading</h1>"))
207 #expect(html.contains("<thead>"))
208 #expect(html.contains("<th>a</th>"))
209 #expect(html.contains("<td>1</td>"))
210 #expect(html.contains("<code>[&nbsp;]</code> todo"))
211 #expect(html.contains("<code>[X]</code> done"))
212 }
213}
214
215/// The tree-based HTML renderer against the same corpus and the same skeleton reduction the
216/// shipped renderer is held to. It now matches on every case the shipped renderer matches, so
217/// this asserts rather than merely reports: a regression fails, and `outofscope` starting to
218/// match fails too, forcing this record to be updated.
219struct ASTConformanceTests {
220
221 /// Cases the tree renderer must match. `outofscope` is `scope: out` in the corpus
222 /// deliberately unsupported constructs, where orgo itself may differ so it is expected
223 /// to diverge, exactly as it does for the shipped renderer.
224 private static let expectedToMatch: Set<String> = [
225 "blocks", "core", "elements", "footnote", "headings", "images",
226 "lists", "minimal", "table", "tblfm", "timestamps",
227 ]
228
229 @Test
230 func matchesTheCorpusWhereTheShippedRendererDoes() throws {
231 guard let dir = astCorpusCasesDir() else {
232 print("org-conformance corpus not found — skipping")
233 return
234 }
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 )
240 var matched: [String] = []
241 var diverged: [String] = []
242
243 for name in astCaseNames(dir) {
244 let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? ""
245 let goldenRaw = (try? String(contentsOf: dir.appendingPathComponent("\(name).skeleton"), encoding: .utf8)) ?? ""
246 let trimmed = goldenRaw.hasSuffix("\n") ? String(goldenRaw.dropLast()) : goldenRaw
247 let golden = trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n")
248
249 let html = renderer.render(OrgParser.parse(source))
250 let got = OrgSkeleton.skeleton(html)
251 if got == golden {
252 matched.append(name)
253 } else {
254 diverged.append(name)
255 if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil {
256 let firstDiff = (0..<max(got.count, golden.count)).first {
257 ($0 < golden.count ? golden[$0] : "") != ($0 < got.count ? got[$0] : "")
258 }
259 if let k = firstDiff {
260 print("AST-DIFF \(name) @\(k): orgo=\(k < golden.count ? golden[k] : "") ours=\(k < got.count ? got[k] : "")")
261 }
262 }
263 }
264 }
265 print("AST-CONFORMANCE matched=\(matched.count)/\(matched.count + diverged.count) \(matched.sorted())")
266 print("AST-CONFORMANCE diverged=\(diverged.sorted())")
267
268 #expect(Set(matched) == Self.expectedToMatch,
269 "tree renderer conformance changed — matched \(matched.sorted()), expected \(Self.expectedToMatch.sorted())")
270 }
271}
272
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
474private func astCorpusCasesDir() -> URL? {
475 if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
476 let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")
477 if FileManager.default.fileExists(atPath: cases.path) { return cases }
478 }
479 let pkgRoot = URL(fileURLWithPath: #filePath)
480 .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
481 let sibling = pkgRoot.deletingLastPathComponent()
482 .appendingPathComponent("org-conformance").appendingPathComponent("cases")
483 return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil
484}
485
486private func astCaseNames(_ dir: URL) -> [String] {
487 let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
488 return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted()
489}
Tests/OrgSwiftTests/OrgTreeTests.swift added +300
@@ -0,0 +1,300 @@
1import Foundation
2import Testing
3@testable import OrgSwift
4
5/// Tests for the element tree: `source OrgDocument {HTML, AttributedString}`.
6///
7/// The parse tests assert the tree carries structure faithfully. The renderer tests assert
8/// the point of the split that a second output format is a walk over the same tree rather
9/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`.
10struct ASTParseTests {
11
12 @Test
13 func headingCarriesTodoPriorityAndTags() {
14 let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")
15 guard case .heading(let heading) = doc.elements.first else {
16 Issue.record("expected a heading"); return
17 }
18 #expect(heading.level == 1)
19 #expect(heading.todo == "TODO")
20 #expect(heading.priority == "A")
21 #expect(heading.tags == ["work", "rust"])
22 #expect(OrgParser.plain(heading.title) == "Write the parser")
23 }
24
25 @Test
26 func emphasisNestsRatherThanFlattening() {
27 // The point of a tree: bold containing italic is structure, not a markup string.
28 let objects = OrgParser.parseInline("*bold /inner/ rest*")
29 guard case .bold(let children) = objects.first else {
30 Issue.record("expected bold"); return
31 }
32 #expect(children.contains { if case .italic = $0 { return true } else { return false } })
33 }
34
35 @Test
36 func documentKeywordsAreMetadataNotContent() {
37 let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.")
38 #expect(doc.keyword("title") == "My Doc")
39 #expect(doc.keyword("author") == "Someone")
40 // Metadata does not appear as a body element.
41 #expect(doc.elements.count == 1)
42 guard case .paragraph = doc.elements.first else {
43 Issue.record("expected a single paragraph"); return
44 }
45 }
46
47 @Test
48 func nestedListsBecomeNestedItems() {
49 let doc = OrgParser.parse("""
50 - outer
51 - inner
52 - deepest
53 - second
54 """)
55 guard case .list(let list) = doc.elements.first else {
56 Issue.record("expected a list"); return
57 }
58 #expect(list.items.count == 2)
59 let inner = list.items[0].sublist
60 #expect(inner != nil)
61 #expect(inner?.items.first?.sublist?.items.count == 1)
62 }
63
64 @Test
65 func tableKeepsRuleRowAndAlignments() {
66 let doc = OrgParser.parse("""
67 | Name | Score |
68 |:------+------:|
69 | alpha | 10 |
70 """)
71 guard case .table(let table) = doc.elements.first else {
72 Issue.record("expected a table"); return
73 }
74 #expect(table.rows.count == 3)
75 #expect(table.headerRowCount == 1)
76 #expect(table.alignments == [.left, .right])
77 if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") }
78 }
79
80 @Test
81 func timestampsAndLinksBecomeTypedObjects() {
82 let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]")
83 let hasTimestamp = objects.contains {
84 if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" }
85 return false
86 }
87 #expect(hasTimestamp)
88 let hasIDLink = objects.contains {
89 if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" }
90 return false
91 }
92 #expect(hasIDLink)
93 }
94}
95
96struct ASTRendererTests {
97
98 /// The payoff: one parse, two output formats, neither re-deriving the other's work.
99 @Test
100 func oneParseFeedsTwoRenderers() {
101 let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].")
102
103 let html = OrgHTMLTreeRenderer().render(document)
104 #expect(html.contains("<strong>bold</strong>"))
105 #expect(html.contains("<code>code</code>"))
106 #expect(html.contains(#"<a href="https://example.com">link</a>"#))
107
108 let attributed = OrgAttributedStringRenderer().inline({
109 if case .paragraph(let objects) = document.elements[0] { return objects }
110 return []
111 }())
112 // Same content, native representation: no markup, real attributes.
113 let plain = String(attributed.characters)
114 #expect(plain == "A bold claim with code and a link.")
115 #expect(!plain.contains("<"))
116
117 let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized }
118 #expect(boldRun != nil)
119 let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code }
120 #expect(codeRun != nil)
121 let linkRun = attributed.runs.first { $0.link != nil }
122 #expect(linkRun?.link?.absoluteString == "https://example.com")
123 }
124
125 @Test
126 func attributedStringCarriesRolesForNonStandardIntents() {
127 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
128 let attributed = OrgAttributedStringRenderer().inline(objects)
129 let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] }
130 #expect(roles.contains(.superscript))
131 #expect(roles.contains(.timestamp))
132 }
133
134 @Test
135 func consecutiveListsOfDifferentKindsStaySeparate() {
136 // An ordered list followed by a bullet list is two lists, not one with mixed items.
137 let doc = OrgParser.parse("""
138 1. first
139 2. second
140
141 - [ ] todo
142 - [X] done
143 """)
144 let lists = doc.elements.compactMap { element -> OrgList? in
145 if case .list(let list) = element { return list } else { return nil }
146 }
147 #expect(lists.count == 2)
148 #expect(lists.first?.kind == .ordered)
149 #expect(lists.last?.kind == .unordered)
150 #expect(lists.last?.items.first?.checkbox == .off)
151
152 let html = OrgHTMLTreeRenderer().render(doc)
153 #expect(html.contains("</ol>"))
154 #expect(html.contains("<ul>"))
155 }
156
157 @Test
158 func inlineFootnoteDefinesItsNoteAtTheReference() {
159 let doc = OrgParser.parse("A claim.[fn:x:defined right here]")
160 let html = OrgHTMLTreeRenderer().render(doc)
161 #expect(html.contains(##"href="#fn-1">1</a>"##))
162 // Inline note text sits directly in the item; only reference-style notes get a <p>.
163 #expect(html.contains(#"<li id="fn-1">defined right here "#))
164 #expect(!html.contains(#"<li id="fn-1"><p>"#))
165 }
166
167 @Test
168 func referenceStyleFootnoteKeepsItsParagraph() {
169 let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.")
170 let html = OrgHTMLTreeRenderer().render(doc)
171 #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#))
172 }
173
174 @Test
175 func timestampRangesRenderAsTwoTimeElements() {
176 // Same-day: the end shows only its time, since the start carries the date.
177 let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>."))
178 #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
179 #expect(sameDay.contains("&#8211;"))
180 #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
181
182 // Multi-day: one timestamp carrying an end date, rendered as two stamps.
183 let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>."))
184 #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
185 #expect(multiDay.contains("&#8211;"))
186 #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
187 // The `--` join is consumed, not left as stray text.
188 #expect(!multiDay.contains("--"))
189 }
190
191 @Test
192 func treeRendererProducesStructuralHTML() {
193 let document = OrgParser.parse("""
194 * Heading
195
196 | a | b |
197 |---+---|
198 | 1 | 2 |
199
200 - [ ] todo
201 - [X] done
202 """)
203 let html = OrgHTMLTreeRenderer().render(document)
204 #expect(html.contains("<h1>Heading</h1>"))
205 #expect(html.contains("<thead>"))
206 #expect(html.contains("<th>a</th>"))
207 #expect(html.contains("<td>1</td>"))
208 #expect(html.contains("<code>[&nbsp;]</code> todo"))
209 #expect(html.contains("<code>[X]</code> done"))
210 }
211}
212
213/// The render options, which the conformance corpus does not exercise because it renders with
214/// no repository context. These were written as shipped-vs-tree equivalence checks during the
215/// migration; now that the tree *is* the renderer, they assert the behaviour directly.
216struct OrgRenderOptionsTests {
217
218 /// gitbay's configuration: images resolve against `raw`, links against `blob`.
219 private static let repositoryOptions = OrgRenderOptions(
220 host: "gitbay.org",
221 owner: "krz",
222 repositoryName: "gitbay",
223 ref: "HEAD",
224 readmePath: "README.org",
225 imagePathSegment: "raw",
226 linkPathSegment: "blob"
227 )
228
229 @Test
230 func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() {
231 let html = OrgRenderer.renderToHTML("""
232 A relative link to [[docs/DESIGN.org][the design]] and an absolute one to
233 [[https://example.org][elsewhere]].
234
235 [[file:docs/logo.png]]
236 """, options: Self.repositoryOptions)
237
238 #expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png"))
239 #expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org"))
240 #expect(html.contains("https://example.org"))
241 }
242
243 @Test
244 func leavesRelativeTargetsAloneWithoutRepositoryContext() {
245 let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]")
246 #expect(html.contains(#"href="docs/DESIGN.org""#))
247 #expect(html.contains(#"src="logo.png""#))
248 }
249
250 @Test
251 func emitsTheMetadataHeaderOnlyWhenAsked() {
252 let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody."
253
254 let withHeader = OrgRenderer.renderToHTML(source)
255 #expect(withHeader.contains(#"<h1 class="org-title">My Doc</h1>"#))
256 #expect(withHeader.contains(#"<p class="org-author">Someone</p>"#))
257
258 let without = OrgRenderer.renderToHTML(source, options: OrgRenderOptions(metadataHeader: false))
259 #expect(!without.contains("org-title"))
260 #expect(!without.contains("My Doc"))
261 #expect(without.contains("<p>Body.</p>"))
262 }
263
264 @Test
265 func rejectsUnsafeSchemes() {
266 let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]")
267 #expect(!html.lowercased().contains("javascript:"))
268 // The link degrades to its text rather than becoming a bad anchor.
269 #expect(html.contains("click"))
270 }
271
272 /// A range whose halves are inactive timestamps is still a range. orgo applies the `--`
273 /// rule to both bracket kinds, requiring only that the halves agree on activeness; the
274 /// renderer this replaced joined active ranges only, so this is the one behaviour the
275 /// swap deliberately changed.
276 @Test
277 func joinsInactiveTimestampRanges() {
278 let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]")
279 #expect(!html.contains("--"))
280 #expect(html.contains("&#8211;"))
281 #expect(html.contains(#"class="timestamp inactive""#))
282 }
283}
284
285private func astCorpusCasesDir() -> URL? {
286 if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
287 let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")
288 if FileManager.default.fileExists(atPath: cases.path) { return cases }
289 }
290 let pkgRoot = URL(fileURLWithPath: #filePath)
291 .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
292 let sibling = pkgRoot.deletingLastPathComponent()
293 .appendingPathComponent("org-conformance").appendingPathComponent("cases")
294 return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil
295}
296
297private func astCaseNames(_ dir: URL) -> [String] {
298 let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
299 return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted()
300}