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 |
| 5 | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | 6 | so that only real rendering differences remain. |
| 7 | 7 | |
| 8 | | Of the 12 corpus cases, **3 match orgo exactly (`table`, `timestamps`, `footnote`)** and 9 |
| 9 | | diverge. Each divergence below is a missing capability, recorded in the `expectations` map |
| 10 | | in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to |
| 11 | | matching and the test fails until it is moved to `.matches` — that is how this list stays |
| 12 | | honest. |
| 8 | Of the 12 corpus cases, **5 match orgo exactly (`table`, `timestamps`, `footnote`, |
| 9 | `minimal`, `headings`)** and 7 diverge. Each divergence below is a missing capability, |
| 10 | recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one |
| 11 | is closed, its case flips to matching and the test fails until it is moved to `.matches` — |
| 12 | that is how this list stays honest. |
| 13 | 13 | |
| 14 | 14 | This is the backlog the shared package exists to work through. Roughly in value order: |
| 15 | 15 | |
| 16 | 16 | | Case | Missing capability | |
| 17 | 17 | |---|---| |
| 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. | |
| 20 | 18 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | |
| 21 | 19 | | `core` | Bare URLs in running text are not autolinked. | |
| 22 | 20 | | `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: |
| 45 | 43 | class="footnotes">` at the end, numbered in first-reference order. Footnote references |
| 46 | 44 | are collected from paragraphs and headings; a reference inside a list item or table cell |
| 47 | 45 | 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. |
| 9 | 9 | |
| 10 | 10 | ## Supported syntax |
| 11 | 11 | |
| 12 | | Headings, 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, |
| 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 13 | nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` |
| 14 | alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, |
| 15 | 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links |
| 16 | 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps |
| 17 | 17 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes |
Sources/OrgSwift/OrgRenderer.swift
+20 −8
| @@ -113,6 +113,17 @@ public enum OrgRenderer { |
| 113 | 113 | |
| 114 | 114 | // MARK: - Org-mode to HTML |
| 115 | 115 | |
| 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. |
| 119 | func 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 | |
| 116 | 127 | func orgToHTML( |
| 117 | 128 | _ text: String, |
| 118 | 129 | highlighter: CodeHighlighter, |
| @@ -232,13 +243,10 @@ func orgToHTML( |
| 232 | 243 | } |
| 233 | 244 | |
| 234 | 245 | 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. |
| 242 | 250 | propertyRows = [] |
| 243 | 251 | } |
| 244 | 252 | |
| @@ -490,7 +498,11 @@ func orgToHTML( |
| 490 | 498 | closeQuoteBlock() |
| 491 | 499 | flushBlockState() |
| 492 | 500 | 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 | } |
| 494 | 506 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 495 | 507 | continue |
| 496 | 508 | } |
Tests/OrgSwiftTests/ConformanceTests.swift
+2 −2
| @@ -54,10 +54,10 @@ private let expectations: [String: Expectation] = [ |
| 54 | 54 | "core": .diverges("bare URLs are not autolinked"), |
| 55 | 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), |
| 56 | 56 | "footnote": .matches, |
| 57 | | "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"), |
| 57 | "headings": .matches, |
| 58 | 58 | "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), |
| 59 | 59 | "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, |
| 61 | 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
| 62 | 62 | "tblfm": .diverges("^ superscript is not rendered in table cells"), |
| 63 | 63 | "timestamps": .matches, |
Tests/OrgSwiftTests/OrgRendererTests.swift
+32
| @@ -254,4 +254,36 @@ struct OrgRendererTests { |
| 254 | 254 | #expect(html.contains(#"<li id="fn-1">defined inline "#)) |
| 255 | 255 | #expect(!html.contains("<li id=\"fn-1\"><p>defined inline")) |
| 256 | 256 | } |
| 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 | } |
| 257 | 289 | } |