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

html library org-mode swift

Commit 7de7851fd9

7de7851fd923af17a911a334ef1531454cbbd43c

parent: 7ca1bd887d

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T18:56:03Z

OrgSwift: autolink bare URLs, render file: images and figures

core: bare http(s) URLs in text are autolinked (trailing punctuation excluded);
task checkboxes render as <code>[ ]</code> like org's exporter, not <input>.
images: file: prefix stripped and relative src/href allowed (scheme-less paths
are safe); a standalone image is <p><img>, promoted to <figure> by #+CAPTION or
#+ATTR_HTML with a numbered <figcaption>; described image links render as links.
Both corpus cases now match orgo (7/12); GAPS.md updated.
GAPS.md +15 −7
@@ -5,18 +5,16 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run
55 orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)`
66 so that only real rendering differences remain.
77
8Of the 12 corpus cases, **5 match orgo exactly (`table`, `timestamps`, `footnote`,
9`minimal`, `headings`)** and 7 diverge. Each divergence below is a missing capability,
10recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one
11is closed, its case flips to matching and the test fails until it is moved to `.matches`
12that is how this list stays honest.
8Of the 12 corpus cases, **7 match orgo exactly (`table`, `timestamps`, `footnote`,
9`minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing
10capability, recorded in the `expectations` map in
11`Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching
12and the test fails until it is moved to `.matches` — that is how this list stays honest.
1313
1414 This is the backlog the shared package exists to work through. Roughly in value order:
1515
1616 | Case | Missing capability |
1717 |---|---|
18| `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. |
19| `core` | Bare URLs in running text are not autolinked. |
2018 | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. |
2119 | `lists` | List nesting deeper than one level is flattened. |
2220 | `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). |
@@ -48,3 +46,13 @@ they are presentation policy, not parser capability:
4846 title and rendered as `<span class="tag">`. TODO/DONE keywords and priority cookies are
4947 still emitted as plain title text (orgo wraps them in styled spans); that is a separate,
5048 cosmetic difference the skeleton does not distinguish.
49 **`core`** — bare `http(s)://` URLs in running text are autolinked (trailing sentence
50 punctuation left outside the link), and task checkboxes render as `<code>[ ]</code>` /
51 `<code>[X]</code>` / `<code>[-]</code>` the way org's HTML exporter emits them, rather
52 than as `<input type="checkbox">`.
53 **`images`** — `[[file:…]]` links have the `file:` prefix stripped and relative image
54 `src`/link `href` are allowed (a scheme-less path cannot carry a `javascript:` payload).
55 A standalone image line becomes `<p><img></p>`; an affiliated `#+CAPTION`/`#+ATTR_HTML`
56 promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present.
57 `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and
58 a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image.
README.md +6 −6
@@ -12,12 +12,12 @@ shared across apps.
1212 Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
1313 nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:`
1414 alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks,
15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links
16and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps
17(`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes
18(references, inline, and definitions → a `<section class="footnotes">`), and
19inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`,
20`_underline_`, email autolinks).
15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME`
16figures, org links and images (`[[file:x.png]]``<img>`, `[[dest][label]]`
17link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive,
18times, and ranges → `<time>`), footnotes (references, inline, and definitions →
19a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`,
20`~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks).
2121
2222 Output is sanitized: only `http`/`https`/`mailto` link schemes and
2323 `http`/`https` image schemes are allowed; everything else is dropped.
Sources/OrgSwift/Escaping.swift +12 −6
@@ -43,14 +43,20 @@ private func sanitizeReadmeURLString(
4343 return escapeHTMLAttribute(trimmedURL)
4444 }
4545
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
46 if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() {
47 guard allowedSchemes.contains(scheme),
48 let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else {
49 return nil
50 }
51 return escapeHTMLAttribute(sanitizedURL)
5152 }
5253
53 return escapeHTMLAttribute(sanitizedURL)
54 // No scheme a relative reference (`diagram.png`, `docs/x`). It cannot carry a
55 // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a
56 // resolver turn these into absolute URLs before they reach here. Reject only the
57 // protocol-relative `//host` form, which points off-origin.
58 guard !trimmedURL.hasPrefix("//") else { return nil }
59 return escapeHTMLAttribute(trimmedURL)
5460 }
5561
5662 // MARK: - Regex Helpers
Sources/OrgSwift/Inline.swift +18
@@ -75,6 +75,24 @@ func processOrgInline(
7575 }
7676 }
7777
78 // Bare URLs in running text become links. Bracketed `[[]]` links are already
79 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is
80 // left outside the link.
81 result = protectMatches(
82 in: result,
83 pattern: #"https?://[^\s<>()\[\]]+"#,
84 protectedFragments: &protectedFragments
85 ) { match, nsText in
86 var url = nsText.substring(with: match.range)
87 var trailing = ""
88 while let last = url.last, ".,;:!?".contains(last) {
89 trailing = String(last) + trailing
90 url.removeLast()
91 }
92 guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing }
93 return #"<a href="\#(safe)">\#(url)</a>"# + trailing
94 }
95
7896 result = protectMatches(
7997 in: result,
8098 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
Sources/OrgSwift/Links.swift +93 −4
@@ -63,12 +63,19 @@ private func parseOrgLink(
6363 return nil
6464 }
6565
66/// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same
67/// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL.
68func normalizeOrgLinkTarget(_ target: String) -> String {
69 target.hasPrefix("file:") ? String(target.dropFirst(5)) : target
70}
71
6672 private func renderOrgLink(
67 destination: String,
73 destination rawDestination: String,
6874 label: String?,
6975 imageURLResolver: ((String) -> String?)? = nil,
7076 linkURLResolver: ((String) -> String?)? = nil
7177 ) -> String {
78 let destination = normalizeOrgLinkTarget(rawDestination)
7279 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
7380 let source = String(label.dropFirst(2).dropLast(2))
7481 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
@@ -80,9 +87,12 @@ private func renderOrgLink(
8087 }
8188 }
8289
83 if let imageHTML = makeOrgImageHTML(
90 // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a
91 // link whose text happens to point at an image org renders it as a link, not an
92 // image, so only take the image path when there is no description.
93 if label == nil, let imageHTML = makeOrgImageHTML(
8494 source: destination,
85 alt: label,
95 alt: nil,
8696 imageURLResolver: imageURLResolver
8797 ) {
8898 return imageHTML
@@ -100,10 +110,11 @@ private func renderOrgLink(
100110 }
101111
102112 func makeOrgImageHTML(
103 source: String,
113 source rawSource: String,
104114 alt: String?,
105115 imageURLResolver: ((String) -> String?)?
106116 ) -> String? {
117 let source = normalizeOrgLinkTarget(rawSource)
107118 guard isRenderableImageSource(source) else { return nil }
108119 let resolvedSource = imageURLResolver?(source) ?? source
109120 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
@@ -117,6 +128,84 @@ private func isRenderableImageSource(_ source: String) -> Bool {
117128 .contains(where: { lowercased.hasSuffix($0) })
118129 }
119130
131/// If a whole line is just an image link with no description (`[[file:x.png]]`), return its
132/// path (with `file:` stripped). A link carrying a description is not a standalone image.
133func standaloneOrgImage(in line: String) -> String? {
134 let trimmed = line.trimmingCharacters(in: .whitespaces)
135 guard trimmed.hasPrefix("[[") && trimmed.hasSuffix("]]") else { return nil }
136 let inner = String(trimmed.dropFirst(2).dropLast(2))
137 guard !inner.contains("][") else { return nil }
138 let path = normalizeOrgLinkTarget(inner)
139 guard isRenderableImageSource(path) else { return nil }
140 return path
141}
142
143/// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup
144/// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining
145/// `#+ATTR_HTML` pairs become attributes.
146func makeFigureImageHTML(
147 path: String,
148 caption: String?,
149 attrHtml: String?,
150 imageURLResolver: ((String) -> String?)?
151) -> String {
152 let resolved = imageURLResolver?(path) ?? path
153 let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path)
154 let attrs = attrHtml.map(parseAttrHtml) ?? []
155
156 let alt: String
157 if let caption {
158 alt = stripOrgEmphasis(caption)
159 } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value {
160 alt = attrAlt
161 } else {
162 alt = ""
163 }
164
165 var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""#
166 for (key, value) in attrs where key != "alt" {
167 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
168 }
169 html += ">"
170 return html
171}
172
173/// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`).
174private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] {
175 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
176 return []
177 }
178 let ns = value as NSString
179 var result: [(String, String)] = []
180 for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) {
181 let key = ns.substring(with: m.range(at: 1)).lowercased()
182 var v = ns.substring(with: m.range(at: 2))
183 if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") {
184 v = String(v.dropFirst().dropLast())
185 }
186 result.append((key, v))
187 }
188 return result
189}
190
191/// Strip paired org emphasis markers for plain-text uses like an image `alt`.
192private func stripOrgEmphasis(_ s: String) -> String {
193 guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else {
194 return s
195 }
196 var result = s
197 for _ in 0..<3 {
198 let ns = result as NSString
199 let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length))
200 if matches.isEmpty { break }
201 for m in matches.reversed() {
202 let inner = ns.substring(with: m.range(at: 2))
203 result = (result as NSString).replacingCharacters(in: m.range, with: inner)
204 }
205 }
206 return result
207}
208
120209 // MARK: - Relative link/image resolution
121210
122211 func resolveRepositoryLinkURL(
Sources/OrgSwift/Lists.swift +5 −2
@@ -136,11 +136,14 @@ func renderTaskListItem(
136136 let prefix = String(trimmed.prefix(4))
137137 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
138138
139 // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>.
139140 switch prefix {
140141 case "[ ] ":
141 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
142 return #"<code>[&nbsp;]</code> \#(inlineRenderer(remainder))"#
142143 case "[x] ", "[X] ":
143 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
144 return #"<code>[X]</code> \#(inlineRenderer(remainder))"#
145 case "[-] ":
146 return #"<code>[-]</code> \#(inlineRenderer(remainder))"#
144147 default:
145148 return inlineRenderer(text)
146149 }
Sources/OrgSwift/OrgRenderer.swift +38 −1
@@ -174,8 +174,10 @@ func orgToHTML(
174174 var verseLines: [String] = []
175175 var pendingBlockName: String?
176176 var pendingBlockCaption: String?
177 var pendingAttrHtml: String?
177178 var activeBlockCaption: String?
178179 var isWrappingBlockFigure = false
180 var figureNumber = 0
179181 let footnotes = FootnoteCollector()
180182
181183 func beginPendingBlockWrapperIfNeeded() {
@@ -382,6 +384,9 @@ func orgToHTML(
382384 case "name":
383385 pendingBlockName = directive.value
384386 continue
387 case "attr_html":
388 pendingAttrHtml = directive.value
389 continue
385390 case "options", "property":
386391 continue
387392 default:
@@ -389,6 +394,37 @@ func orgToHTML(
389394 }
390395 }
391396
397 // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML
398 // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a
399 // description ([[file:x][label]]) is an ordinary link and falls through to the
400 // paragraph path instead.
401 if let imagePath = standaloneOrgImage(in: trimmed) {
402 closeQuoteBlock()
403 flushBlockState()
404 let img = makeFigureImageHTML(
405 path: imagePath,
406 caption: pendingBlockCaption,
407 attrHtml: pendingAttrHtml,
408 imageURLResolver: imageURLResolver
409 )
410 if pendingBlockCaption != nil || pendingAttrHtml != nil {
411 html += "<figure>" + img
412 if let caption = pendingBlockCaption {
413 figureNumber += 1
414 html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"#
415 + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
416 + "</figcaption>"
417 }
418 html += "</figure>\n"
419 } else {
420 html += "<p>" + img + "</p>\n"
421 }
422 pendingBlockCaption = nil
423 pendingBlockName = nil
424 pendingAttrHtml = nil
425 continue
426 }
427
392428 if trimmed.lowercased().hasPrefix("#+begin_src") {
393429 closeQuoteBlock()
394430 flushBlockState()
@@ -550,9 +586,10 @@ func orgToHTML(
550586 }
551587
552588 // Regular text
553 if pendingBlockName != nil || pendingBlockCaption != nil {
589 if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil {
554590 pendingBlockName = nil
555591 pendingBlockCaption = nil
592 pendingAttrHtml = nil
556593 }
557594 paragraph.append(line)
558595 }
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
@@ -51,11 +51,11 @@ private enum Expectation {
5151 private let expectations: [String: Expectation] = [
5252 "table": .matches,
5353 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"),
54 "core": .diverges("bare URLs are not autolinked"),
54 "core": .matches,
5555 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"),
5656 "footnote": .matches,
5757 "headings": .matches,
58 "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"),
58 "images": .matches,
5959 "lists": .diverges("list nesting deeper than one level is not represented"),
6060 "minimal": .matches,
6161 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"),
Tests/OrgSwiftTests/OrgRendererTests.swift +73
@@ -286,4 +286,77 @@ struct OrgRendererTests {
286286 #expect(html.contains("<h1>Just a heading</h1>"))
287287 #expect(!html.contains("class=\"tag\""))
288288 }
289
290 @Test
291 func bareUrlBecomesLink() {
292 let html = render("See https://example.com now.")
293 #expect(html.contains(#"<a href="https://example.com">https://example.com</a>"#))
294 // Trailing sentence punctuation stays outside the link.
295 let end = render("End at https://example.com.")
296 #expect(end.contains(#"<a href="https://example.com">https://example.com</a>."#))
297 }
298
299 @Test
300 func taskCheckboxesRenderAsCode() {
301 let html = render("""
302 - [ ] todo
303 - [X] done
304 - [-] partial
305 """)
306 #expect(html.contains("<code>[&nbsp;]</code> todo"))
307 #expect(html.contains("<code>[X]</code> done"))
308 #expect(html.contains("<code>[-]</code> partial"))
309 #expect(!html.contains("<input"))
310 }
311
312 @Test
313 func bareFileImageIsImgInParagraph() {
314 let html = render("[[file:diagram.png]]")
315 #expect(html.contains(#"<p><img src="diagram.png" alt=""></p>"#))
316 }
317
318 @Test
319 func describedFileImageIsALink() {
320 // A description turns an image target into a link, not an inline image.
321 let html = render("[[file:diagram.png][the diagram]]")
322 #expect(html.contains(#"<a href="diagram.png">the diagram</a>"#))
323 #expect(!html.contains("<img"))
324 }
325
326 @Test
327 func captionPromotesImageToFigure() {
328 let html = render("""
329 #+CAPTION: A /stylised/ chart
330 [[file:chart.png]]
331 """)
332 #expect(html.contains("<figure><img src=\"chart.png\""))
333 #expect(html.contains(#"<figcaption><span class="figure-number">Figure 1: </span>A <em>stylised</em> chart</figcaption>"#))
334 // alt is the caption with markup stripped.
335 #expect(html.contains(#"alt="A stylised chart""#))
336 }
337
338 @Test
339 func attrHtmlPromotesImageToFigureWithoutCaption() {
340 let html = render("""
341 #+ATTR_HTML: :alt "a cat, sitting" :loading lazy
342 [[file:cat.jpg]]
343 """)
344 #expect(html.contains("<figure><img src=\"cat.jpg\""))
345 #expect(html.contains(#"alt="a cat, sitting""#))
346 #expect(html.contains(#"loading="lazy""#))
347 #expect(!html.contains("<figcaption"))
348 }
349
350 @Test
351 func figuresNumberSequentially() {
352 let html = render("""
353 #+CAPTION: First
354 [[file:a.png]]
355
356 #+CAPTION: Second
357 [[file:b.png]]
358 """)
359 #expect(html.contains("Figure 1: </span>First"))
360 #expect(html.contains("Figure 2: </span>Second"))
361 }
289362 }