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

html library org-mode swift

Parse org into an element tree, and render the tree !1

merged cmc wants to merge krz/org-swift:org-tree into main

16 files changed, +1864 −1359

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.
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/AST/OrgAttributedStringRenderer.swift added +144
@@ -0,0 +1,144 @@
1import Foundation
2
3/// Renders inline org content to `AttributedString` the native counterpart to the HTML
4/// renderer, walking the same tree.
5///
6/// This is the prototype's argument: a second output format is a walk over the parsed tree,
7/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a
8/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
9public struct OrgAttributedStringRenderer: Sendable {
10
11 public init() {}
12
13 /// Render one run of inline objects, carrying intents a UI layer can style.
14 public func inline(_ objects: [OrgObject]) -> AttributedString {
15 var result = AttributedString()
16 for object in objects {
17 switch object {
18 case .text(let text):
19 result += AttributedString(text)
20
21 case .bold(let children):
22 var part = inline(children)
23 part.inlinePresentationIntent = .stronglyEmphasized
24 result += part
25
26 case .italic(let children):
27 var part = inline(children)
28 part.inlinePresentationIntent = .emphasized
29 result += part
30
31 // Underline and strikethrough have no Foundation-portable attribute (the
32 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
33 // travel as roles the UI layer applies.
34 case .underline(let children):
35 var part = inline(children)
36 part.orgRole = .underline
37 result += part
38
39 case .strikeThrough(let children):
40 var part = inline(children)
41 part.orgRole = .strikeThrough
42 result += part
43
44 case .verbatim(let text), .code(let text):
45 var part = AttributedString(text)
46 part.inlinePresentationIntent = .code
47 result += part
48
49 case .superscript(let children):
50 // No portable superscript attribute; mark it so a UI layer can raise it.
51 var part = inline(children)
52 part.orgRole = .superscript
53 result += part
54
55 case .lineBreak:
56 result += AttributedString("\n")
57
58 case .timestamp(let stamp):
59 var part = AttributedString(stamp.displayValue)
60 part.orgRole = .timestamp
61 result += part
62
63 case .footnoteRef(let label, _):
64 var part = AttributedString("[\(label)]")
65 part.orgRole = .footnoteReference
66 result += part
67
68 case .image(let figure):
69 var part = AttributedString(figure.alt ?? figure.source)
70 part.orgRole = .image
71 result += part
72
73 case .link(let link):
74 var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target))
75 if let url = URL(string: hrefValue(link.target)) {
76 part.link = url
77 }
78 result += part
79 }
80 }
81 return result
82 }
83
84 /// Flatten a whole document to attributed paragraphs a convenience for callers that
85 /// want text without building block views (a share sheet, a plain-text export).
86 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
87 document.elements.compactMap { element in
88 switch element {
89 case .paragraph(let objects): return inline(objects)
90 case .heading(let heading): return inline(heading.title)
91 default: return nil
92 }
93 }
94 }
95
96 private func hrefValue(_ target: OrgLinkTarget) -> String {
97 switch target {
98 case .external(let url): return url
99 case .file(let path): return path
100 case .id(let identifier): return "#\(identifier)"
101 }
102 }
103
104 private func displayValue(_ target: OrgLinkTarget) -> String {
105 switch target {
106 case .external(let url): return url
107 case .file(let path): return path
108 case .id(let identifier): return identifier
109 }
110 }
111}
112
113// MARK: - Custom attribute
114
115/// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to
116/// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable)
117/// without the renderer needing to know about fonts or colors.
118public enum OrgRole: String, Sendable, Codable {
119 case superscript
120 case timestamp
121 case footnoteReference
122 case image
123 case underline
124 case strikeThrough
125}
126
127public enum OrgRoleAttribute: AttributedStringKey {
128 public typealias Value = OrgRole
129 public static let name = "orgRole"
130}
131
132public extension AttributeScopes {
133 struct OrgAttributes: AttributeScope {
134 public let orgRole: OrgRoleAttribute
135 }
136 var org: OrgAttributes.Type { OrgAttributes.self }
137}
138
139public extension AttributedString {
140 var orgRole: OrgRole? {
141 get { self[OrgRoleAttribute.self] }
142 set { self[OrgRoleAttribute.self] = newValue }
143 }
144}
Sources/OrgSwift/AST/OrgDocument.swift added +258
@@ -0,0 +1,258 @@
1import Foundation
2
3// The org element tree the document model, deliberately mirroring orgo's `model.rs`
4// (`Element`/`Object`, `TableRow::{Cells, Rule}`, `ListKind`, `Checkbox`) so the two can be
5// compared tree-to-tree later, instead of only through rendered HTML.
6//
7// Why a tree at all: the shipped renderer is a single pass from source straight to an HTML
8// string, so every new output format would mean re-deriving the parse. With a tree, parsing
9// happens once and each renderer is a walk the HTML renderer stays the one the conformance
10// corpus measures, and a native renderer rides on the same proven parse.
11
12/// A parsed org document: metadata keywords plus the block elements of the body.
13public struct OrgDocument: Sendable, Equatable {
14 /// `#+TITLE:`, `#+AUTHOR:`, `#+DATE:` and any other `#+KEY: value`, in source order.
15 public var keywords: [(key: String, value: String)]
16 public var elements: [OrgElement]
17
18 public init(keywords: [(key: String, value: String)] = [], elements: [OrgElement] = []) {
19 self.keywords = keywords
20 self.elements = elements
21 }
22
23 public func keyword(_ name: String) -> String? {
24 keywords.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
25 }
26
27 public static func == (lhs: OrgDocument, rhs: OrgDocument) -> Bool {
28 lhs.elements == rhs.elements
29 && lhs.keywords.count == rhs.keywords.count
30 && zip(lhs.keywords, rhs.keywords).allSatisfy { $0.key == $1.key && $0.value == $1.value }
31 }
32}
33
34/// A block-level element.
35public indirect enum OrgElement: Sendable, Equatable {
36 case heading(OrgHeading)
37 case paragraph([OrgObject])
38 case list(OrgList)
39 case table(OrgTable)
40 case srcBlock(language: String?, code: String)
41 case exampleBlock(String)
42 case quoteBlock([OrgElement])
43 case centerBlock([OrgElement])
44 case verseBlock([[OrgObject]])
45 case specialBlock(name: String, content: [OrgElement])
46 case exportBlock(backend: String, raw: String)
47 case figure(OrgFigure)
48 /// A block carrying an affiliated `#+CAPTION:` / `#+NAME:`, which org's exporter wraps in
49 /// a `<figure>`. Images take the `.figure` case instead; this is for everything else
50 /// (a captioned source block, example, or table).
51 case captioned(name: String?, caption: [OrgObject]?, content: OrgElement)
52 case horizontalRule
53 case footnoteDefinition(label: String, content: [OrgObject])
54}
55
56public struct OrgHeading: Sendable, Equatable {
57 /// Star count, before any render-time level offset.
58 public var level: Int
59 public var todo: String?
60 public var priority: Character?
61 public var title: [OrgObject]
62 public var tags: [String]
63
64 public init(level: Int, todo: String? = nil, priority: Character? = nil,
65 title: [OrgObject], tags: [String] = []) {
66 self.level = level
67 self.todo = todo
68 self.priority = priority
69 self.title = title
70 self.tags = tags
71 }
72}
73
74// MARK: - Lists
75
76public struct OrgList: Sendable, Equatable {
77 public var kind: OrgListKind
78 public var items: [OrgListItem]
79
80 public init(kind: OrgListKind, items: [OrgListItem]) {
81 self.kind = kind
82 self.items = items
83 }
84}
85
86public enum OrgListKind: Sendable, Equatable {
87 case unordered
88 case ordered
89 case description
90}
91
92public struct OrgListItem: Sendable, Equatable {
93 public var checkbox: OrgCheckbox?
94 /// The term of a description-list item (`term :: definition`).
95 public var term: [OrgObject]?
96 /// The item's own content: one paragraph normally, several for a multi-paragraph item.
97 public var content: [[OrgObject]]
98 /// A nested list, when the item has one.
99 public var sublist: OrgList?
100
101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102 content: [[OrgObject]], sublist: OrgList? = nil) {
103 self.checkbox = checkbox
104 self.term = term
105 self.content = content
106 self.sublist = sublist
107 }
108}
109
110public enum OrgCheckbox: Sendable, Equatable {
111 case off
112 case on
113 case partial
114}
115
116// MARK: - Tables
117
118public struct OrgTable: Sendable, Equatable {
119 public var rows: [OrgTableRow]
120 /// Per-column alignment from the separator row, when it carries `:` markers.
121 public var alignments: [OrgAlignment?]
122
123 public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
124 self.rows = rows
125 self.alignments = alignments
126 }
127
128 /// A rule row separates the header band from the body, as in org.
129 public var headerRowCount: Int {
130 guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
131 else { return 0 }
132 return ruleIndex
133 }
134}
135
136public enum OrgTableRow: Sendable, Equatable {
137 case cells([[OrgObject]])
138 case rule
139}
140
141public enum OrgAlignment: String, Sendable, Equatable {
142 case left, center, right
143}
144
145// MARK: - Figures
146
147public struct OrgFigure: Sendable, Equatable {
148 public var source: String
149 public var caption: [OrgObject]?
150 /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
151 /// renderer can read the ones it understands (`:alt`, `:width`).
152 public var attributes: [(key: String, value: String)]
153
154 public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
155 self.source = source
156 self.caption = caption
157 self.attributes = attributes
158 }
159
160 public var alt: String? {
161 attributes.first { $0.key == "alt" }?.value
162 }
163
164 public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
165 lhs.source == rhs.source && lhs.caption == rhs.caption
166 && lhs.attributes.count == rhs.attributes.count
167 && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
168 }
169}
170
171// MARK: - Inline objects
172
173/// An inline object. Text-bearing cases carry their own children so a renderer can nest
174/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
175public indirect enum OrgObject: Sendable, Equatable {
176 case text(String)
177 case bold([OrgObject])
178 case italic([OrgObject])
179 case underline([OrgObject])
180 case strikeThrough([OrgObject])
181 /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
182 case verbatim(String)
183 case code(String)
184 case link(OrgLink)
185 case image(OrgFigure)
186 /// A footnote reference. `inline` carries the text of an inline footnote
187 /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
188 /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
189 /// renderer's job, not the parser's.
190 case footnoteRef(label: String, inline: [OrgObject]?)
191 case timestamp(OrgTimestamp)
192 case superscript([OrgObject])
193 case lineBreak
194}
195
196public struct OrgLink: Sendable, Equatable {
197 public var target: OrgLinkTarget
198 /// Nil description means the link shows its target.
199 public var description: [OrgObject]?
200
201 public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
202 self.target = target
203 self.description = description
204 }
205}
206
207public enum OrgLinkTarget: Sendable, Equatable {
208 /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
209 case external(String)
210 /// A repository-relative path, from `[[file:]]` or a bare relative target.
211 case file(String)
212 /// `[[id:]]` an in-page fragment.
213 case id(String)
214}
215
216public struct OrgTimestamp: Sendable, Equatable {
217 public var date: String
218 public var time: String?
219 /// End of a same-day time range (`< 10:00-11:45>`).
220 public var endTime: String?
221 /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
222 public var endDate: String?
223 public var active: Bool
224
225 public init(date: String, time: String? = nil, endTime: String? = nil,
226 endDate: String? = nil, active: Bool) {
227 self.date = date
228 self.time = time
229 self.endTime = endTime
230 self.endDate = endDate
231 self.active = active
232 }
233
234 /// True when this stamp spans a range, in either form.
235 public var isRange: Bool { endTime != nil || endDate != nil }
236
237 /// The machine value and display text of the range's end, when there is one. A multi-day
238 /// end shows its whole date; a same-day end shows only the time, since the date is already
239 /// on the start.
240 public var end: (machineValue: String, displayValue: String)? {
241 if let endDate {
242 return (endDate, endDate)
243 }
244 if let endTime {
245 return ("\(date)T\(endTime)", endTime)
246 }
247 return nil
248 }
249
250 /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
251 public var machineValue: String {
252 time.map { "\(date)T\($0)" } ?? date
253 }
254
255 public var displayValue: String {
256 time.map { "\(date) \($0)" } ?? date
257 }
258}
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift added +344
@@ -0,0 +1,344 @@
1import Foundation
2
3/// Renders an ``OrgDocument`` to HTML by walking the tree.
4///
5/// The point of the prototype: this produces the same shape of output as the shipped
6/// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second
7/// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it.
8public struct OrgHTMLTreeRenderer: Sendable {
9 /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
10 public var options: OrgRenderOptions
11 public var highlighter: CodeHighlighter
12
13 public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) {
14 self.options = options
15 self.highlighter = highlighter
16 }
17
18 public func render(_ document: OrgDocument) -> String {
19 var footnotes = FootnoteNumbering(document: document)
20 var html = metadataHeader(document)
21 html += document.elements.map { element($0, &footnotes) }.joined()
22 html += footnotes.renderSection(self)
23 return html
24 }
25
26 /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats
27 /// these as document metadata carried by a page template, so the corpus renders with this
28 /// off; an app showing a README title turns it on.
29 private func metadataHeader(_ document: OrgDocument) -> String {
30 guard options.metadataHeader else { return "" }
31 let title = document.keyword("title")
32 let author = document.keyword("author")
33 let date = document.keyword("date")
34 guard title != nil || author != nil || date != nil else { return "" }
35
36 var html = "<div class=\"org-metadata\">\n"
37 if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" }
38 if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" }
39 if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" }
40 return html + "</div>\n"
41 }
42
43 // MARK: - URL resolution
44
45 /// Resolve a link target to a safe `href`, or nil when it cannot be made safe.
46 ///
47 /// Because the tree keeps targets typed, resolution is a renderer concern applied to the
48 /// `.file` case only no resolver closure has to be threaded through the parse.
49 private func href(for target: OrgLinkTarget) -> String? {
50 switch target {
51 case .id(let identifier):
52 return sanitizedReadmeLinkURLString("#\(identifier)")
53 case .external(let url):
54 return sanitizedReadmeLinkURLString(url)
55 case .file(let path):
56 return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
57 }
58 }
59
60 /// Resolve an image source to a safe `src`, or nil when it cannot be made safe.
61 private func imageSource(_ source: String) -> String? {
62 sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
63 }
64
65 // MARK: - Blocks
66
67 private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
68 switch element {
69 case .heading(let heading):
70 let level = min(6, max(1, heading.level + options.headingLevelOffset))
71 var inner = ""
72 if let todo = heading.todo {
73 inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "#
74 }
75 if let priority = heading.priority {
76 inner += #"<span class="priority">[#\#(priority)]</span> "#
77 }
78 inner += renderInline(heading.title, &notes)
79 for tag in heading.tags {
80 inner += #" <span class="tag">\#(escapeHTML(tag))</span>"#
81 }
82 return "<h\(level)>\(inner)</h\(level)>\n"
83
84 case .paragraph(let objects):
85 return "<p>" + renderInline(objects, &notes) + "</p>\n"
86
87 case .list(let list):
88 return renderList(list, &notes)
89
90 case .table(let table):
91 return renderTable(table, &notes)
92
93 case .srcBlock(let language, let code):
94 let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? ""
95 let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code)
96 return "<pre><code\(classAttribute)>\(body)</code></pre>\n"
97
98 case .exampleBlock(let text):
99 return "<pre>\(escapeHTML(text))</pre>\n"
100
101 case .quoteBlock(let children):
102 return "<blockquote>\n" + children.map { self.element($0, &notes) }.joined() + "</blockquote>\n"
103
104 case .centerBlock(let children):
105 return #"<div class="center">"# + "\n" + children.map { self.element($0, &notes) }.joined() + "</div>\n"
106
107 case .verseBlock(let lines):
108 let body = lines.map { renderInline($0, &notes) }.joined(separator: "<br>\n")
109 return #"<p class="verse">"# + "\n" + body + "\n</p>\n"
110
111 case .specialBlock(let name, let children):
112 return #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n"
113 + children.map { self.element($0, &notes) }.joined() + "</div>\n"
114
115 case .exportBlock(let backend, let raw):
116 return backend == "html" ? raw + "\n" : ""
117
118 case .figure(let figure):
119 guard let tag = imageTag(figure, caption: figure.caption) else {
120 return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n"
121 }
122 // A bare image is a paragraph; a caption or explicit attributes promote it to a
123 // <figure>, matching org's exporter.
124 guard figure.caption != nil || !figure.attributes.isEmpty else {
125 return "<p>" + tag + "</p>\n"
126 }
127 var html = "<figure>" + tag
128 if let caption = figure.caption {
129 notes.figureNumber += 1
130 html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"#
131 + renderInline(caption, &notes) + "</figcaption>"
132 }
133 return html + "</figure>\n"
134
135 case .captioned(let name, let caption, let content):
136 let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
137 var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n"
138 html += self.element(content, &notes)
139 if let caption {
140 html += "<figcaption>" + renderInline(caption, &notes) + "</figcaption>\n"
141 }
142 return html + "</figure>\n"
143
144 case .horizontalRule:
145 return "<hr>\n"
146
147 case .footnoteDefinition:
148 return "" // collected and emitted in the notes section
149 }
150 }
151
152 private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String {
153 if list.kind == .description {
154 var html = "<dl>\n"
155 for item in list.items {
156 if let term = item.term {
157 html += "<dt>" + renderInline(term, &notes) + "</dt>\n"
158 }
159 if let first = item.content.first {
160 html += "<dd>" + renderInline(first, &notes) + "</dd>\n"
161 }
162 }
163 return html + "</dl>\n"
164 }
165
166 let tag = list.kind == .ordered ? "ol" : "ul"
167 var html = "<\(tag)>\n"
168 for item in list.items {
169 html += "<li>"
170 if let checkbox = item.checkbox {
171 switch checkbox {
172 case .off: html += "<code>[&nbsp;]</code> "
173 case .on: html += "<code>[X]</code> "
174 case .partial: html += "<code>[-]</code> "
175 }
176 }
177 if item.content.count <= 1 {
178 html += renderInline(item.content.first ?? [], &notes)
179 } else {
180 html += item.content.map { "<p>" + renderInline($0, &notes) + "</p>" }.joined(separator: "\n")
181 }
182 if let sublist = item.sublist {
183 html += "\n" + renderList(sublist, &notes)
184 }
185 html += "</li>\n"
186 }
187 return html + "</\(tag)>\n"
188 }
189
190 private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String {
191 var html = "<table>\n"
192 var wroteHeader = false
193 var inBody = false
194 let headerCount = table.headerRowCount
195
196 for (index, row) in table.rows.enumerated() {
197 switch row {
198 case .rule:
199 if wroteHeader, !inBody { html += "</thead>\n<tbody>\n"; inBody = true }
200 case .cells(let cells):
201 let isHeader = headerCount > 0 && index < headerCount
202 if isHeader, !wroteHeader { html += "<thead>\n"; wroteHeader = true }
203 if !isHeader, !inBody { html += "<tbody>\n"; inBody = true }
204 html += "<tr>\n"
205 for (column, cell) in cells.enumerated() {
206 let tag = isHeader ? "th" : "td"
207 let alignment = column < table.alignments.count ? table.alignments[column] : nil
208 let style = alignment.map { #" style="text-align: \#($0.rawValue);""# } ?? ""
209 html += "<\(tag)\(style)>" + renderInline(cell, &notes) + "</\(tag)>\n"
210 }
211 html += "</tr>\n"
212 }
213 }
214 if inBody { html += "</tbody>\n" }
215 return html + "</table>\n"
216 }
217
218 /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL
219 /// in which case the caller falls back to text rather than pointing at something unsafe.
220 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
221 guard let source = imageSource(figure.source) else { return nil }
222 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""#
224 for (key, value) in figure.attributes where key != "alt" {
225 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
226 }
227 return html + ">"
228 }
229
230 // MARK: - Inline
231
232 func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String {
233 var html = ""
234 for object in objects {
235 switch object {
236 case .text(let text): html += escapeHTML(text)
237 case .bold(let children): html += "<strong>" + renderInline(children, &notes) + "</strong>"
238 case .italic(let children): html += "<em>" + renderInline(children, &notes) + "</em>"
239 case .underline(let children): html += "<u>" + renderInline(children, &notes) + "</u>"
240 case .strikeThrough(let children): html += "<del>" + renderInline(children, &notes) + "</del>"
241 case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>"
242 case .superscript(let children): html += "<sup>" + renderInline(children, &notes) + "</sup>"
243 case .lineBreak: html += "<br>"
244 case .image(let figure):
245 html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source)
246 case .timestamp(let stamp):
247 let cssClass = stamp.active ? "timestamp" : "timestamp inactive"
248 func time(_ machine: String, _ display: String) -> String {
249 #"<time class="\#(cssClass)" datetime="\#(machine)">\#(display)</time>"#
250 }
251 html += time(stamp.machineValue, stamp.displayValue)
252 // A range is two <time> elements joined by an en-dash, as org exports it.
253 if let end = stamp.end {
254 html += "&#8211;" + time(end.machineValue, end.displayValue)
255 }
256 case .footnoteRef(let label, let inline):
257 let number = notes.number(for: label, inline: inline)
258 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
259 case .link(let link):
260 let text = link.description.map { renderInline($0, &notes) }
261 ?? escapeHTML(displayValue(link.target))
262 // An unsafe or unresolvable target degrades to its text, never a bad anchor.
263 if let href = href(for: link.target) {
264 html += #"<a href="\#(href)">\#(text)</a>"#
265 } else {
266 html += text
267 }
268 }
269 }
270 return html
271 }
272
273 private func displayValue(_ target: OrgLinkTarget) -> String {
274 switch target {
275 case .external(let url): return url
276 case .file(let path): return path
277 case .id(let identifier): return identifier
278 }
279 }
280
281 /// Inline objects reduced to plain text, for an `alt` attribute.
282 func plainText(_ objects: [OrgObject]) -> String {
283 objects.map { object in
284 switch object {
285 case .text(let text): return text
286 case .verbatim(let text), .code(let text): return text
287 case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
288 return plainText(c)
289 case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target)
290 case .timestamp(let stamp): return stamp.displayValue
291 case .image(let figure): return figure.alt ?? ""
292 case .footnoteRef, .lineBreak: return ""
293 }
294 }.joined()
295 }
296}
297
298// MARK: - Footnote numbering
299
300/// Assigns footnote numbers in first-reference order and renders the notes section.
301struct FootnoteNumbering {
302 private var numbers: [String: Int] = [:]
303 private var order: [String] = []
304 /// Reference-style definitions, gathered from the document's `[fn:x] ` lines.
305 private var definitions: [String: [OrgObject]] = [:]
306 /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered.
307 private var inlineDefinitions: [String: [OrgObject]] = [:]
308 var figureNumber = 0
309
310 init(document: OrgDocument) {
311 for element in document.elements {
312 if case .footnoteDefinition(let label, let content) = element {
313 definitions[label] = content
314 }
315 }
316 }
317
318 mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int {
319 if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline }
320 if let existing = numbers[label] { return existing }
321 let next = order.count + 1
322 numbers[label] = next
323 order.append(label)
324 return next
325 }
326
327 mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String {
328 guard !order.isEmpty else { return "" }
329 var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
330 for label in order {
331 let n = numbers[label] ?? 0
332 let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"##
333 // An inline footnote's text sits directly in the item; a reference-style
334 // definition is a paragraph, matching org's exporter.
335 if let inline = inlineDefinitions[label] {
336 html += "<li id=\"fn-\(n)\">\(renderer.renderInline(inline, &self)) \(back)</li>\n"
337 } else {
338 let body = definitions[label].map { renderer.renderInline($0, &self) } ?? ""
339 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
340 }
341 }
342 return html + "</ol>\n</section>\n"
343 }
344}
Sources/OrgSwift/AST/OrgInlineParser.swift added +321
@@ -0,0 +1,321 @@
1import Foundation
2
3// Inline parsing into `[OrgObject]`.
4//
5// The shipped renderer does inline work by regex-substituting HTML into an escaped string,
6// using placeholder tokens to protect what must not be re-scanned. That works for one output
7// format but bakes HTML into the parse. Here the same constructs become a tree, so `*bold
8// /italic/*` nests properly and every renderer decides its own representation.
9
10extension OrgParser {
11
12 /// Inline objects reduced to their plain text for alt text, previews, or assertions.
13 public static func plain(_ objects: [OrgObject]) -> String {
14 objects.map { object in
15 switch object {
16 case .text(let text): return text
17 case .verbatim(let text), .code(let text): return text
18 case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
19 return plain(c)
20 case .link(let link):
21 if let description = link.description { return plain(description) }
22 switch link.target {
23 case .external(let value), .file(let value), .id(let value): return value
24 }
25 case .image(let figure): return figure.alt ?? ""
26 case .timestamp(let stamp): return stamp.displayValue
27 case .footnoteRef, .lineBreak: return ""
28 }
29 }.joined()
30 }
31
32 /// Parse a run of inline org text into objects.
33 public static func parseInline(_ text: String) -> [OrgObject] {
34 var objects: [OrgObject] = []
35 var plain = ""
36 let chars = Array(text)
37 var i = 0
38
39 func flushPlain() {
40 if !plain.isEmpty { objects.append(.text(plain)); plain = "" }
41 }
42
43 while i < chars.count {
44 // Bracket links: [[target]] or [[target][description]]
45 if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[",
46 let link = scanLink(chars, from: i) {
47 flushPlain()
48 objects.append(link.object)
49 i = link.next
50 continue
51 }
52
53 // Footnote reference: [fn:label] or [fn:label:inline]
54 if chars[i] == "[", let note = scanFootnote(chars, from: i) {
55 flushPlain()
56 objects.append(note.object)
57 i = note.next
58 continue
59 }
60
61 // Timestamps: <2024-01-15 Mon 10:30> or [2024-01-15 Mon]
62 if chars[i] == "<" || chars[i] == "[", let stamp = scanTimestamp(chars, from: i) {
63 flushPlain()
64 objects.append(stamp.object)
65 i = stamp.next
66 continue
67 }
68
69 // Bare URL autolink.
70 if chars[i] == "h", let url = scanBareURL(chars, from: i) {
71 flushPlain()
72 objects.append(url.object)
73 i = url.next
74 continue
75 }
76
77 // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token.
78 if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]),
79 let email = scanEmail(chars, from: i) {
80 flushPlain()
81 objects.append(email.object)
82 i = email.next
83 continue
84 }
85
86 // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~
87 if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i),
88 let span = scanEmphasis(chars, from: i, marker: chars[i]) {
89 flushPlain()
90 switch marker {
91 case .bold: objects.append(.bold(parseInline(span.body)))
92 case .italic: objects.append(.italic(parseInline(span.body)))
93 case .underline: objects.append(.underline(parseInline(span.body)))
94 case .strike: objects.append(.strikeThrough(parseInline(span.body)))
95 case .verbatim: objects.append(.verbatim(span.body))
96 case .code: objects.append(.code(span.body))
97 }
98 i = span.next
99 continue
100 }
101
102 // Superscript: x^2 or x^{group}
103 if chars[i] == "^", i > 0, isWordCharacter(chars[i - 1]),
104 let sup = scanSuperscript(chars, from: i) {
105 flushPlain()
106 objects.append(.superscript(parseInline(sup.body)))
107 i = sup.next
108 continue
109 }
110
111 plain.append(chars[i])
112 i += 1
113 }
114 flushPlain()
115 return objects
116 }
117
118 // MARK: - Scanners
119
120 private enum Emphasis { case bold, italic, underline, strike, verbatim, code }
121
122 private static func emphasisMarker(_ c: Character) -> Emphasis? {
123 switch c {
124 case "*": return .bold
125 case "/": return .italic
126 case "_": return .underline
127 case "+": return .strike
128 case "=": return .verbatim
129 case "~": return .code
130 default: return nil
131 }
132 }
133
134 /// org requires the opening marker to follow whitespace or start the run.
135 private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool {
136 i == 0 || chars[i - 1].isWhitespace || "([{'\"".contains(chars[i - 1])
137 }
138
139 private static func isWordCharacter(_ c: Character) -> Bool {
140 c.isLetter || c.isNumber
141 }
142
143 private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character)
144 -> (body: String, next: Int)? {
145 var j = start + 1
146 var body = ""
147 while j < chars.count {
148 if chars[j] == marker {
149 // The closer must end the run or be followed by space/punctuation.
150 let after = j + 1 < chars.count ? chars[j + 1] : " "
151 if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count {
152 return (body, j + 1)
153 }
154 }
155 if chars[j] == "\n" { return nil }
156 body.append(chars[j])
157 j += 1
158 }
159 return nil
160 }
161
162 private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
163 var j = start + 2
164 var target = ""
165 while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) {
166 target.append(chars[j]); j += 1
167 }
168 guard j < chars.count else { return nil }
169
170 var description: String?
171 if chars[j + 1] == "[" {
172 j += 2
173 var text = ""
174 var depth = 0
175 while j < chars.count {
176 if chars[j] == "[" { depth += 1 }
177 if chars[j] == "]" {
178 if depth == 0 { break }
179 depth -= 1
180 }
181 text.append(chars[j]); j += 1
182 }
183 description = text
184 }
185 // Consume the closing ]]
186 while j < chars.count, chars[j] == "]" { j += 1 }
187
188 let object = makeLinkObject(target: target, description: description)
189 return (object, j)
190 }
191
192 private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject {
193 let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget
194
195 // A description that is itself an image makes the image the link's content the
196 // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any
197 // balanced brackets before deciding.
198 if let description {
199 var inner = description
200 while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 {
201 inner = String(inner.dropFirst().dropLast())
202 }
203 let wasWrapped = inner != description
204 let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner
205 if isImagePath(imageSource),
206 wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
207 return .link(OrgLink(target: linkTarget(target),
208 description: [.image(OrgFigure(source: imageSource))]))
209 }
210 return .link(OrgLink(target: linkTarget(target), description: parseInline(description)))
211 }
212
213 if isImagePath(target) { return .image(OrgFigure(source: target)) }
214 return .link(OrgLink(target: linkTarget(target), description: nil))
215 }
216
217 private static func linkTarget(_ target: String) -> OrgLinkTarget {
218 if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) }
219 if target.hasPrefix("http://") || target.hasPrefix("https://")
220 || target.hasPrefix("mailto:") || target.hasPrefix("#") {
221 return .external(target)
222 }
223 return .file(target)
224 }
225
226 private static func isImagePath(_ path: String) -> Bool {
227 let lower = path.lowercased()
228 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
229 .contains { lower.hasSuffix($0) }
230 }
231
232 private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
233 let rest = String(chars[start...])
234 guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil }
235 let label = String(match.1)
236 // An inline footnote defines its note where it is used; parse that text as content.
237 let inline = match.2.map { parseInline(String($0)) }
238 let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound)
239 return (.footnoteRef(label: label, inline: inline), start + consumed)
240 }
241
242 private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
243 let active = chars[start] == "<"
244 let closing: Character = active ? ">" : "]"
245 var j = start + 1
246 var body = ""
247 while j < chars.count, chars[j] != closing {
248 if chars[j] == "\n" { return nil }
249 body.append(chars[j]); j += 1
250 }
251 guard j < chars.count else { return nil }
252 guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil }
253
254 let date = String(dateMatch.1)
255 var time: String?
256 var endTime: String?
257 if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) {
258 time = String(timeMatch.1)
259 if let end = timeMatch.2 { endTime = String(end) }
260 }
261 var next = j + 1
262
263 // A multi-day range joins two stamps with `--`; org models that as one timestamp
264 // carrying an end, so consume the second stamp here rather than leaving `--` as text.
265 var endDate: String?
266 let opening: Character = active ? "<" : "["
267 if next + 2 < chars.count, chars[next] == "-", chars[next + 1] == "-", chars[next + 2] == opening {
268 var k = next + 3
269 var second = ""
270 while k < chars.count, chars[k] != closing { second.append(chars[k]); k += 1 }
271 if k < chars.count, let endMatch = second.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) {
272 endDate = String(endMatch.1)
273 next = k + 1
274 }
275 }
276
277 return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime,
278 endDate: endDate, active: active)), next)
279 }
280
281 private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
282 let rest = String(chars[start...])
283 guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil }
284 var url = String(rest[match.range])
285 while let last = url.last, ".,;:!?".contains(last) { url.removeLast() }
286 return (.link(OrgLink(target: .external(url), description: nil)), start + url.count)
287 }
288
289 /// Characters that keep an email from starting here, mirroring the shipped renderer's
290 /// `(?<![\w.%+\-])` guard.
291 private static func isEmailBoundaryCharacter(_ c: Character) -> Bool {
292 c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-"
293 }
294
295 private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
296 let rest = String(chars[start...])
297 guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
298 return nil
299 }
300 let address = String(rest[match.range])
301 // A trailing character that cannot end an address belongs to the sentence, not the link.
302 guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil }
303 return (.link(OrgLink(target: .external("mailto:\(address)"),
304 description: [.text(address)])), start + address.count)
305 }
306
307 private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? {
308 var j = start + 1
309 guard j < chars.count else { return nil }
310 if chars[j] == "{" {
311 j += 1
312 var body = ""
313 while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 }
314 guard j < chars.count else { return nil }
315 return (body, j + 1)
316 }
317 var body = ""
318 while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 }
319 return body.isEmpty ? nil : (body, j)
320 }
321}
Sources/OrgSwift/AST/OrgParser.swift added +408
@@ -0,0 +1,408 @@
1import Foundation
2
3/// Parses org source into an ``OrgDocument``.
4///
5/// This is the prototype of the AST split: parsing happens once, here, and renderers walk the
6/// result. It is deliberately a separate pipeline from the shipped ``OrgRenderer`` (which goes
7/// straight from source to an HTML string) so the two can be compared before anything migrates.
8public enum OrgParser {
9
10 public static func parse(_ source: String) -> OrgDocument {
11 var document = OrgDocument()
12 let lines = source
13 .replacingOccurrences(of: "\r\n", with: "\n")
14 .replacingOccurrences(of: "\r", with: "\n")
15 .components(separatedBy: "\n")
16 var index = 0
17 var pendingCaption: String?
18 var pendingName: String?
19 var pendingAttrs: [(key: String, value: String)] = []
20
21 func flushPending() {
22 pendingCaption = nil
23 pendingName = nil
24 pendingAttrs = []
25 }
26
27 /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or
28 /// `#+NAME:` precedes it org's exporter turns that pairing into a `<figure>`.
29 func append(_ element: OrgElement) {
30 if pendingCaption != nil || pendingName != nil {
31 document.elements.append(.captioned(
32 name: pendingName,
33 caption: pendingCaption.map(parseInline),
34 content: element
35 ))
36 } else {
37 document.elements.append(element)
38 }
39 }
40
41 while index < lines.count {
42 let line = lines[index]
43 let trimmed = line.trimmingCharacters(in: .whitespaces)
44
45 if trimmed.isEmpty { index += 1; continue }
46
47 // Comments.
48 if trimmed == "#" || trimmed.hasPrefix("# ") { index += 1; continue }
49
50 // Property drawers are heading metadata; org's exporter drops them.
51 if trimmed == ":PROPERTIES:" {
52 index += 1
53 while index < lines.count,
54 lines[index].trimmingCharacters(in: .whitespaces) != ":END:" {
55 index += 1
56 }
57 if index < lines.count { index += 1 }
58 continue
59 }
60
61 // Affiliated keywords and document metadata.
62 if let directive = orgKeywordDirective(in: trimmed) {
63 switch directive.keyword {
64 case "caption": pendingCaption = directive.value
65 case "name": pendingName = directive.value
66 case "attr_html": pendingAttrs = parseAttributes(directive.value)
67 default: document.keywords.append((directive.keyword, directive.value))
68 }
69 index += 1
70 continue
71 }
72
73 // Blocks: #+begin_ / #+end_
74 if trimmed.lowercased().hasPrefix("#+begin_") {
75 let (element, next) = parseBlock(lines, from: index)
76 if let element { append(element) }
77 index = next
78 flushPending()
79 continue
80 }
81
82 // Heading.
83 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
84 document.elements.append(.heading(parseHeading(stars: match.1.count, rest: String(match.2))))
85 index += 1
86 flushPending()
87 continue
88 }
89
90 // Horizontal rule.
91 if isOrgHorizontalRule(trimmed) {
92 document.elements.append(.horizontalRule)
93 index += 1
94 flushPending()
95 continue
96 }
97
98 // Footnote definition.
99 if let def = orgFootnoteDefinition(in: trimmed) {
100 document.elements.append(.footnoteDefinition(label: def.label, content: parseInline(def.text)))
101 index += 1
102 flushPending()
103 continue
104 }
105
106 // A standalone image link, promoted to a figure by an affiliated caption/attrs.
107 if let path = standaloneOrgImage(in: trimmed) {
108 document.elements.append(.figure(OrgFigure(
109 source: path,
110 caption: pendingCaption.map(parseInline),
111 attributes: pendingAttrs
112 )))
113 index += 1
114 flushPending()
115 continue
116 }
117
118 // Table.
119 if isTableLine(trimmed) {
120 let (table, next) = parseTable(lines, from: index)
121 append(.table(table))
122 index = next
123 flushPending()
124 continue
125 }
126
127 // List.
128 if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) {
129 let (list, next) = parseList(lines, from: index)
130 document.elements.append(.list(list))
131 index = next
132 flushPending()
133 continue
134 }
135
136 // Paragraph: consume until a blank line or a line that starts another construct.
137 var paragraph: [String] = []
138 while index < lines.count {
139 let candidate = lines[index]
140 let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces)
141 if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break }
142 paragraph.append(candidateTrimmed)
143 index += 1
144 }
145 if !paragraph.isEmpty {
146 document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " "))))
147 }
148 flushPending()
149 }
150
151 return document
152 }
153
154 /// Would this line begin a construct other than the paragraph currently being consumed?
155 private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool {
156 if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true }
157 if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true }
158 if isOrgHorizontalRule(trimmed) { return true }
159 if isTableLine(trimmed) { return true }
160 if orgFootnoteDefinition(in: trimmed) != nil { return true }
161 if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true }
162 return false
163 }
164
165 // MARK: - Heading
166
167 private static func parseHeading(stars: Int, rest: String) -> OrgHeading {
168 var body = rest
169 var todo: String?
170 var priority: Character?
171
172 for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") {
173 todo = keyword
174 body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces)
175 break
176 }
177 if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) {
178 priority = Character(String(match.1))
179 body = String(body[match.range.upperBound...])
180 }
181 let (title, tags) = splitHeadingTags(body)
182 return OrgHeading(level: stars, todo: todo, priority: priority,
183 title: parseInline(title), tags: tags)
184 }
185
186 // MARK: - Blocks
187
188 private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) {
189 let opener = lines[start].trimmingCharacters(in: .whitespaces)
190 let lower = opener.lowercased()
191 let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? ""
192 let argument = opener
193 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
194 .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) }
195
196 var body: [String] = []
197 var index = start + 1
198 while index < lines.count {
199 let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased()
200 if trimmed == "#+end_\(name)" { index += 1; break }
201 body.append(lines[index])
202 index += 1
203 }
204
205 switch name {
206 case "src":
207 return (.srcBlock(language: argument?.isEmpty == false ? argument : nil,
208 code: body.joined(separator: "\n")), index)
209 case "example":
210 return (.exampleBlock(body.joined(separator: "\n")), index)
211 case "quote":
212 return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index)
213 case "center":
214 return (.centerBlock(parse(body.joined(separator: "\n")).elements), index)
215 case "verse":
216 return (.verseBlock(body.map(parseInline)), index)
217 case "export":
218 return (.exportBlock(backend: (argument ?? "").lowercased(),
219 raw: body.joined(separator: "\n")), index)
220 default:
221 return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index)
222 }
223 }
224
225 // MARK: - Table
226
227 private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) {
228 var rows: [OrgTableRow] = []
229 var alignments: [OrgAlignment?] = []
230 var index = start
231
232 while index < lines.count {
233 let trimmed = lines[index].trimmingCharacters(in: .whitespaces)
234 guard isTableLine(trimmed) else { break }
235
236 // A separator row's columns are divided by `+`, not `|`, so it needs its own
237 // split `|:---+---:|` is two columns, which splitting on `|` would miss.
238 let separatorCells = parseOrgTableSeparatorRow(trimmed)
239 let cells = separatorCells.allSatisfy(isTableSeparatorCell)
240 ? separatorCells : parseTableRow(trimmed)
241 if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) {
242 rows.append(.rule)
243 let parsed = cells.map { cell -> OrgAlignment? in
244 switch tableAlignment(for: cell) {
245 case "left": return .left
246 case "center": return .center
247 case "right": return .right
248 default: return nil
249 }
250 }
251 if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed }
252 } else {
253 rows.append(.cells(cells.map(parseInline)))
254 }
255 index += 1
256 }
257 return (OrgTable(rows: rows, alignments: alignments), index)
258 }
259
260 // MARK: - List
261
262 private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) {
263 var block: [String] = []
264 var index = start
265 var pendingBlanks: [String] = []
266 // A top-level marker of the other kind starts a *separate* list: an ordered list
267 // followed by a bullet list is two lists, not one with mixed items.
268 let startsOrdered = orderedListItem(in: lines[start].trimmingCharacters(in: .whitespaces)) != nil
269
270 while index < lines.count {
271 let line = lines[index]
272 let trimmed = line.trimmingCharacters(in: .whitespaces)
273 if trimmed.isEmpty {
274 pendingBlanks.append(line); index += 1; continue
275 }
276 if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) {
277 guard (orderedListItem(in: trimmed) != nil) == startsOrdered else { break }
278 block.append(contentsOf: pendingBlanks); pendingBlanks = []
279 block.append(line); index += 1; continue
280 }
281 if isIndentedContinuationLine(line) {
282 block.append(contentsOf: pendingBlanks); pendingBlanks = []
283 block.append(line); index += 1; continue
284 }
285 break
286 }
287 return (buildList(block), index)
288 }
289
290 /// Group a list block's lines into items, recursing for nested lists.
291 private static func buildList(_ lines: [String]) -> OrgList {
292 let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
293 .map(leadingWidth).min() ?? 0
294 let normalized = lines.map { dropLeading($0, base) }
295
296 var groups: [[String]] = []
297 var current: [String] = []
298 for line in normalized {
299 if isListMarkerLine(line) {
300 if !current.isEmpty { groups.append(current) }
301 current = [line]
302 } else if !current.isEmpty {
303 current.append(line)
304 }
305 }
306 if !current.isEmpty { groups.append(current) }
307
308 let firstMarker = groups.first?.first ?? ""
309 var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered
310 if stripMarker(firstMarker).contains(" :: ") { kind = .description }
311
312 let items = groups.map { buildItem($0, kind: kind) }
313 return OrgList(kind: kind, items: items)
314 }
315
316 private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem {
317 var head = stripMarker(lines[0])
318 var checkbox: OrgCheckbox?
319 if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) }
320 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) }
321 else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) }
322
323 let rest = Array(lines.dropFirst())
324 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
325 .map(leadingWidth).min() ?? 0
326 let outdented = rest.map { dropLeading($0, childIndent) }
327
328 var paragraphs: [String] = []
329 var currentParagraph = [head]
330 var sublistLines: [String] = []
331 var inSublist = false
332
333 func flush() {
334 let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces)
335 if !joined.isEmpty { paragraphs.append(joined) }
336 currentParagraph = []
337 }
338
339 for line in outdented {
340 let trimmed = line.trimmingCharacters(in: .whitespaces)
341 if isListMarkerLine(line) || inSublist {
342 if !inSublist { flush() }
343 inSublist = true
344 sublistLines.append(line)
345 } else if trimmed.isEmpty {
346 flush()
347 } else {
348 currentParagraph.append(trimmed)
349 }
350 }
351 flush()
352
353 var term: [OrgObject]?
354 var content = paragraphs
355 if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") {
356 term = parseInline(String(first[..<range.lowerBound]))
357 content[0] = String(first[range.upperBound...])
358 }
359
360 return OrgListItem(
361 checkbox: checkbox,
362 term: term,
363 content: content.map(parseInline),
364 sublist: sublistLines.isEmpty ? nil : buildList(sublistLines)
365 )
366 }
367
368 // MARK: - Helpers
369
370 private static func parseAttributes(_ value: String) -> [(key: String, value: String)] {
371 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
372 return []
373 }
374 let ns = value as NSString
375 return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in
376 var raw = ns.substring(with: m.range(at: 2))
377 if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) }
378 return (ns.substring(with: m.range(at: 1)).lowercased(), raw)
379 }
380 }
381
382 private static func stripMarker(_ line: String) -> String {
383 let trimmed = line.trimmingCharacters(in: .whitespaces)
384 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) }
385 if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) }
386 return trimmed
387 }
388
389 private static func leadingWidth(_ line: String) -> Int {
390 var count = 0
391 for ch in line {
392 if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break }
393 }
394 return count
395 }
396
397 private static func dropLeading(_ line: String, _ n: Int) -> String {
398 var dropped = 0
399 var index = line.startIndex
400 while index < line.endIndex, dropped < n {
401 if line[index] == " " { dropped += 1 }
402 else if line[index] == "\t" { dropped += 8 }
403 else { break }
404 index = line.index(after: index)
405 }
406 return String(line[index...])
407 }
408}
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 +3 −51
@@ -12,7 +12,7 @@ func parseTableRow(_ line: String) -> [String] {
1212 .map { String($0).trimmingCharacters(in: .whitespaces) }
1313 }
1414
15private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
15func parseOrgTableSeparatorRow(_ line: String) -> [String] {
1616 var content = line.trimmingCharacters(in: .whitespaces)
1717 if content.hasPrefix("|") {
1818 content.removeFirst()
@@ -25,11 +25,11 @@ private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
2525 .map { String($0).trimmingCharacters(in: .whitespaces) }
2626 }
2727
28private func isTableSeparatorCell(_ cell: String) -> Bool {
28func isTableSeparatorCell(_ cell: String) -> Bool {
2929 tableAlignment(for: cell) != nil
3030 }
3131
32private func tableAlignment(for cell: String) -> String? {
32func tableAlignment(for cell: String) -> String? {
3333 let trimmed = cell.trimmingCharacters(in: .whitespaces)
3434 guard !trimmed.isEmpty else { return nil }
3535
@@ -51,51 +51,3 @@ private 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/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}