Commit 94c0b2af42
Unsigned
Layout: unified · split
GAPS.md added +37
| @@ -0,0 +1,37 @@ | |||
| 1 | # Conformance gaps | ||
| 2 | |||
| 3 | OrgSwift is measured against the [org-conformance](../org-conformance) corpus, whose | ||
| 4 | goldens come from orgo (validated against Emacs `ox-html`). The renderer is run in | ||
| 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — | ||
| 6 | so that only real rendering differences remain. | ||
| 7 | |||
| 8 | Of the 12 corpus cases, **1 matches orgo exactly (`table`)** and 11 diverge. Each | ||
| 9 | divergence below is a missing capability, recorded in the `expectations` map in | ||
| 10 | `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. | ||
| 13 | |||
| 14 | This is the backlog the shared package exists to work through. Roughly in value order: | ||
| 15 | |||
| 16 | | Case | Missing capability | | ||
| 17 | |---|---| | ||
| 18 | | `timestamps` | Active/inactive timestamps (`<2024-01-15 Mon>`, `[…]`) are left as literal text; orgo emits `<time datetime>`. | | ||
| 19 | | `footnote` | Footnote references (`[fn:1]`) and definitions are not parsed. | | ||
| 20 | | `headings` | Heading `:tags:` are not stripped/rendered; property drawers render as a visible `<dl>`. | | ||
| 21 | | `minimal` | Property drawers (`:PROPERTIES:` … `:END:`) render as a visible `<dl>` instead of being dropped. | | ||
| 22 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | | ||
| 23 | | `core` | Bare URLs in running text are not autolinked. | | ||
| 24 | | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. | | ||
| 25 | | `lists` | List nesting deeper than one level is flattened. | | ||
| 26 | | `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). | | ||
| 27 | | `blocks` | Example blocks wrap in `<pre><code>`; orgo uses a bare `<pre>`. | | ||
| 28 | | `outofscope` | Deliberately unsupported constructs. `scope: out` in the corpus — orgo may also differ, so treat this as documentation, not a target. | | ||
| 29 | |||
| 30 | Two behaviors were made configurable during extraction rather than left hardcoded, because | ||
| 31 | they are presentation policy, not parser capability: | ||
| 32 | |||
| 33 | - **`metadataHeader`** — whether `#+TITLE`/`#+AUTHOR`/`#+DATE` render as a leading | ||
| 34 | `<div class="org-metadata">`. Apps that want a visible README title keep the default | ||
| 35 | (`true`); orgo carries the title in the page template, so conformance runs with `false`. | ||
| 36 | - **`headingLevelOffset`** — added to a heading's star count. Default `0` renders `*` as | ||
| 37 | `<h1>`; orgo uses `1` (`*` → `<h2>`, leaving `<h1>` for the title). | ||
README.md +21
| @@ -50,6 +50,17 @@ let html = OrgRenderer.renderToHTML( | |||
| 50 | `./images/badge.svg` then resolves to | 50 | `./images/badge.svg` then resolves to |
| 51 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. | 51 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. |
| 52 | 52 | ||
| 53 | ### Title and heading level | ||
| 54 | |||
| 55 | Two presentation knobs, both defaulting to how Hutch rendered: | ||
| 56 | |||
| 57 | - `metadataHeader` (default `true`) — emit a leading `<div class="org-metadata">` | ||
| 58 | with the `#+TITLE`/`#+AUTHOR`/`#+DATE`. Set `false` to drop it (e.g. when the | ||
| 59 | surrounding UI shows the title itself). | ||
| 60 | - `headingLevelOffset` (default `0`) — added to each heading's star count, clamped | ||
| 61 | to `1...6`. Default renders `*` as `<h1>`; use `1` to render `*` as `<h2>`, | ||
| 62 | leaving `<h1>` for a document title. | ||
| 63 | |||
| 53 | ### Syntax highlighting | 64 | ### Syntax highlighting |
| 54 | 65 | ||
| 55 | Code blocks are highlighted through a protocol so the library carries no | 66 | Code blocks are highlighted through a protocol so the library carries no |
| @@ -69,3 +80,13 @@ let html = OrgRenderer.renderToHTML(orgSource, highlighter: MyHighlighter()) | |||
| 69 | ``` | 80 | ``` |
| 70 | 81 | ||
| 71 | A conformer returning `nil` for a given block gets the same escaped fallback. | 82 | A conformer returning `nil` for a given block gets the same escaped fallback. |
| 83 | |||
| 84 | ## Conformance | ||
| 85 | |||
| 86 | OrgSwift is tested against the shared [org-conformance](../org-conformance) | ||
| 87 | corpus, whose golden outputs come from `orgo` (itself validated against Emacs | ||
| 88 | `ox-html`). `swift test` locates a sibling `../org-conformance` checkout, or set | ||
| 89 | `ORG_CONFORMANCE_DIR`. The suite reduces both this renderer's HTML and orgo's to | ||
| 90 | a semantic skeleton and compares them; the current pass/divergence state is | ||
| 91 | recorded per case, so a closed gap or a regression both surface as a failing | ||
| 92 | test. See [GAPS.md](GAPS.md) for the outstanding backlog. | ||
Sources/OrgSwift/OrgRenderer.swift +22 −5
| @@ -30,19 +30,32 @@ public struct OrgRenderOptions { | |||
| 30 | public var ref: String | 30 | public var ref: String |
| 31 | /// Path of the README being rendered, used to resolve paths relative to it. | 31 | /// Path of the README being rendered, used to resolve paths relative to it. |
| 32 | public var readmePath: String? | 32 | public var readmePath: String? |
| 33 | /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`. | ||
| 34 | /// Apps that show a README title want this; orgo treats those keywords as document | ||
| 35 | /// metadata carried by the page template, not body content, so the conformance corpus | ||
| 36 | /// renders with this off. | ||
| 37 | public var metadataHeader: Bool | ||
| 38 | /// Added to every heading's star count before it becomes an `<hN>` level (clamped to | ||
| 39 | /// 1...6). Default 0 renders `*` as `<h1>`. orgo offsets by 1 (`*` → `<h2>`) because the | ||
| 40 | /// document title occupies `<h1>`; set this to 1 to match orgo. | ||
| 41 | public var headingLevelOffset: Int | ||
| 33 | 42 | ||
| 34 | public init( | 43 | public init( |
| 35 | host: String = "git.sr.ht", | 44 | host: String = "git.sr.ht", |
| 36 | owner: String? = nil, | 45 | owner: String? = nil, |
| 37 | repositoryName: String? = nil, | 46 | repositoryName: String? = nil, |
| 38 | ref: String = "HEAD", | 47 | ref: String = "HEAD", |
| 39 | readmePath: String? = nil | 48 | readmePath: String? = nil, |
| 49 | metadataHeader: Bool = true, | ||
| 50 | headingLevelOffset: Int = 0 | ||
| 40 | ) { | 51 | ) { |
| 41 | self.host = host | 52 | self.host = host |
| 42 | self.owner = owner | 53 | self.owner = owner |
| 43 | self.repositoryName = repositoryName | 54 | self.repositoryName = repositoryName |
| 44 | self.ref = ref | 55 | self.ref = ref |
| 45 | self.readmePath = readmePath | 56 | self.readmePath = readmePath |
| 57 | self.metadataHeader = metadataHeader | ||
| 58 | self.headingLevelOffset = headingLevelOffset | ||
| 46 | } | 59 | } |
| 47 | 60 | ||
| 48 | func imageURLResolver() -> ((String) -> String?)? { | 61 | func imageURLResolver() -> ((String) -> String?)? { |
| @@ -91,7 +104,9 @@ public enum OrgRenderer { | |||
| 91 | source, | 104 | source, |
| 92 | highlighter: highlighter, | 105 | highlighter: highlighter, |
| 93 | imageURLResolver: options.imageURLResolver(), | 106 | imageURLResolver: options.imageURLResolver(), |
| 94 | linkURLResolver: options.linkURLResolver() | 107 | linkURLResolver: options.linkURLResolver(), |
| 108 | metadataHeader: options.metadataHeader, | ||
| 109 | headingLevelOffset: options.headingLevelOffset | ||
| 95 | ) | 110 | ) |
| 96 | } | 111 | } |
| 97 | } | 112 | } |
| @@ -102,7 +117,9 @@ func orgToHTML( | |||
| 102 | _ text: String, | 117 | _ text: String, |
| 103 | highlighter: CodeHighlighter, | 118 | highlighter: CodeHighlighter, |
| 104 | imageURLResolver: ((String) -> String?)? = nil, | 119 | imageURLResolver: ((String) -> String?)? = nil, |
| 105 | linkURLResolver: ((String) -> String?)? = nil | 120 | linkURLResolver: ((String) -> String?)? = nil, |
| 121 | metadataHeader: Bool = true, | ||
| 122 | headingLevelOffset: Int = 0 | ||
| 106 | ) -> String { | 123 | ) -> String { |
| 107 | let normalizedText = text | 124 | let normalizedText = text |
| 108 | .replacingOccurrences(of: "\r\n", with: "\n") | 125 | .replacingOccurrences(of: "\r\n", with: "\n") |
| @@ -284,7 +301,7 @@ func orgToHTML( | |||
| 284 | flushPropertyDrawer() | 301 | flushPropertyDrawer() |
| 285 | } | 302 | } |
| 286 | 303 | ||
| 287 | if title != nil || author != nil || date != nil { | 304 | if metadataHeader, title != nil || author != nil || date != nil { |
| 288 | html += "<div class=\"org-metadata\">\n" | 305 | html += "<div class=\"org-metadata\">\n" |
| 289 | if let title { | 306 | if let title { |
| 290 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" | 307 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" |
| @@ -462,7 +479,7 @@ func orgToHTML( | |||
| 462 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | 479 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { |
| 463 | closeQuoteBlock() | 480 | closeQuoteBlock() |
| 464 | flushBlockState() | 481 | flushBlockState() |
| 465 | let level = match.1.count | 482 | let level = min(6, max(1, match.1.count + headingLevelOffset)) |
| 466 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | 483 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) |
| 467 | html += "<h\(level)>" + content + "</h\(level)>\n" | 484 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 468 | continue | 485 | continue |
Sources/OrgSwift/Skeleton.swift added +218
| @@ -0,0 +1,218 @@ | |||
| 1 | // HTML → semantic skeleton reduction. | ||
| 2 | // | ||
| 3 | // A faithful port of orgo's `skeleton()` (src/skeleton.rs) and the contract described in | ||
| 4 | // the org-conformance corpus (SKELETON.md). It exists so OrgSwift can be measured against | ||
| 5 | // the same goldens every other org renderer is: reduce this renderer's HTML to a | ||
| 6 | // skeleton, reduce orgo's reference HTML to a skeleton, and compare. The two renderers | ||
| 7 | // wrap and class things completely differently; the skeleton is the part they can | ||
| 8 | // meaningfully agree on. | ||
| 9 | // | ||
| 10 | // The reduction must be byte-identical to orgo's, or a comparison means nothing. The | ||
| 11 | // package's conformance test self-checks that first, by feeding each corpus `.html` | ||
| 12 | // through this port and requiring it to reproduce the checked-in `.skeleton`. | ||
| 13 | |||
| 14 | import Foundation | ||
| 15 | |||
| 16 | public enum OrgSkeleton { | ||
| 17 | /// Elements dropped entirely: `div` is layout, `span` is per-token highlighter noise. | ||
| 18 | private static let ignored: Set<String> = ["div", "span"] | ||
| 19 | |||
| 20 | /// The only content-bearing attributes; everything else is generated or cosmetic. | ||
| 21 | private static let keptAttrs = ["href", "src"] | ||
| 22 | |||
| 23 | /// HTML void elements never emit a close event. | ||
| 24 | private static let void: Set<String> = [ | ||
| 25 | "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr", | ||
| 26 | ] | ||
| 27 | |||
| 28 | /// Reduce an HTML fragment to one line per element open, element close, or text run. | ||
| 29 | public static func skeleton(_ html: String) -> [String] { | ||
| 30 | var out: [String] = [] | ||
| 31 | let chars = Array(html) | ||
| 32 | var i = 0 | ||
| 33 | var text = "" | ||
| 34 | |||
| 35 | while i < chars.count { | ||
| 36 | if chars[i] != "<" { | ||
| 37 | text.append(chars[i]) | ||
| 38 | i += 1 | ||
| 39 | continue | ||
| 40 | } | ||
| 41 | |||
| 42 | // Comments and doctypes carry nothing. | ||
| 43 | if i + 1 < chars.count, chars[i + 1] == "!" { | ||
| 44 | if let end = findFrom(chars, i, ">") { | ||
| 45 | i = end + 1 | ||
| 46 | } else { | ||
| 47 | break | ||
| 48 | } | ||
| 49 | continue | ||
| 50 | } | ||
| 51 | guard let end = findFrom(chars, i, ">") else { break } | ||
| 52 | let rawFull = String(chars[(i + 1)..<end]) | ||
| 53 | i = end + 1 | ||
| 54 | |||
| 55 | // trim → strip trailing '/' → trim | ||
| 56 | var raw = rawFull.trimmingCharacters(in: .whitespaces) | ||
| 57 | if raw.hasSuffix("/") { raw.removeLast() } | ||
| 58 | raw = raw.trimmingCharacters(in: .whitespaces) | ||
| 59 | |||
| 60 | if raw.hasPrefix("/") { | ||
| 61 | let name = raw.dropFirst().trimmingCharacters(in: .whitespaces).lowercased() | ||
| 62 | if !ignored.contains(name) && !void.contains(name) { | ||
| 63 | flushText(&text, &out) | ||
| 64 | out.append("</\(name)>") | ||
| 65 | } | ||
| 66 | continue | ||
| 67 | } | ||
| 68 | |||
| 69 | let (name, rest) = splitFirstWhitespace(raw) | ||
| 70 | let lname = name.lowercased() | ||
| 71 | if lname.isEmpty || ignored.contains(lname) { | ||
| 72 | continue | ||
| 73 | } | ||
| 74 | let attrs = keptAttributes(rest) | ||
| 75 | flushText(&text, &out) | ||
| 76 | out.append("<\(lname)\(attrs)>") | ||
| 77 | } | ||
| 78 | flushText(&text, &out) | ||
| 79 | return out | ||
| 80 | } | ||
| 81 | |||
| 82 | private static func flushText(_ text: inout String, _ out: inout [String]) { | ||
| 83 | let decoded = decodeEntities(text) | ||
| 84 | let collapsed = decoded.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") | ||
| 85 | if !collapsed.isEmpty { | ||
| 86 | out.append(debugQuote(collapsed)) | ||
| 87 | } | ||
| 88 | text = "" | ||
| 89 | } | ||
| 90 | |||
| 91 | /// Reproduce Rust's `format!("{:?}", s)` for whitespace-collapsed text: wrap in double | ||
| 92 | /// quotes and escape `\` and `"`. No control characters survive whitespace collapse, | ||
| 93 | /// so no further escaping is needed to match the goldens. | ||
| 94 | private static func debugQuote(_ s: String) -> String { | ||
| 95 | var escaped = "" | ||
| 96 | escaped.reserveCapacity(s.count + 2) | ||
| 97 | for c in s { | ||
| 98 | if c == "\\" || c == "\"" { escaped.append("\\") } | ||
| 99 | escaped.append(c) | ||
| 100 | } | ||
| 101 | return "\"\(escaped)\"" | ||
| 102 | } | ||
| 103 | |||
| 104 | private static func findFrom(_ chars: [Character], _ from: Int, _ needle: Character) -> Int? { | ||
| 105 | var k = from | ||
| 106 | while k < chars.count { | ||
| 107 | if chars[k] == needle { return k } | ||
| 108 | k += 1 | ||
| 109 | } | ||
| 110 | return nil | ||
| 111 | } | ||
| 112 | |||
| 113 | private static func splitFirstWhitespace(_ s: String) -> (String, String) { | ||
| 114 | guard let idx = s.firstIndex(where: { $0.isWhitespace }) else { return (s, "") } | ||
| 115 | let name = String(s[s.startIndex..<idx]) | ||
| 116 | let rest = String(s[s.index(after: idx)...]) | ||
| 117 | return (name, rest) | ||
| 118 | } | ||
| 119 | |||
| 120 | private static func keptAttributes(_ rest: String) -> String { | ||
| 121 | var result = "" | ||
| 122 | for attr in keptAttrs { | ||
| 123 | if let value = attributeValue(rest, attr) { | ||
| 124 | result += " \(attr)=\"\(decodeEntities(value))\"" | ||
| 125 | } | ||
| 126 | } | ||
| 127 | return result | ||
| 128 | } | ||
| 129 | |||
| 130 | private static func attributeValue(_ rest: String, _ name: String) -> String? { | ||
| 131 | let chars = Array(rest) | ||
| 132 | let nameChars = Array(name) | ||
| 133 | var search = 0 | ||
| 134 | while let pos = indexOf(chars, nameChars, from: search) { | ||
| 135 | let beforeOK = pos == 0 || chars[pos - 1].isWhitespace | ||
| 136 | var after = pos + nameChars.count | ||
| 137 | while after < chars.count && chars[after].isWhitespace { after += 1 } | ||
| 138 | if beforeOK && after < chars.count && chars[after] == "=" { | ||
| 139 | var value = after + 1 | ||
| 140 | while value < chars.count && chars[value].isWhitespace { value += 1 } | ||
| 141 | guard value < chars.count else { return nil } | ||
| 142 | let quote = chars[value] | ||
| 143 | if quote == "\"" || quote == "'" { | ||
| 144 | var end = value + 1 | ||
| 145 | while end < chars.count && chars[end] != quote { end += 1 } | ||
| 146 | guard end < chars.count else { return nil } | ||
| 147 | return String(chars[(value + 1)..<end]) | ||
| 148 | } | ||
| 149 | var end = value | ||
| 150 | while end < chars.count && !chars[end].isWhitespace { end += 1 } | ||
| 151 | return String(chars[value..<end]) | ||
| 152 | } | ||
| 153 | search = pos + nameChars.count | ||
| 154 | } | ||
| 155 | return nil | ||
| 156 | } | ||
| 157 | |||
| 158 | private static func indexOf(_ haystack: [Character], _ needle: [Character], from: Int) -> Int? { | ||
| 159 | if needle.isEmpty { return from } | ||
| 160 | var k = from | ||
| 161 | while k + needle.count <= haystack.count { | ||
| 162 | if Array(haystack[k..<(k + needle.count)]) == needle { return k } | ||
| 163 | k += 1 | ||
| 164 | } | ||
| 165 | return nil | ||
| 166 | } | ||
| 167 | |||
| 168 | /// Decode the entities either exporter is likely to emit. | ||
| 169 | static func decodeEntities(_ s: String) -> String { | ||
| 170 | var out = "" | ||
| 171 | out.reserveCapacity(s.count) | ||
| 172 | let chars = Array(s) | ||
| 173 | var i = 0 | ||
| 174 | while i < chars.count { | ||
| 175 | guard chars[i] == "&" else { out.append(chars[i]); i += 1; continue } | ||
| 176 | // find ';' within 12 chars | ||
| 177 | var semi: Int? = nil | ||
| 178 | var k = i + 1 | ||
| 179 | while k < chars.count && k - i <= 12 { | ||
| 180 | if chars[k] == ";" { semi = k; break } | ||
| 181 | k += 1 | ||
| 182 | } | ||
| 183 | guard let semiIdx = semi else { out.append("&"); i += 1; continue } | ||
| 184 | let entity = String(chars[(i + 1)..<semiIdx]) | ||
| 185 | let decoded = decodeEntity(entity) | ||
| 186 | if let d = decoded { | ||
| 187 | out.append(d == "\u{a0}" ? " " : String(d)) | ||
| 188 | i = semiIdx + 1 | ||
| 189 | } else { | ||
| 190 | out.append("&") | ||
| 191 | i += 1 | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return out | ||
| 195 | } | ||
| 196 | |||
| 197 | private static func decodeEntity(_ entity: String) -> Character? { | ||
| 198 | switch entity { | ||
| 199 | case "amp": return "&" | ||
| 200 | case "lt": return "<" | ||
| 201 | case "gt": return ">" | ||
| 202 | case "quot": return "\"" | ||
| 203 | case "apos": return "'" | ||
| 204 | case "nbsp": return "\u{a0}" | ||
| 205 | default: | ||
| 206 | guard entity.hasPrefix("#") else { return nil } | ||
| 207 | let num = String(entity.dropFirst()) | ||
| 208 | let scalar: UInt32? | ||
| 209 | if num.hasPrefix("x") || num.hasPrefix("X") { | ||
| 210 | scalar = UInt32(num.dropFirst(), radix: 16) | ||
| 211 | } else { | ||
| 212 | scalar = UInt32(num) | ||
| 213 | } | ||
| 214 | guard let s = scalar, let u = Unicode.Scalar(s) else { return nil } | ||
| 215 | return Character(u) | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
Tests/OrgSwiftTests/ConformanceTests.swift added +117
| @@ -0,0 +1,117 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgSwift | ||
| 4 | |||
| 5 | /// Locate the org-conformance corpus: env var first, then the sibling checkout that sits | ||
| 6 | /// next to this package under a shared parent (…/org-swift and …/org-conformance). | ||
| 7 | private func corpusCasesDir() -> URL? { | ||
| 8 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { | ||
| 9 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") | ||
| 10 | if FileManager.default.fileExists(atPath: cases.path) { return cases } | ||
| 11 | } | ||
| 12 | // #filePath = …/org-swift/Tests/OrgSwiftTests/ConformanceTests.swift | ||
| 13 | let pkgRoot = URL(fileURLWithPath: #filePath) | ||
| 14 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | ||
| 15 | let sibling = pkgRoot.deletingLastPathComponent() | ||
| 16 | .appendingPathComponent("org-conformance").appendingPathComponent("cases") | ||
| 17 | if FileManager.default.fileExists(atPath: sibling.path) { return sibling } | ||
| 18 | return nil | ||
| 19 | } | ||
| 20 | |||
| 21 | private func caseNames(_ dir: URL) -> [String] { | ||
| 22 | let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] | ||
| 23 | return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() | ||
| 24 | } | ||
| 25 | |||
| 26 | private func read(_ dir: URL, _ name: String, _ ext: String) -> String { | ||
| 27 | (try? String(contentsOf: dir.appendingPathComponent("\(name).\(ext)"), encoding: .utf8)) ?? "" | ||
| 28 | } | ||
| 29 | |||
| 30 | private func goldenSkeleton(_ dir: URL, _ name: String) -> [String] { | ||
| 31 | let raw = read(dir, name, "skeleton") | ||
| 32 | let trimmed = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw | ||
| 33 | return trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n") | ||
| 34 | } | ||
| 35 | |||
| 36 | /// orgo's `render()` produces body content only: `#+TITLE`/`#+AUTHOR`/`#+DATE` are document | ||
| 37 | /// metadata carried by the page template, and a top-level `*` heading is `<h2>` because the | ||
| 38 | /// title owns `<h1>`. The corpus goldens are that body content, so the renderer is measured | ||
| 39 | /// in the matching configuration. | ||
| 40 | private let orgoCompatibleOptions = OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1) | ||
| 41 | |||
| 42 | /// What each corpus case does against orgo today. This is a *reviewed* record, not a wish: | ||
| 43 | /// a case that starts matching, or a matching case that regresses, both fail the test and | ||
| 44 | /// demand this map be updated — which is the point. Each `.diverges` reason names the | ||
| 45 | /// missing capability, and together they are OrgSwift's conformance backlog (see GAPS.md). | ||
| 46 | private enum Expectation { | ||
| 47 | case matches | ||
| 48 | case diverges(String) | ||
| 49 | } | ||
| 50 | |||
| 51 | private let expectations: [String: Expectation] = [ | ||
| 52 | "table": .matches, | ||
| 53 | "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), | ||
| 54 | "core": .diverges("bare URLs are not autolinked"), | ||
| 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), | ||
| 56 | "footnote": .diverges("footnote references and definitions are not parsed"), | ||
| 57 | "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"), | ||
| 58 | "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), | ||
| 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"), | ||
| 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"), | ||
| 63 | "timestamps": .diverges("active/inactive timestamps are not parsed"), | ||
| 64 | ] | ||
| 65 | |||
| 66 | struct ConformanceTests { | ||
| 67 | /// The skeleton port must be byte-identical to orgo's, or comparing renderers means | ||
| 68 | /// nothing. Prove it against the reference HTML first: feed each corpus `.html` through | ||
| 69 | /// the Swift port and require it to reproduce the checked-in `.skeleton`. A failure here | ||
| 70 | /// is a port bug, isolated from any renderer question. | ||
| 71 | @Test | ||
| 72 | func skeletonPortMatchesGoldens() throws { | ||
| 73 | guard let dir = corpusCasesDir() else { | ||
| 74 | print("org-conformance corpus not found — skipping (set ORG_CONFORMANCE_DIR)") | ||
| 75 | return | ||
| 76 | } | ||
| 77 | for name in caseNames(dir) { | ||
| 78 | let got = OrgSkeleton.skeleton(read(dir, name, "html")) | ||
| 79 | #expect(got == goldenSkeleton(dir, name), "skeleton port diverges from orgo on '\(name)'") | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Render every case with OrgSwift and check the result against the reviewed | ||
| 84 | /// expectation map. Green when reality matches the record; a case that newly conforms | ||
| 85 | /// (a gap closed) or newly diverges (a regression) fails and points at the map. | ||
| 86 | @Test | ||
| 87 | func renderConformanceMatchesExpectation() throws { | ||
| 88 | guard let dir = corpusCasesDir() else { | ||
| 89 | print("org-conformance corpus not found — skipping (set ORG_CONFORMANCE_DIR)") | ||
| 90 | return | ||
| 91 | } | ||
| 92 | let dump = ProcessInfo.processInfo.environment["ORG_DUMP"] != nil | ||
| 93 | for name in caseNames(dir) { | ||
| 94 | let expected = goldenSkeleton(dir, name) | ||
| 95 | let got = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(read(dir, name, "org"), options: orgoCompatibleOptions)) | ||
| 96 | let conforms = got == expected | ||
| 97 | |||
| 98 | switch expectations[name] { | ||
| 99 | case .matches: | ||
| 100 | #expect(conforms, "'\(name)' was expected to match orgo but diverged") | ||
| 101 | case .diverges(let reason): | ||
| 102 | #expect(!conforms, "'\(name)' now MATCHES orgo — gap closed (\(reason)). Move it to .matches in expectations.") | ||
| 103 | case nil: | ||
| 104 | Issue.record("'\(name)' has no entry in the expectations map") | ||
| 105 | } | ||
| 106 | |||
| 107 | if dump && (conforms != (expectations[name].map { if case .matches = $0 { true } else { false } } ?? false)) { | ||
| 108 | let maxN = max(got.count, expected.count) | ||
| 109 | print("---- DIFF \(name) ----") | ||
| 110 | for k in 0..<maxN where (k < expected.count ? expected[k] : "∅") != (k < got.count ? got[k] : "∅") { | ||
| 111 | print(" orgo[\(k)]=\(k < expected.count ? expected[k] : "∅")") | ||
| 112 | print(" ours[\(k)]=\(k < got.count ? got[k] : "∅")") | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } | ||
Tests/OrgSwiftTests/OrgRendererTests.swift +30
| @@ -161,4 +161,34 @@ struct OrgRendererTests { | |||
| 161 | #expect(html.contains("text-align: center;")) | 161 | #expect(html.contains("text-align: center;")) |
| 162 | #expect(html.contains("text-align: right;")) | 162 | #expect(html.contains("text-align: right;")) |
| 163 | } | 163 | } |
| 164 | |||
| 165 | @Test | ||
| 166 | func metadataHeaderShownByDefault() { | ||
| 167 | let html = render("#+TITLE: My Doc\n\nBody.") | ||
| 168 | #expect(html.contains("<h1 class=\"org-title\">My Doc</h1>")) | ||
| 169 | } | ||
| 170 | |||
| 171 | @Test | ||
| 172 | func metadataHeaderSuppressed() { | ||
| 173 | let html = render("#+TITLE: My Doc\n\nBody.", options: OrgRenderOptions(metadataHeader: false)) | ||
| 174 | #expect(!html.contains("org-title")) | ||
| 175 | #expect(!html.contains("My Doc")) | ||
| 176 | #expect(html.contains("<p>Body.</p>")) | ||
| 177 | } | ||
| 178 | |||
| 179 | @Test | ||
| 180 | func headingLevelOffsetShiftsAndClamps() { | ||
| 181 | let base = render("* Top\n\n** Sub") | ||
| 182 | #expect(base.contains("<h1>Top</h1>")) | ||
| 183 | #expect(base.contains("<h2>Sub</h2>")) | ||
| 184 | |||
| 185 | let shifted = render("* Top\n\n** Sub", options: OrgRenderOptions(headingLevelOffset: 1)) | ||
| 186 | #expect(shifted.contains("<h2>Top</h2>")) | ||
| 187 | #expect(shifted.contains("<h3>Sub</h3>")) | ||
| 188 | |||
| 189 | // A six-star heading with a +1 offset clamps to h6, never h7. | ||
| 190 | let deep = render("****** Deep", options: OrgRenderOptions(headingLevelOffset: 1)) | ||
| 191 | #expect(deep.contains("<h6>Deep</h6>")) | ||
| 192 | #expect(!deep.contains("<h7")) | ||
| 193 | } | ||
| 164 | } | 194 | } |