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

html library org-mode swift

Commit e2f8315a9c

e2f8315a9c1ced9231b3830dfb61fd546f5fadf1

Email mismatch · cmc

Christian Cleberg <claude@cleberg.net> · 2026-08-27T15:57:47Z

OrgSwift: extract org renderer from hutch
.gitignore added +4
@@ -0,0 +1,4 @@
1.build/
2.swiftpm/
3*.xcodeproj
4.DS_Store
Package.swift added +20
@@ -0,0 +1,20 @@
1// swift-tools-version: 5.9
2import PackageDescription
3
4let package = Package(
5 name: "OrgSwift",
6 platforms: [
7 .macOS(.v13),
8 .iOS(.v16)
9 ],
10 products: [
11 .library(name: "OrgSwift", targets: ["OrgSwift"])
12 ],
13 targets: [
14 .target(
15 name: "OrgSwift",
16 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
17 ),
18 .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"])
19 ]
20)
README.md added +71
@@ -0,0 +1,71 @@
1# OrgSwift
2
3A dependency-free Swift library that renders a practical subset of
4[org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no
5SwiftUI, WebKit, UIKit, or third-party packages.
6
7Extracted from the hand-rolled renderer in the Hutch iOS client so it can be
8shared across apps.
9
10## Supported syntax
11
12Headings, paragraphs, ordered/unordered lists (with nesting, wrapped lines, and
13`[ ]`/`[x]` task checkboxes), tables (with `:---:` alignment), `#+begin_src` /
14`example` / `quote` / `center` / `verse` blocks, property drawers,
15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links
16and linked images (`[[dest][label]]`), horizontal rules, comments, and inline
17markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, `_underline_`,
18email autolinks).
19
20Output is sanitized: only `http`/`https`/`mailto` link schemes and
21`http`/`https` image schemes are allowed; everything else is dropped.
22
23## Usage
24
25```swift
26import OrgSwift
27
28let html = OrgRenderer.renderToHTML(orgSource)
29```
30
31### Relative links
32
33Pass an `OrgRenderOptions` to rewrite repository-relative links and images to
34absolute `blob` URLs. When `owner`/`repositoryName` are nil (the default),
35relative links are left as-is, which the scheme allowlist then drops.
36
37```swift
38let html = OrgRenderer.renderToHTML(
39 orgSource,
40 options: OrgRenderOptions(
41 host: "git.sr.ht", // default
42 owner: "~ccleberg",
43 repositoryName: "Hutch",
44 ref: "HEAD", // default
45 readmePath: "README.org" // resolves paths relative to this file
46 )
47)
48```
49
50`./images/badge.svg` then resolves to
51`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`.
52
53### Syntax highlighting
54
55Code blocks are highlighted through a protocol so the library carries no
56highlighter dependency:
57
58```swift
59public protocol CodeHighlighter {
60 func highlightedHTML(code: String, language: String?) -> String?
61}
62```
63
64The default `PlainCodeHighlighter` returns `nil`, so blocks fall back to escaped
65`<pre><code>`. Provide your own conformer to plug in a real highlighter:
66
67```swift
68let html = OrgRenderer.renderToHTML(orgSource, highlighter: MyHighlighter())
69```
70
71A conformer returning `nil` for a given block gets the same escaped fallback.
Sources/OrgSwift/Escaping.swift added +122
@@ -0,0 +1,122 @@
1import Foundation
2
3// MARK: - HTML Escaping
4
5func escapeHTML(_ text: String) -> String {
6 text.replacingOccurrences(of: "&", with: "&amp;")
7 .replacingOccurrences(of: "<", with: "&lt;")
8 .replacingOccurrences(of: ">", with: "&gt;")
9 .replacingOccurrences(of: "\"", with: "&quot;")
10}
11
12func escapeHTMLAttribute(_ text: String) -> String {
13 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
14}
15
16// MARK: - URL Sanitization
17
18func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
19 sanitizeReadmeURLString(
20 rawURL,
21 allowedSchemes: ["http", "https", "mailto"],
22 allowsFragmentOnly: true
23 )
24}
25
26func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
27 sanitizeReadmeURLString(
28 rawURL,
29 allowedSchemes: ["http", "https"],
30 allowsFragmentOnly: false
31 )
32}
33
34private func sanitizeReadmeURLString(
35 _ rawURL: String,
36 allowedSchemes: Set<String>,
37 allowsFragmentOnly: Bool
38) -> String? {
39 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
40 guard !trimmedURL.isEmpty else { return nil }
41
42 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
43 return escapeHTMLAttribute(trimmedURL)
44 }
45
46 guard let components = URLComponents(string: trimmedURL),
47 let scheme = components.scheme?.lowercased(),
48 allowedSchemes.contains(scheme),
49 let sanitizedURL = components.url?.absoluteString else {
50 return nil
51 }
52
53 return escapeHTMLAttribute(sanitizedURL)
54}
55
56// MARK: - Regex Helpers
57
58func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
59 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
60 return nil
61 }
62 return (
63 keyword: String(match.1).lowercased(),
64 value: String(match.2).trimmingCharacters(in: .whitespaces)
65 )
66}
67
68func isOrgHorizontalRule(_ line: String) -> Bool {
69 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
70}
71
72func matchesRegex(_ text: String, pattern: String) -> Bool {
73 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
74 let range = NSRange(location: 0, length: (text as NSString).length)
75 return regex.firstMatch(in: text, range: range) != nil
76}
77
78func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
79 guard range.location != NSNotFound else { return false }
80 let prefix = text.substring(to: range.location)
81 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
82 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
83 return lastOpen > lastClose
84}
85
86func protectMatches(
87 in text: String,
88 pattern: String,
89 protectedFragments: inout [String: String],
90 transform: (NSTextCheckingResult, NSString) -> String
91) -> String {
92 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
93 var result = text
94 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
95
96 for match in matches.reversed() {
97 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
98 let nsText = result as NSString
99 protectedFragments[token] = transform(match, nsText)
100 result = nsText.replacingCharacters(in: match.range, with: token)
101 }
102
103 return result
104}
105
106func replaceMatches(
107 in text: String,
108 pattern: String,
109 transform: (NSTextCheckingResult, NSString) -> String
110) -> String {
111 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
112 var result = text
113 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
114
115 for match in matches.reversed() {
116 let nsText = result as NSString
117 let replacement = transform(match, nsText)
118 result = nsText.replacingCharacters(in: match.range, with: replacement)
119 }
120
121 return result
122}
Sources/OrgSwift/Inline.swift added +99
@@ -0,0 +1,99 @@
1import Foundation
2
3func processOrgInline(
4 _ text: String,
5 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil
7) -> String {
8 var result = escapeHTML(text)
9 var protectedFragments: [String: String] = [:]
10
11 result = protectMatches(
12 in: result,
13 pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
14 protectedFragments: &protectedFragments
15 ) { match, nsText in
16 let destination = nsText.substring(with: match.range(at: 1))
17 let source = nsText.substring(with: match.range(at: 2))
18 guard let imageHTML = makeOrgImageHTML(
19 source: source,
20 alt: nil,
21 imageURLResolver: imageURLResolver
22 ) else {
23 return source
24 }
25 let resolvedDestination = linkURLResolver?(destination) ?? destination
26 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
27 return imageHTML
28 }
29 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
30 }
31
32 result = protectOrgLinks(
33 in: result,
34 protectedFragments: &protectedFragments,
35 imageURLResolver: imageURLResolver,
36 linkURLResolver: linkURLResolver
37 )
38 result = protectMatches(
39 in: result,
40 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
41 protectedFragments: &protectedFragments
42 ) { match, nsText in
43 let tildeRange = match.range(at: 1)
44 let equalsRange = match.range(at: 2)
45 let codeText: String
46 if tildeRange.location != NSNotFound {
47 codeText = nsText.substring(with: tildeRange)
48 } else {
49 codeText = nsText.substring(with: equalsRange)
50 }
51 return "<code>\(codeText)</code>"
52 }
53 result = protectMatches(
54 in: result,
55 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
56 protectedFragments: &protectedFragments
57 ) { match, nsText in
58 let value = nsText.substring(with: match.range(at: 1))
59 return "<del>\(value)</del>"
60 }
61 result = protectMatches(
62 in: result,
63 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
64 protectedFragments: &protectedFragments
65 ) { match, nsText in
66 let value = nsText.substring(with: match.range(at: 1))
67 return "<u>\(value)</u>"
68 }
69
70 // Bold: *text*
71 result = result.replacingOccurrences(
72 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
73 with: "<strong>$1</strong>",
74 options: .regularExpression
75 )
76 // Italic: /text/
77 result = result.replacingOccurrences(
78 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
79 with: "<em>$1</em>",
80 options: .regularExpression
81 )
82 result = replaceMatches(
83 in: result,
84 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
85 ) { match, nsText in
86 guard !isInsideHTMLTag(nsText, range: match.range) else {
87 return nsText.substring(with: match.range)
88 }
89 let email = nsText.substring(with: match.range(at: 1))
90 let href = escapeHTMLAttribute("mailto:\(email)")
91 return #"<a href="\#(href)">\#(email)</a>"#
92 }
93
94 for (token, fragment) in protectedFragments {
95 result = result.replacingOccurrences(of: token, with: fragment)
96 }
97
98 return result
99}
Sources/OrgSwift/Links.swift added +207
@@ -0,0 +1,207 @@
1import Foundation
2
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 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
36 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
37 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
38 }
39
40 let destination = String(text[index..<destinationEnd])
41 index = text.index(destinationEnd, offsetBy: 2)
42 let labelStart = index
43 var depth = 0
44
45 while index < text.endIndex {
46 if text[index...].hasPrefix("[[") {
47 depth += 1
48 index = text.index(index, offsetBy: 2)
49 continue
50 }
51 if text[index...].hasPrefix("]]") {
52 if depth == 0 {
53 let end = text.index(index, offsetBy: 2)
54 return (start..<end, destination, String(text[labelStart..<index]))
55 }
56 depth -= 1
57 index = text.index(index, offsetBy: 2)
58 continue
59 }
60 index = text.index(after: index)
61 }
62
63 return nil
64}
65
66private func renderOrgLink(
67 destination: String,
68 label: String?,
69 imageURLResolver: ((String) -> String?)? = nil,
70 linkURLResolver: ((String) -> String?)? = nil
71) -> String {
72 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
73 let source = String(label.dropFirst(2).dropLast(2))
74 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
75 let resolvedDestination = linkURLResolver?(destination) ?? destination
76 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
77 return imageHTML
78 }
79 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
80 }
81 }
82
83 if let imageHTML = makeOrgImageHTML(
84 source: destination,
85 alt: label,
86 imageURLResolver: imageURLResolver
87 ) {
88 return imageHTML
89 }
90
91 let resolvedDestination = linkURLResolver?(destination) ?? destination
92 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
93 return label ?? destination
94 }
95
96 let renderedLabel = label.map {
97 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
98 } ?? destination
99 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
100}
101
102func makeOrgImageHTML(
103 source: String,
104 alt: String?,
105 imageURLResolver: ((String) -> String?)?
106) -> String? {
107 guard isRenderableImageSource(source) else { return nil }
108 let resolvedSource = imageURLResolver?(source) ?? source
109 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
110 let altText = escapeHTMLAttribute(alt ?? "")
111 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
112}
113
114private func isRenderableImageSource(_ source: String) -> Bool {
115 let lowercased = source.lowercased()
116 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
117 .contains(where: { lowercased.hasSuffix($0) })
118}
119
120// MARK: - Relative link/image resolution
121
122func resolveRepositoryLinkURL(
123 _ source: String,
124 host: String,
125 owner: String,
126 repositoryName: String,
127 ref: String,
128 readmePath: String?
129) -> String? {
130 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
131 guard !trimmedSource.isEmpty else { return nil }
132
133 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
134 || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
135 return trimmedSource
136 }
137
138 return resolveRepositoryAssetURL(
139 trimmedSource,
140 host: host,
141 owner: owner,
142 repositoryName: repositoryName,
143 ref: ref,
144 readmePath: readmePath
145 )
146}
147
148func resolveRepositoryAssetURL(
149 _ source: String,
150 host: String,
151 owner: String,
152 repositoryName: String,
153 ref: String,
154 readmePath: String?
155) -> String? {
156 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
157 guard !trimmedSource.isEmpty else { return nil }
158
159 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
160 return trimmedSource
161 }
162
163 let relativePath: String
164 if trimmedSource.hasPrefix("/") {
165 relativePath = String(trimmedSource.dropFirst())
166 } else {
167 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
168 relativePath = normalizeRepositoryPath(
169 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
170 )
171 }
172
173 guard !relativePath.isEmpty else { return nil }
174 var components = URLComponents()
175 components.scheme = "https"
176 components.host = host
177 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
178 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
179 let encodedRef = ref.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? ref
180 let encodedRelativePath = relativePath
181 .split(separator: "/", omittingEmptySubsequences: false)
182 .map { segment in
183 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
184 }
185 .joined(separator: "/")
186 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/\(encodedRef)/\(encodedRelativePath)"
187 return components.string
188}
189
190private func normalizeRepositoryPath(_ path: String) -> String {
191 var components: [String] = []
192
193 for part in path.split(separator: "/") {
194 switch part {
195 case ".":
196 continue
197 case "..":
198 if !components.isEmpty {
199 components.removeLast()
200 }
201 default:
202 components.append(String(part))
203 }
204 }
205
206 return components.joined(separator: "/")
207}
Sources/OrgSwift/Lists.swift added +147
@@ -0,0 +1,147 @@
1import Foundation
2
3enum OrgListType: Equatable {
4 case unordered
5 case ordered
6}
7
8func orderedListItem(in line: String) -> String? {
9 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
10 return String(match.2)
11}
12
13func renderOrgListItemBody(
14 _ lines: [String],
15 imageURLResolver: ((String) -> String?)? = nil,
16 linkURLResolver: ((String) -> String?)? = nil
17) -> String {
18 guard let firstLine = lines.first else { return "" }
19
20 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
21 var nestedLines: [String] = []
22
23 for line in lines.dropFirst() {
24 let trimmed = line.trimmingCharacters(in: .whitespaces)
25 if trimmed.isEmpty {
26 continue
27 }
28
29 if isIndentedListItemLine(line) {
30 nestedLines.append(outdentOrgListLine(line))
31 } else {
32 contentLines.append(trimmed)
33 }
34 }
35
36 var html = renderTaskListItem(
37 contentLines.joined(separator: " "),
38 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
39 )
40 if !nestedLines.isEmpty {
41 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
42 }
43 return html
44}
45
46private func renderNestedOrgListHTML(
47 _ lines: [String],
48 imageURLResolver: ((String) -> String?)? = nil,
49 linkURLResolver: ((String) -> String?)? = nil
50) -> String {
51 var html = ""
52 var listType: OrgListType?
53 var currentItemLines: [String] = []
54
55 func flushNestedItem() {
56 guard !currentItemLines.isEmpty else { return }
57 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
58 currentItemLines = []
59 }
60
61 func closeNestedList() {
62 flushNestedItem()
63 switch listType {
64 case .unordered:
65 html += "</ul>\n"
66 case .ordered:
67 html += "</ol>\n"
68 case nil:
69 break
70 }
71 listType = nil
72 }
73
74 for line in lines {
75 let trimmed = line.trimmingCharacters(in: .whitespaces)
76 if trimmed.hasPrefix("- ") {
77 if listType != .unordered {
78 closeNestedList()
79 html += "<ul>\n"
80 listType = .unordered
81 }
82 flushNestedItem()
83 currentItemLines = [String(trimmed.dropFirst(2))]
84 continue
85 }
86
87 if let orderedItem = orderedListItem(in: trimmed) {
88 if listType != .ordered {
89 closeNestedList()
90 html += "<ol>\n"
91 listType = .ordered
92 }
93 flushNestedItem()
94 currentItemLines = [orderedItem]
95 continue
96 }
97
98 if listType != nil {
99 currentItemLines.append(line)
100 }
101 }
102
103 closeNestedList()
104 return html
105}
106
107func isIndentedContinuationLine(_ line: String) -> Bool {
108 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
109 guard let first = line.first else { return false }
110 return first == " " || first == "\t"
111}
112
113private func isIndentedListItemLine(_ line: String) -> Bool {
114 guard isIndentedContinuationLine(line) else { return false }
115 let trimmed = line.trimmingCharacters(in: .whitespaces)
116 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
117}
118
119private func outdentOrgListLine(_ line: String) -> String {
120 var result = line
121 while result.first == " " || result.first == "\t" {
122 result.removeFirst()
123 }
124 return result
125}
126
127func renderTaskListItem(
128 _ text: String,
129 inlineRenderer: (String) -> String
130) -> String {
131 let trimmed = text.trimmingCharacters(in: .whitespaces)
132 guard trimmed.count >= 4 else {
133 return inlineRenderer(text)
134 }
135
136 let prefix = String(trimmed.prefix(4))
137 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
138
139 switch prefix {
140 case "[ ] ":
141 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
142 case "[x] ", "[X] ":
143 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
144 default:
145 return inlineRenderer(text)
146 }
147}
Sources/OrgSwift/OrgRenderer.swift added +529
@@ -0,0 +1,529 @@
1import Foundation
2
3/// Produces highlighted HTML for a fenced code block. Returning `nil` makes the
4/// renderer fall back to an escaped `<pre><code>` block.
5public protocol CodeHighlighter {
6 func highlightedHTML(code: String, language: String?) -> String?
7}
8
9/// A highlighter that performs no highlighting. The renderer escapes the code
10/// and wraps it in `<pre><code>` when this is used.
11public struct PlainCodeHighlighter: CodeHighlighter {
12 public init() {}
13 public func highlightedHTML(code: String, language: String?) -> String? { nil }
14}
15
16/// Options controlling how relative links and images are resolved.
17///
18/// When `owner` and `repositoryName` are both non-nil, relative links and image
19/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
20/// When either is nil, relative links are left as-is (and dropped by the
21/// URL-scheme allowlist, which only permits absolute http/https/mailto).
22public struct OrgRenderOptions {
23 /// Host used to build absolute URLs for repository-relative links.
24 public var host: String
25 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
26 public var owner: String?
27 /// Repository name. Nil disables relative-link resolution.
28 public var repositoryName: String?
29 /// Git ref used in the `blob/<ref>` path segment.
30 public var ref: String
31 /// Path of the README being rendered, used to resolve paths relative to it.
32 public var readmePath: String?
33
34 public init(
35 host: String = "git.sr.ht",
36 owner: String? = nil,
37 repositoryName: String? = nil,
38 ref: String = "HEAD",
39 readmePath: String? = nil
40 ) {
41 self.host = host
42 self.owner = owner
43 self.repositoryName = repositoryName
44 self.ref = ref
45 self.readmePath = readmePath
46 }
47
48 func imageURLResolver() -> ((String) -> String?)? {
49 guard let owner, let repositoryName else { return nil }
50 let host = host
51 let ref = ref
52 let readmePath = readmePath
53 return { source in
54 resolveRepositoryAssetURL(
55 source,
56 host: host,
57 owner: owner,
58 repositoryName: repositoryName,
59 ref: ref,
60 readmePath: readmePath
61 )
62 }
63 }
64
65 func linkURLResolver() -> ((String) -> String?)? {
66 guard let owner, let repositoryName else { return nil }
67 let host = host
68 let ref = ref
69 let readmePath = readmePath
70 return { source in
71 resolveRepositoryLinkURL(
72 source,
73 host: host,
74 owner: owner,
75 repositoryName: repositoryName,
76 ref: ref,
77 readmePath: readmePath
78 )
79 }
80 }
81}
82
83/// Renders org-mode source to sanitized HTML.
84public enum OrgRenderer {
85 public static func renderToHTML(
86 _ source: String,
87 options: OrgRenderOptions = .init(),
88 highlighter: CodeHighlighter = PlainCodeHighlighter()
89 ) -> String {
90 orgToHTML(
91 source,
92 highlighter: highlighter,
93 imageURLResolver: options.imageURLResolver(),
94 linkURLResolver: options.linkURLResolver()
95 )
96 }
97}
98
99// MARK: - Org-mode to HTML
100
101func orgToHTML(
102 _ text: String,
103 highlighter: CodeHighlighter,
104 imageURLResolver: ((String) -> String?)? = nil,
105 linkURLResolver: ((String) -> String?)? = nil
106) -> String {
107 let normalizedText = text
108 .replacingOccurrences(of: "\r\n", with: "\n")
109 .replacingOccurrences(of: "\r", with: "\n")
110 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
111 var title: String?
112 var author: String?
113 var date: String?
114 let lines = rawLines.filter { line in
115 let trimmed = line.trimmingCharacters(in: .whitespaces)
116 guard let directive = orgKeywordDirective(in: trimmed) else {
117 return true
118 }
119 switch directive.keyword {
120 case "title":
121 title = directive.value
122 return false
123 case "author":
124 author = directive.value
125 return false
126 case "date":
127 date = directive.value
128 return false
129 default:
130 return true
131 }
132 }
133 var html = ""
134 var listType: OrgListType?
135 var inQuoteBlock = false
136 var inPropertyDrawer = false
137 var srcLanguage: String?
138 var srcLines: [String] = []
139 var inExampleBlock = false
140 var inCenterBlock = false
141 var inVerseBlock = false
142 var currentListItemLines: [String] = []
143 var paragraph: [String] = []
144 var tableRows: [[String]] = []
145 var propertyRows: [(String, String)] = []
146 var verseLines: [String] = []
147 var pendingBlockName: String?
148 var pendingBlockCaption: String?
149 var activeBlockCaption: String?
150 var isWrappingBlockFigure = false
151
152 func beginPendingBlockWrapperIfNeeded() {
153 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
154 let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
155 html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
156 activeBlockCaption = pendingBlockCaption
157 isWrappingBlockFigure = true
158 pendingBlockName = nil
159 pendingBlockCaption = nil
160 }
161
162 func closePendingBlockWrapper() {
163 guard isWrappingBlockFigure else { return }
164 if let activeBlockCaption {
165 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
166 }
167 html += "</figure>\n"
168 activeBlockCaption = nil
169 isWrappingBlockFigure = false
170 }
171
172 func flushParagraph() {
173 if !paragraph.isEmpty {
174 let normalizedParagraph = paragraph
175 .map { $0.trimmingCharacters(in: .whitespaces) }
176 .joined(separator: " ")
177 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
178 paragraph = []
179 }
180 }
181
182 func flushListItem() {
183 guard !currentListItemLines.isEmpty else { return }
184 html += "<li>" + renderOrgListItemBody(
185 currentListItemLines,
186 imageURLResolver: imageURLResolver,
187 linkURLResolver: linkURLResolver
188 ) + "</li>\n"
189 currentListItemLines = []
190 }
191
192 func closeList() {
193 flushListItem()
194 switch listType {
195 case .unordered:
196 html += "</ul>\n"
197 case .ordered:
198 html += "</ol>\n"
199 case nil:
200 break
201 }
202 listType = nil
203 }
204
205 func flushTable() {
206 guard !tableRows.isEmpty else { return }
207 beginPendingBlockWrapperIfNeeded()
208 html += renderHTMLTable(
209 rows: tableRows,
210 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
211 )
212 closePendingBlockWrapper()
213 tableRows = []
214 }
215
216 func flushPropertyDrawer() {
217 guard !propertyRows.isEmpty else { return }
218 html += "<dl class=\"org-properties\">\n"
219 for (key, value) in propertyRows {
220 html += "<dt>" + escapeHTML(key) + "</dt>"
221 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
222 }
223 html += "</dl>\n"
224 propertyRows = []
225 }
226
227 func closeQuoteBlock() {
228 if inQuoteBlock {
229 flushParagraph()
230 html += "</blockquote>\n"
231 inQuoteBlock = false
232 }
233 }
234
235 func closeSourceBlock() {
236 if let language = srcLanguage {
237 let code = srcLines.joined(separator: "\n")
238 if !code.isEmpty {
239 let highlighted = highlighter.highlightedHTML(code: code, language: language.isEmpty ? nil : language)
240 html += (highlighted ?? escapeHTML(code)) + "\n"
241 }
242 html += "</code></pre>\n"
243 srcLanguage = nil
244 srcLines = []
245 closePendingBlockWrapper()
246 }
247 }
248
249 func closeExampleBlock() {
250 if inExampleBlock {
251 html += "</code></pre>\n"
252 inExampleBlock = false
253 closePendingBlockWrapper()
254 }
255 }
256
257 func closeCenterBlock() {
258 if inCenterBlock {
259 flushParagraph()
260 html += "</div>\n"
261 inCenterBlock = false
262 closePendingBlockWrapper()
263 }
264 }
265
266 func closeVerseBlock() {
267 if inVerseBlock {
268 let content = verseLines
269 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
270 .joined(separator: "\n")
271 html += #"<blockquote class="org-verse">"# + "\n"
272 html += content + "\n"
273 html += "</blockquote>\n"
274 verseLines = []
275 inVerseBlock = false
276 closePendingBlockWrapper()
277 }
278 }
279
280 func flushBlockState() {
281 flushParagraph()
282 closeList()
283 flushTable()
284 flushPropertyDrawer()
285 }
286
287 if title != nil || author != nil || date != nil {
288 html += "<div class=\"org-metadata\">\n"
289 if let title {
290 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
291 }
292 if let author {
293 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
294 }
295 if let date {
296 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
297 }
298 html += "</div>\n"
299 }
300
301 for line in lines {
302 let trimmed = line.trimmingCharacters(in: .whitespaces)
303
304 if srcLanguage != nil {
305 if trimmed.lowercased() == "#+end_src" {
306 closeSourceBlock()
307 } else {
308 srcLines.append(line)
309 }
310 continue
311 }
312
313 if inExampleBlock {
314 if trimmed.lowercased() == "#+end_example" {
315 closeExampleBlock()
316 } else {
317 html += escapeHTML(line) + "\n"
318 }
319 continue
320 }
321
322 if inVerseBlock {
323 if trimmed.lowercased() == "#+end_verse" {
324 closeVerseBlock()
325 } else {
326 verseLines.append(line)
327 }
328 continue
329 }
330
331 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
332 closeQuoteBlock()
333 continue
334 }
335
336 if inCenterBlock {
337 if trimmed.lowercased() == "#+end_center" {
338 closeCenterBlock()
339 } else if trimmed.isEmpty {
340 flushParagraph()
341 } else {
342 paragraph.append(line)
343 }
344 continue
345 }
346
347 if trimmed == "#" || trimmed.hasPrefix("# ") {
348 continue
349 }
350
351 if let directive = orgKeywordDirective(in: trimmed) {
352 switch directive.keyword {
353 case "caption":
354 pendingBlockCaption = directive.value
355 continue
356 case "name":
357 pendingBlockName = directive.value
358 continue
359 case "options", "property":
360 continue
361 default:
362 break
363 }
364 }
365
366 if trimmed.lowercased().hasPrefix("#+begin_src") {
367 closeQuoteBlock()
368 flushBlockState()
369 beginPendingBlockWrapperIfNeeded()
370 let language = trimmed
371 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
372 .dropFirst()
373 .first
374 .map(String.init)?
375 .trimmingCharacters(in: .whitespacesAndNewlines)
376 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
377 html += "<pre><code\(classAttribute)>"
378 srcLanguage = language ?? ""
379 srcLines = []
380 continue
381 }
382
383 if trimmed.lowercased() == "#+begin_example" {
384 closeQuoteBlock()
385 flushBlockState()
386 beginPendingBlockWrapperIfNeeded()
387 html += "<pre><code>"
388 inExampleBlock = true
389 continue
390 }
391
392 if trimmed.lowercased() == "#+begin_quote" {
393 flushBlockState()
394 beginPendingBlockWrapperIfNeeded()
395 html += "<blockquote>\n"
396 inQuoteBlock = true
397 continue
398 }
399
400 if trimmed.lowercased() == "#+begin_center" {
401 closeQuoteBlock()
402 flushBlockState()
403 beginPendingBlockWrapperIfNeeded()
404 html += "<div style=\"text-align:center\">\n"
405 inCenterBlock = true
406 continue
407 }
408
409 if trimmed.lowercased() == "#+begin_verse" {
410 closeQuoteBlock()
411 flushBlockState()
412 beginPendingBlockWrapperIfNeeded()
413 verseLines = []
414 inVerseBlock = true
415 continue
416 }
417
418 if trimmed == ":PROPERTIES:" {
419 closeQuoteBlock()
420 flushBlockState()
421 inPropertyDrawer = true
422 continue
423 }
424
425 if trimmed == ":END:", inPropertyDrawer {
426 flushPropertyDrawer()
427 inPropertyDrawer = false
428 continue
429 }
430
431 if inPropertyDrawer,
432 trimmed.hasPrefix(":"),
433 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
434 let keyStart = trimmed.index(after: trimmed.startIndex)
435 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
436 let valueStart = trimmed.index(after: secondColonIndex)
437 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
438 if !key.isEmpty {
439 propertyRows.append((key, value))
440 continue
441 }
442 }
443
444 if isTableLine(trimmed) {
445 closeQuoteBlock()
446 flushParagraph()
447 closeList()
448 tableRows.append(parseTableRow(trimmed))
449 continue
450 } else {
451 flushTable()
452 }
453
454 if isOrgHorizontalRule(trimmed) {
455 closeQuoteBlock()
456 flushBlockState()
457 html += "<hr>\n"
458 continue
459 }
460
461 // Org headings: * heading, ** heading, *** heading
462 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
463 closeQuoteBlock()
464 flushBlockState()
465 let level = match.1.count
466 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
467 html += "<h\(level)>" + content + "</h\(level)>\n"
468 continue
469 }
470
471 if listType != nil && isIndentedContinuationLine(line) {
472 currentListItemLines.append(line)
473 continue
474 }
475
476 // List items: - item
477 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
478 flushParagraph()
479 flushPropertyDrawer()
480 if listType != .unordered {
481 closeList()
482 html += "<ul>\n"
483 listType = .unordered
484 }
485 flushListItem()
486 currentListItemLines = [String(trimmed.dropFirst(2))]
487 continue
488 }
489
490 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
491 flushParagraph()
492 flushPropertyDrawer()
493 if listType != .ordered {
494 closeList()
495 html += "<ol>\n"
496 listType = .ordered
497 }
498 flushListItem()
499 currentListItemLines = [orderedItem]
500 continue
501 }
502
503 // Blank line
504 if trimmed.isEmpty {
505 if inQuoteBlock {
506 flushParagraph()
507 } else {
508 flushBlockState()
509 }
510 continue
511 }
512
513 // Regular text
514 if pendingBlockName != nil || pendingBlockCaption != nil {
515 pendingBlockName = nil
516 pendingBlockCaption = nil
517 }
518 paragraph.append(line)
519 }
520
521 closeSourceBlock()
522 closeExampleBlock()
523 closeCenterBlock()
524 closeVerseBlock()
525 closeQuoteBlock()
526 flushBlockState()
527
528 return html
529}
Sources/OrgSwift/Tables.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2
3func isTableLine(_ line: String) -> Bool {
4 line.hasPrefix("|") && line.hasSuffix("|")
5}
6
7func parseTableRow(_ line: String) -> [String] {
8 line
9 .split(separator: "|", omittingEmptySubsequences: false)
10 .dropFirst()
11 .dropLast()
12 .map { String($0).trimmingCharacters(in: .whitespaces) }
13}
14
15private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
16 var content = line.trimmingCharacters(in: .whitespaces)
17 if content.hasPrefix("|") {
18 content.removeFirst()
19 }
20 if content.hasSuffix("|") {
21 content.removeLast()
22 }
23 return content
24 .split(separator: "+", omittingEmptySubsequences: false)
25 .map { String($0).trimmingCharacters(in: .whitespaces) }
26}
27
28private func isTableSeparatorCell(_ cell: String) -> Bool {
29 tableAlignment(for: cell) != nil
30}
31
32private func tableAlignment(for cell: String) -> String? {
33 let trimmed = cell.trimmingCharacters(in: .whitespaces)
34 guard !trimmed.isEmpty else { return nil }
35
36 let core = trimmed.replacingOccurrences(of: ":", with: "")
37 guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else {
38 return nil
39 }
40
41 let isLeftAligned = trimmed.hasPrefix(":")
42 let isRightAligned = trimmed.hasSuffix(":")
43 switch (isLeftAligned, isRightAligned) {
44 case (true, true):
45 return "center"
46 case (true, false):
47 return "left"
48 case (false, true):
49 return "right"
50 case (false, false):
51 return ""
52 }
53}
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}
Tests/OrgSwiftTests/OrgRendererTests.swift added +164
@@ -0,0 +1,164 @@
1import Foundation
2import Testing
3@testable import OrgSwift
4
5private func render(_ source: String, options: OrgRenderOptions = .init()) -> String {
6 OrgRenderer.renderToHTML(source, options: options)
7}
8
9struct OrgRendererTests {
10
11 @Test
12 func orgDeepHeading() {
13 let html = render("**** Level 4 Heading")
14
15 #expect(html.contains("<h4>"))
16 }
17
18 @Test
19 func orgCommentLinesIgnored() {
20 let html = render("# This is a comment\nNormal text")
21
22 #expect(!html.contains("This is a comment"))
23 #expect(html.contains("Normal text"))
24 }
25
26 @Test
27 func orgStrikethrough() {
28 let html = render("+deleted text+")
29
30 #expect(html.contains("<del>"))
31 }
32
33 @Test
34 func orgExampleBlock() {
35 let html = render("#+begin_example\nhello world\n#+end_example")
36
37 #expect(html.contains("<pre><code>"))
38 #expect(html.contains("hello world"))
39 }
40
41 @Test
42 func orgTitleKeyword() {
43 let html = render("#+TITLE: My Document\nBody text")
44
45 #expect(html.contains("org-title"))
46 #expect(html.contains("My Document"))
47 }
48
49 @Test
50 func orgItalicDoesNotMatchURLPaths() {
51 let html = render("[[https://example.com/path/to/file]]")
52
53 #expect(!html.contains("<em>"))
54 }
55
56 @Test
57 func orgWrappedBulletNormalizesLines() {
58 let html = render("- First line\n continues here")
59
60 #expect(html.contains("<li>First line continues here</li>"))
61 }
62
63 @Test
64 func orgHeaderKeywordsIgnored() {
65 let html = render("""
66 #+OPTIONS: toc:nil
67 #+PROPERTY: header-args :results output
68 Body text
69 """)
70
71 #expect(!html.contains("#+OPTIONS"))
72 #expect(!html.contains("#+PROPERTY"))
73 #expect(html.contains("<p>Body text</p>"))
74 }
75
76 @Test
77 func orgVerseBlock() {
78 let html = render("""
79 #+begin_verse
80 There is a line.
81 And an indented line.
82 #+end_verse
83 """)
84
85 #expect(html.contains(#"<blockquote class="org-verse">"#))
86 #expect(html.contains("There is a line."))
87 #expect(html.contains("And an indented line."))
88 #expect(!html.contains("#+begin_verse"))
89 }
90
91 @Test
92 func orgNamedBlockRendersCaption() {
93 let html = render("""
94 #+CAPTION: Build output
95 #+NAME: build-log
96 #+begin_example
97 hello world
98 #+end_example
99 """)
100
101 #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
102 #expect(html.contains("<figcaption>Build output</figcaption>"))
103 #expect(!html.contains("#+CAPTION"))
104 #expect(!html.contains("#+NAME"))
105 }
106
107 @Test
108 func orgLinkedImageRenders() {
109 let html = render("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
110
111 #expect(html.contains(#"<a href="https://example.com">"#))
112 #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
113 }
114
115 @Test
116 func orgLinkedRelativeImageRenders() {
117 // In hutch this passed an explicit imageURLResolver closure; here the same
118 // resolution is expressed through OrgRenderOptions (owner/repositoryName/host).
119 let html = render(
120 "[[https://example.com/docs][[./images/badge.svg]]]",
121 options: OrgRenderOptions(owner: "~ccleberg", repositoryName: "Hutch")
122 )
123
124 #expect(html.contains(#"<a href="https://example.com/docs">"#))
125 #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
126 }
127
128 @Test
129 func orgNestedBulletListRendersNestedMarkup() {
130 let html = render("""
131 * Lists
132 ** Unordered
133 - First bullet
134 - Second bullet
135 - Third bullet with /italic/ and *bold*
136 - Bullet with wrapped
137 continuation line
138 - Bullet with nested list
139 - Nested child one
140 - Nested child two
141 - Bullet with inline code ~let x = 1~
142 """)
143
144 #expect(html.contains("<ul>"))
145 #expect(html.contains("<li>Bullet with nested list\n<ul>"))
146 #expect(html.contains("<li>Nested child one</li>"))
147 #expect(html.contains("<li>Nested child two</li>"))
148 #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
149 }
150
151 @Test
152 func orgTableAlignmentRendersStyles() {
153 let html = render("""
154 | Left | Center | Right |
155 |:-----+:-----:+------:|
156 | a | b | c |
157 | 1 | 2 | 3 |
158 """)
159
160 #expect(html.contains("text-align: left;"))
161 #expect(html.contains("text-align: center;"))
162 #expect(html.contains("text-align: right;"))
163 }
164}