Commit a1aa1a5882
Unsigned
Layout: unified · split
GAPS.md +10 −7
| @@ -5,18 +5,16 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run | |||
| 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | so that only real rendering differences remain. | 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 | 8 | Of the 12 corpus cases, **5 match orgo exactly (`table`, `timestamps`, `footnote`, |
| 9 | diverge. Each divergence below is a missing capability, recorded in the `expectations` map | 9 | `minimal`, `headings`)** and 7 diverge. Each divergence below is a missing capability, |
| 10 | in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to | 10 | recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one |
| 11 | matching and the test fails until it is moved to `.matches` — that is how this list stays | 11 | is closed, its case flips to matching and the test fails until it is moved to `.matches` — |
| 12 | honest. | 12 | that is how this list stays honest. |
| 13 | 13 | ||
| 14 | This is the backlog the shared package exists to work through. Roughly in value order: | 14 | This is the backlog the shared package exists to work through. Roughly in value order: |
| 15 | 15 | ||
| 16 | | Case | Missing capability | | 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 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | | 18 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | |
| 21 | | `core` | Bare URLs in running text are not autolinked. | | 19 | | `core` | Bare URLs in running text are not autolinked. | |
| 22 | | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. | | 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 | class="footnotes">` at the end, numbered in first-reference order. Footnote references | 43 | class="footnotes">` at the end, numbered in first-reference order. Footnote references |
| 46 | are collected from paragraphs and headings; a reference inside a list item or table cell | 44 | are collected from paragraphs and headings; a reference inside a list item or table cell |
| 47 | is not yet collected (it degrades to literal text). | 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 | ## Supported syntax | 10 | ## Supported syntax |
| 11 | 11 | ||
| 12 | Headings, paragraphs, ordered/unordered lists (with nesting, wrapped lines, and | 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 13 | `[ ]`/`[x]` task checkboxes), tables (with `:---:` alignment), `#+begin_src` / | 13 | nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` |
| 14 | `example` / `quote` / `center` / `verse` blocks, property drawers, | 14 | alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, |
| 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links | 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links |
| 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps | 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps |
| 17 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes | 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 | // MARK: - Org-mode to HTML | 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 | func orgToHTML( | 127 | func orgToHTML( |
| 117 | _ text: String, | 128 | _ text: String, |
| 118 | highlighter: CodeHighlighter, | 129 | highlighter: CodeHighlighter, |
| @@ -232,13 +243,10 @@ func orgToHTML( | |||
| 232 | } | 243 | } |
| 233 | 244 | ||
| 234 | func flushPropertyDrawer() { | 245 | func flushPropertyDrawer() { |
| 235 | guard !propertyRows.isEmpty else { return } | 246 | // Property drawers are heading metadata, not body content. org's HTML exporter |
| 236 | html += "<dl class=\"org-properties\">\n" | 247 | // drops them (CUSTOM_ID becomes the heading's anchor); we drop them too rather |
| 237 | for (key, value) in propertyRows { | 248 | // than render a stray <dl>. The lines were still consumed above, so they never |
| 238 | html += "<dt>" + escapeHTML(key) + "</dt>" | 249 | // fall through to become a paragraph. |
| 239 | html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n" | ||
| 240 | } | ||
| 241 | html += "</dl>\n" | ||
| 242 | propertyRows = [] | 250 | propertyRows = [] |
| 243 | } | 251 | } |
| 244 | 252 | ||
| @@ -490,7 +498,11 @@ func orgToHTML( | |||
| 490 | closeQuoteBlock() | 498 | closeQuoteBlock() |
| 491 | flushBlockState() | 499 | flushBlockState() |
| 492 | let level = min(6, max(1, match.1.count + headingLevelOffset)) | 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 | html += "<h\(level)>" + content + "</h\(level)>\n" | 506 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 495 | continue | 507 | continue |
| 496 | } | 508 | } |
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
| @@ -54,10 +54,10 @@ private let expectations: [String: Expectation] = [ | |||
| 54 | "core": .diverges("bare URLs are not autolinked"), | 54 | "core": .diverges("bare URLs are not autolinked"), |
| 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), | 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), |
| 56 | "footnote": .matches, | 56 | "footnote": .matches, |
| 57 | "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"), | 57 | "headings": .matches, |
| 58 | "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), | 58 | "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), |
| 59 | "lists": .diverges("list nesting deeper than one level is not represented"), | 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 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), | 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
| 62 | "tblfm": .diverges("^ superscript is not rendered in table cells"), | 62 | "tblfm": .diverges("^ superscript is not rendered in table cells"), |
| 63 | "timestamps": .matches, | 63 | "timestamps": .matches, |
Tests/OrgSwiftTests/OrgRendererTests.swift +32
| @@ -254,4 +254,36 @@ struct OrgRendererTests { | |||
| 254 | #expect(html.contains(#"<li id="fn-1">defined inline "#)) | 254 | #expect(html.contains(#"<li id="fn-1">defined inline "#)) |
| 255 | #expect(!html.contains("<li id=\"fn-1\"><p>defined inline")) | 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 | } |