Commit 242cd9f2c1
Verified · cmc
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 | 50 | `./images/badge.svg` then resolves to |
| 51 | 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 | 64 | ### Syntax highlighting |
| 54 | 65 | |
| 55 | 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 | 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 | 30 | public var ref: String |
| 31 | 31 | /// Path of the README being rendered, used to resolve paths relative to it. |
| 32 | 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 | 43 | public init( |
| 35 | 44 | host: String = "git.sr.ht", |
| 36 | 45 | owner: String? = nil, |
| 37 | 46 | repositoryName: String? = nil, |
| 38 | 47 | ref: String = "HEAD", |
| 39 | readmePath: String? = nil | |
| 48 | readmePath: String? = nil, | |
| 49 | metadataHeader: Bool = true, | |
| 50 | headingLevelOffset: Int = 0 | |
| 40 | 51 | ) { |
| 41 | 52 | self.host = host |
| 42 | 53 | self.owner = owner |
| 43 | 54 | self.repositoryName = repositoryName |
| 44 | 55 | self.ref = ref |
| 45 | 56 | self.readmePath = readmePath |
| 57 | self.metadataHeader = metadataHeader | |
| 58 | self.headingLevelOffset = headingLevelOffset | |
| 46 | 59 | } |
| 47 | 60 | |
| 48 | 61 | func imageURLResolver() -> ((String) -> String?)? { |
| @@ -91,7 +104,9 @@ public enum OrgRenderer { | ||
| 91 | 104 | source, |
| 92 | 105 | highlighter: highlighter, |
| 93 | 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 | 117 | _ text: String, |
| 103 | 118 | highlighter: CodeHighlighter, |
| 104 | 119 | imageURLResolver: ((String) -> String?)? = nil, |
| 105 | linkURLResolver: ((String) -> String?)? = nil | |
| 120 | linkURLResolver: ((String) -> String?)? = nil, | |
| 121 | metadataHeader: Bool = true, | |
| 122 | headingLevelOffset: Int = 0 | |
| 106 | 123 | ) -> String { |
| 107 | 124 | let normalizedText = text |
| 108 | 125 | .replacingOccurrences(of: "\r\n", with: "\n") |
| @@ -284,7 +301,7 @@ func orgToHTML( | ||
| 284 | 301 | flushPropertyDrawer() |
| 285 | 302 | } |
| 286 | 303 | |
| 287 | if title != nil || author != nil || date != nil { | |
| 304 | if metadataHeader, title != nil || author != nil || date != nil { | |
| 288 | 305 | html += "<div class=\"org-metadata\">\n" |
| 289 | 306 | if let title { |
| 290 | 307 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" |
| @@ -462,7 +479,7 @@ func orgToHTML( | ||
| 462 | 479 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { |
| 463 | 480 | closeQuoteBlock() |
| 464 | 481 | flushBlockState() |
| 465 | let level = match.1.count | |
| 482 | let level = min(6, max(1, match.1.count + headingLevelOffset)) | |
| 466 | 483 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) |
| 467 | 484 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 468 | 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 | 161 | #expect(html.contains("text-align: center;")) |
| 162 | 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 | } |