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

html library org-mode swift

Commit 7ca1bd887d

7ca1bd887da2d0b7a607d486e258c459dd1c535a

parent: 256ee33237

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T16:28:00Z

OrgSwift: drop property drawers, render heading tags

Property drawers (:PROPERTIES: … :END:) are dropped from the body instead of
rendered as a stray <dl>, matching org's HTML exporter. Heading trailing
:tag1:tag2: are split off the title and rendered as <span class="tag">. Both
corpus cases (minimal, headings) now match orgo (5/12); GAPS.md updated.
GAPS.md +10 −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, **3 match orgo exactly (`table`, `timestamps`, `footnote`)** and 9
9diverge. Each divergence below is a missing capability, recorded in the `expectations` map
10in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to
11matching and the test fails until it is moved to `.matches` — that is how this list stays
12honest.
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.
1313
1414 This is the backlog the shared package exists to work through. Roughly in value order:
1515
1616 | Case | Missing capability |
1717 |---|---|
18| `headings` | Heading `:tags:` are not stripped/rendered; property drawers render as a visible `<dl>`. |
19| `minimal` | Property drawers (`:PROPERTIES:``:END:`) render as a visible `<dl>` instead of being dropped. |
2018 | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. |
2119 | `core` | Bare URLs in running text are not autolinked. |
2220 | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. |
@@ -45,3 +43,8 @@ they are presentation policy, not parser capability:
4543 class="footnotes">` at the end, numbered in first-reference order. Footnote references
4644 are collected from paragraphs and headings; a reference inside a list item or table cell
4745 is not yet collected (it degrades to literal text).
46 **`minimal` / `headings`** — property drawers (`:PROPERTIES:` … `:END:`) are dropped
47 rather than rendered as a `<dl>`, and heading trailing `:tag1:tag2:` are split off the
48 title and rendered as `<span class="tag">`. TODO/DONE keywords and priority cookies are
49 still emitted as plain title text (orgo wraps them in styled spans); that is a separate,
50 cosmetic difference the skeleton does not distinguish.
README.md +3 −3
@@ -9,9 +9,9 @@ shared across apps.
99
1010 ## Supported syntax
1111
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,
12Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
13nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:`
14alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks,
1515 `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links
1616 and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps
1717 (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes
Sources/OrgSwift/OrgRenderer.swift +20 −8
@@ -113,6 +113,17 @@ public enum OrgRenderer {
113113
114114 // MARK: - Org-mode to HTML
115115
116/// Split an org heading's trailing `:tag1:tag2:` off its title. Tags are the final
117/// whitespace-separated run of colon-delimited words; a heading without them returns its
118/// text unchanged and no tags.
119func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) {
120 guard let match = heading.firstMatch(of: /^(.*?)\s+(:(?:[A-Za-z0-9_@#%]+:)+)$/) else {
121 return (heading, [])
122 }
123 let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty }
124 return (String(match.1).trimmingCharacters(in: .whitespaces), tags)
125}
126
116127 func orgToHTML(
117128 _ text: String,
118129 highlighter: CodeHighlighter,
@@ -232,13 +243,10 @@ func orgToHTML(
232243 }
233244
234245 func flushPropertyDrawer() {
235 guard !propertyRows.isEmpty else { return }
236 html += "<dl class=\"org-properties\">\n"
237 for (key, value) in propertyRows {
238 html += "<dt>" + escapeHTML(key) + "</dt>"
239 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
240 }
241 html += "</dl>\n"
246 // Property drawers are heading metadata, not body content. org's HTML exporter
247 // drops them (CUSTOM_ID becomes the heading's anchor); we drop them too rather
248 // than render a stray <dl>. The lines were still consumed above, so they never
249 // fall through to become a paragraph.
242250 propertyRows = []
243251 }
244252
@@ -490,7 +498,11 @@ func orgToHTML(
490498 closeQuoteBlock()
491499 flushBlockState()
492500 let level = min(6, max(1, match.1.count + headingLevelOffset))
493 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes)
501 let (titleText, tags) = splitHeadingTags(String(match.2))
502 var content = processOrgInline(titleText, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes)
503 if !tags.isEmpty {
504 content += " " + tags.map { #"<span class="tag">\#(escapeHTML($0))</span>"# }.joined(separator: " ")
505 }
494506 html += "<h\(level)>" + content + "</h\(level)>\n"
495507 continue
496508 }
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
@@ -54,10 +54,10 @@ private let expectations: [String: Expectation] = [
5454 "core": .diverges("bare URLs are not autolinked"),
5555 "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"),
5656 "footnote": .matches,
57 "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"),
57 "headings": .matches,
5858 "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"),
5959 "lists": .diverges("list nesting deeper than one level is not represented"),
60 "minimal": .diverges("property drawers render as a visible <dl> instead of being dropped"),
60 "minimal": .matches,
6161 "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"),
6262 "tblfm": .diverges("^ superscript is not rendered in table cells"),
6363 "timestamps": .matches,
Tests/OrgSwiftTests/OrgRendererTests.swift +32
@@ -254,4 +254,36 @@ struct OrgRendererTests {
254254 #expect(html.contains(#"<li id="fn-1">defined inline "#))
255255 #expect(!html.contains("<li id=\"fn-1\"><p>defined inline"))
256256 }
257
258 @Test
259 func propertyDrawerIsDropped() {
260 let html = render("""
261 * Heading
262 :PROPERTIES:
263 :CUSTOM_ID: first
264 :OWNER: nobody
265 :END:
266
267 Body text.
268 """)
269 #expect(!html.contains("org-properties"))
270 #expect(!html.contains("<dl"))
271 #expect(!html.contains("CUSTOM_ID"))
272 #expect(!html.contains("nobody"))
273 #expect(html.contains("<h1>Heading</h1>"))
274 #expect(html.contains("<p>Body text.</p>"))
275 }
276
277 @Test
278 func headingTagsRenderAsSpans() {
279 let html = render("* Write the parser :work:rust:")
280 #expect(html.contains(#"<h1>Write the parser <span class="tag">work</span> <span class="tag">rust</span></h1>"#))
281 }
282
283 @Test
284 func headingWithoutTagsIsUnchanged() {
285 let html = render("* Just a heading")
286 #expect(html.contains("<h1>Just a heading</h1>"))
287 #expect(!html.contains("class=\"tag\""))
288 }
257289 }