Commit 256ee33237
Verified · cmc
GAPS.md +15 −5
| @@ -5,9 +5,9 @@ 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, **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 | |
| 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 | 11 | matching and the test fails until it is moved to `.matches` — that is how this list stays |
| 12 | 12 | honest. |
| 13 | 13 | |
| @@ -15,8 +15,6 @@ This is the backlog the shared package exists to work through. Roughly in value | ||
| 15 | 15 | |
| 16 | 16 | | Case | Missing capability | |
| 17 | 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 | 18 | | `headings` | Heading `:tags:` are not stripped/rendered; property drawers render as a visible `<dl>`. | |
| 21 | 19 | | `minimal` | Property drawers (`:PROPERTIES:` … `:END:`) render as a visible `<dl>` instead of being dropped. | |
| 22 | 20 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | |
| @@ -35,3 +33,15 @@ they are presentation policy, not parser capability: | ||
| 35 | 33 | (`true`); orgo carries the title in the page template, so conformance runs with `false`. |
| 36 | 34 | - **`headingLevelOffset`** — added to a heading's star count. Default `0` renders `*` as |
| 37 | 35 | `<h1>`; orgo uses `1` (`*` → `<h2>`, leaving `<h1>` for the title). |
| 36 | ||
| 37 | ## Closed | |
| 38 | ||
| 39 | **`timestamps`** — active/inactive timestamps, times, same-day and multi-day ranges now | |
| 40 | render as `<time class="timestamp" datetime>` elements matching orgo; day names and | |
| 41 | repeater/warning cookies are dropped, non-timestamps (`3 < 4`, `[not a stamp]`) stay | |
| 42 | literal. | |
| 43 | **`footnote`** — references (`[fn:1]`), inline footnotes (`[fn:label:text]`), and | |
| 44 | reference-style definitions now render as `<sup>` anchors plus a `<section | |
| 45 | 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 | |
| 47 | is not yet collected (it degrades to literal text). | |
README.md +5 −3
| @@ -13,9 +13,11 @@ Headings, paragraphs, ordered/unordered lists (with nesting, wrapped lines, and | ||
| 13 | 13 | `[ ]`/`[x]` task checkboxes), tables (with `:---:` alignment), `#+begin_src` / |
| 14 | 14 | `example` / `quote` / `center` / `verse` blocks, property drawers, |
| 15 | 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links |
| 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, and inline | |
| 17 | markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, `_underline_`, | |
| 18 | email autolinks). | |
| 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps | |
| 17 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes | |
| 18 | (references, inline, and definitions → a `<section class="footnotes">`), and | |
| 19 | inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, | |
| 20 | `_underline_`, email autolinks). | |
| 19 | 21 | |
| 20 | 22 | Output is sanitized: only `http`/`https`/`mailto` link schemes and |
| 21 | 23 | `http`/`https` image schemes are allowed; everything else is dropped. |
Sources/OrgSwift/Footnotes.swift added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Org footnotes: references `[fn:1]` in running text, inline footnotes that define at the | |
| 4 | // point of use `[fn:label:text]`, and reference-style definitions on their own line | |
| 5 | // `[fn:1] the definition`. References become `<sup>` anchors; the definitions are | |
| 6 | // collected and emitted as a `<section class="footnotes">` at the end of the document, in | |
| 7 | // order of first reference — matching orgo's `ox-html` shape. | |
| 8 | ||
| 9 | /// Accumulates footnote references and definitions across a document render. A single | |
| 10 | /// instance lives for one `orgToHTML` call; references register as paragraphs flush, and | |
| 11 | /// the section is rendered once at the end. | |
| 12 | final class FootnoteCollector { | |
| 13 | private(set) var order: [String] = [] | |
| 14 | private var numbers: [String: Int] = [:] | |
| 15 | private var inlineText: [String: String] = [:] | |
| 16 | private var definitions: [String: String] = [:] | |
| 17 | ||
| 18 | var hasEntries: Bool { !order.isEmpty } | |
| 19 | ||
| 20 | /// Register a reference and return its display number. `inline` is the text of an | |
| 21 | /// inline footnote (`[fn:label:inline]`), nil for a plain reference. | |
| 22 | func reference(label: String, inline: String?) -> Int { | |
| 23 | let number: Int | |
| 24 | if let existing = numbers[label] { | |
| 25 | number = existing | |
| 26 | } else { | |
| 27 | number = order.count + 1 | |
| 28 | numbers[label] = number | |
| 29 | order.append(label) | |
| 30 | } | |
| 31 | if let inline, inlineText[label] == nil { | |
| 32 | inlineText[label] = inline | |
| 33 | } | |
| 34 | return number | |
| 35 | } | |
| 36 | ||
| 37 | /// Record a reference-style definition line. A definition can arrive before or after | |
| 38 | /// its reference; the number is assigned by reference order regardless. | |
| 39 | func define(label: String, text: String) { | |
| 40 | if numbers[label] == nil { | |
| 41 | numbers[label] = order.count + 1 | |
| 42 | order.append(label) | |
| 43 | } | |
| 44 | definitions[label] = text | |
| 45 | } | |
| 46 | ||
| 47 | /// Render the footnotes section, or "" if there are none. `inlineRenderer` renders a | |
| 48 | /// block definition's own inline markup (it must not itself collect footnotes). Inline | |
| 49 | /// footnote text was captured mid-inline and is already escaped, so it is emitted | |
| 50 | /// directly — running it through `inlineRenderer` again would double-escape it. | |
| 51 | func renderSection(inlineRenderer: (String) -> String) -> String { | |
| 52 | guard hasEntries else { return "" } | |
| 53 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | |
| 54 | for label in order { | |
| 55 | let n = numbers[label] ?? 0 | |
| 56 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | |
| 57 | if let inline = inlineText[label] { | |
| 58 | html += "<li id=\"fn-\(n)\">\(inline) \(back)</li>\n" | |
| 59 | } else { | |
| 60 | let body = definitions[label].map(inlineRenderer) ?? "" | |
| 61 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | |
| 62 | } | |
| 63 | } | |
| 64 | html += "</ol>\n</section>\n" | |
| 65 | return html | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | /// Detect a reference-style footnote definition line: `[fn:label] text…`. Returns the | |
| 70 | /// label and the definition text, or nil if the line is not one. | |
| 71 | func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? { | |
| 72 | guard let match = line.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)\]\s+(.+)$/) else { | |
| 73 | return nil | |
| 74 | } | |
| 75 | return (String(match.1), String(match.2)) | |
| 76 | } | |
Sources/OrgSwift/Inline.swift +41 −1
| @@ -3,7 +3,8 @@ import Foundation | ||
| 3 | 3 | func processOrgInline( |
| 4 | 4 | _ text: String, |
| 5 | 5 | imageURLResolver: ((String) -> String?)? = nil, |
| 6 | linkURLResolver: ((String) -> String?)? = nil | |
| 6 | linkURLResolver: ((String) -> String?)? = nil, | |
| 7 | footnotes: FootnoteCollector? = nil | |
| 7 | 8 | ) -> String { |
| 8 | 9 | var result = escapeHTML(text) |
| 9 | 10 | var protectedFragments: [String: String] = [:] |
| @@ -35,6 +36,45 @@ func processOrgInline( | ||
| 35 | 36 | imageURLResolver: imageURLResolver, |
| 36 | 37 | linkURLResolver: linkURLResolver |
| 37 | 38 | ) |
| 39 | ||
| 40 | result = protectTimestamps(in: result, protectedFragments: &protectedFragments) | |
| 41 | ||
| 42 | if let footnotes { | |
| 43 | // Assign footnote numbers in textual (forward) order first. The protect passes | |
| 44 | // below replace right-to-left, so registering there would number references | |
| 45 | // backwards and reverse the definitions section; this pre-scan fixes the order, | |
| 46 | // and the passes then just look up the already-assigned number. | |
| 47 | if let scan = try? NSRegularExpression(pattern: #"\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]"#) { | |
| 48 | let ns = result as NSString | |
| 49 | for m in scan.matches(in: result, range: NSRange(location: 0, length: ns.length)) { | |
| 50 | let label = ns.substring(with: m.range(at: 1)) | |
| 51 | let textRange = m.range(at: 2) | |
| 52 | let inline = textRange.location == NSNotFound ? nil : ns.substring(with: textRange) | |
| 53 | _ = footnotes.reference(label: label, inline: inline) | |
| 54 | } | |
| 55 | } | |
| 56 | // Inline footnote `[fn:label:text]` first (more specific), then a plain reference. | |
| 57 | result = protectMatches( | |
| 58 | in: result, | |
| 59 | pattern: #"\[fn:([A-Za-z0-9_-]+):([^\]]*)\]"#, | |
| 60 | protectedFragments: &protectedFragments | |
| 61 | ) { match, nsText in | |
| 62 | let label = nsText.substring(with: match.range(at: 1)) | |
| 63 | let inline = nsText.substring(with: match.range(at: 2)) | |
| 64 | let n = footnotes.reference(label: label, inline: inline) | |
| 65 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 66 | } | |
| 67 | result = protectMatches( | |
| 68 | in: result, | |
| 69 | pattern: #"\[fn:([A-Za-z0-9_-]+)\]"#, | |
| 70 | protectedFragments: &protectedFragments | |
| 71 | ) { match, nsText in | |
| 72 | let label = nsText.substring(with: match.range(at: 1)) | |
| 73 | let n = footnotes.reference(label: label, inline: nil) | |
| 74 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 38 | 78 | result = protectMatches( |
| 39 | 79 | in: result, |
| 40 | 80 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, |
Sources/OrgSwift/OrgRenderer.swift +16 −2
| @@ -165,6 +165,7 @@ func orgToHTML( | ||
| 165 | 165 | var pendingBlockCaption: String? |
| 166 | 166 | var activeBlockCaption: String? |
| 167 | 167 | var isWrappingBlockFigure = false |
| 168 | let footnotes = FootnoteCollector() | |
| 168 | 169 | |
| 169 | 170 | func beginPendingBlockWrapperIfNeeded() { |
| 170 | 171 | guard pendingBlockName != nil || pendingBlockCaption != nil else { return } |
| @@ -191,7 +192,7 @@ func orgToHTML( | ||
| 191 | 192 | let normalizedParagraph = paragraph |
| 192 | 193 | .map { $0.trimmingCharacters(in: .whitespaces) } |
| 193 | 194 | .joined(separator: " ") |
| 194 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n" | |
| 195 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) + "</p>\n" | |
| 195 | 196 | paragraph = [] |
| 196 | 197 | } |
| 197 | 198 | } |
| @@ -475,12 +476,21 @@ func orgToHTML( | ||
| 475 | 476 | continue |
| 476 | 477 | } |
| 477 | 478 | |
| 479 | // Reference-style footnote definition: [fn:label] text. Collected out of the body | |
| 480 | // flow and emitted in the footnotes section at the end. | |
| 481 | if let definition = orgFootnoteDefinition(in: trimmed) { | |
| 482 | closeQuoteBlock() | |
| 483 | flushBlockState() | |
| 484 | footnotes.define(label: definition.label, text: definition.text) | |
| 485 | continue | |
| 486 | } | |
| 487 | ||
| 478 | 488 | // Org headings: * heading, ** heading, *** heading |
| 479 | 489 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { |
| 480 | 490 | closeQuoteBlock() |
| 481 | 491 | flushBlockState() |
| 482 | 492 | let level = min(6, max(1, match.1.count + headingLevelOffset)) |
| 483 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 493 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) | |
| 484 | 494 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 485 | 495 | continue |
| 486 | 496 | } |
| @@ -542,5 +552,9 @@ func orgToHTML( | ||
| 542 | 552 | closeQuoteBlock() |
| 543 | 553 | flushBlockState() |
| 544 | 554 | |
| 555 | html += footnotes.renderSection { text in | |
| 556 | processOrgInline(text, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 557 | } | |
| 558 | ||
| 545 | 559 | return html |
| 546 | 560 | } |
Sources/OrgSwift/Timestamps.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Org timestamps: active `<2024-01-15 Mon>`, inactive `[2024-01-15 Mon]`, with a time | |
| 4 | // `<… 10:30>`, a same-day time range `<… 10:00-11:45>`, and a multi-day range | |
| 5 | // `<…>--<…>`. Rendered as `<time>` elements the way orgo does: the date is data a | |
| 6 | // browser can act on, day names and repeater/warning cookies are dropped, and a range | |
| 7 | // becomes two `<time>`s joined by an en-dash. The interior arrives already | |
| 8 | // HTML-escaped, so these operate on `<…>` / `[…]`. | |
| 9 | ||
| 10 | /// Render one timestamp interior (no surrounding brackets) as one or two `<time>` | |
| 11 | /// elements. `interior` is e.g. "2024-01-15 Mon", "2024-01-15 Mon 10:30", | |
| 12 | /// "2024-01-15 Mon 10:00-11:45". A day name and any repeater/warning cookie are ignored. | |
| 13 | func renderTimestamp(interior: String, inactive: Bool) -> String { | |
| 14 | let ns = interior as NSString | |
| 15 | let full = NSRange(location: 0, length: ns.length) | |
| 16 | let cssClass = inactive ? "timestamp inactive" : "timestamp" | |
| 17 | ||
| 18 | guard let dateRegex = try? NSRegularExpression(pattern: #"\d{4}-\d{2}-\d{2}"#), | |
| 19 | let dateMatch = dateRegex.firstMatch(in: interior, range: full) else { | |
| 20 | // No date — not really a timestamp; hand the text back untouched. | |
| 21 | return interior | |
| 22 | } | |
| 23 | let date = ns.substring(with: dateMatch.range) | |
| 24 | ||
| 25 | var startTime: String? | |
| 26 | var endTime: String? | |
| 27 | if let timeRegex = try? NSRegularExpression(pattern: #"(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?"#), | |
| 28 | let timeMatch = timeRegex.firstMatch(in: interior, range: full) { | |
| 29 | startTime = ns.substring(with: timeMatch.range(at: 1)) | |
| 30 | let endRange = timeMatch.range(at: 2) | |
| 31 | if endRange.location != NSNotFound { | |
| 32 | endTime = ns.substring(with: endRange) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | func time(_ datetime: String, _ text: String) -> String { | |
| 37 | #"<time class="\#(cssClass)" datetime="\#(datetime)">\#(text)</time>"# | |
| 38 | } | |
| 39 | ||
| 40 | if let startTime, let endTime { | |
| 41 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 42 | + "–" | |
| 43 | + time("\(date)T\(endTime)", endTime) | |
| 44 | } | |
| 45 | if let startTime { | |
| 46 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 47 | } | |
| 48 | return time(date, date) | |
| 49 | } | |
| 50 | ||
| 51 | /// Extract every timestamp in already-escaped inline text into protected fragments, | |
| 52 | /// leaving placeholders behind so later emphasis passes cannot touch a `<time>`. | |
| 53 | func protectTimestamps(in text: String, protectedFragments: inout [String: String]) -> String { | |
| 54 | var result = text | |
| 55 | ||
| 56 | // Multi-day range first, so its two `<…>` are consumed as one unit before the | |
| 57 | // single-timestamp pass sees them. `--` between them becomes the en-dash. | |
| 58 | result = protectMatches( | |
| 59 | in: result, | |
| 60 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>--<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 61 | protectedFragments: &protectedFragments | |
| 62 | ) { match, nsText in | |
| 63 | let a = nsText.substring(with: match.range(at: 1)) | |
| 64 | let b = nsText.substring(with: match.range(at: 2)) | |
| 65 | return renderTimestamp(interior: a, inactive: false) | |
| 66 | + "–" | |
| 67 | + renderTimestamp(interior: b, inactive: false) | |
| 68 | } | |
| 69 | ||
| 70 | // Active single. | |
| 71 | result = protectMatches( | |
| 72 | in: result, | |
| 73 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 74 | protectedFragments: &protectedFragments | |
| 75 | ) { match, nsText in | |
| 76 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: false) | |
| 77 | } | |
| 78 | ||
| 79 | // Inactive. Requires a date inside, so `[not a stamp]` is left alone. | |
| 80 | result = protectMatches( | |
| 81 | in: result, | |
| 82 | pattern: #"\[(\d{4}-\d{2}-\d{2}[^\]]*?)\]"#, | |
| 83 | protectedFragments: &protectedFragments | |
| 84 | ) { match, nsText in | |
| 85 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: true) | |
| 86 | } | |
| 87 | ||
| 88 | return result | |
| 89 | } | |
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
| @@ -53,14 +53,14 @@ private let expectations: [String: Expectation] = [ | ||
| 53 | 53 | "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), |
| 54 | 54 | "core": .diverges("bare URLs are not autolinked"), |
| 55 | 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), |
| 56 | "footnote": .diverges("footnote references and definitions are not parsed"), | |
| 56 | "footnote": .matches, | |
| 57 | 57 | "headings": .diverges("heading :tags: are not parsed; property drawers render as <dl>"), |
| 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 | 60 | "minimal": .diverges("property drawers render as a visible <dl> instead of being dropped"), |
| 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 | "timestamps": .diverges("active/inactive timestamps are not parsed"), | |
| 63 | "timestamps": .matches, | |
| 64 | 64 | ] |
| 65 | 65 | |
| 66 | 66 | struct ConformanceTests { |
Tests/OrgSwiftTests/OrgRendererTests.swift +63
| @@ -191,4 +191,67 @@ struct OrgRendererTests { | ||
| 191 | 191 | #expect(deep.contains("<h6>Deep</h6>")) |
| 192 | 192 | #expect(!deep.contains("<h7")) |
| 193 | 193 | } |
| 194 | ||
| 195 | @Test | |
| 196 | func timestampActiveAndInactive() { | |
| 197 | let html = render("An active <2024-01-15 Mon> and inactive [2024-01-15 Mon].") | |
| 198 | #expect(html.contains(#"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#)) | |
| 199 | #expect(html.contains(#"<time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>"#)) | |
| 200 | } | |
| 201 | ||
| 202 | @Test | |
| 203 | func timestampWithTimeAndRanges() { | |
| 204 | #expect(render("At <2024-01-15 Mon 10:30>.").contains( | |
| 205 | #"<time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>"#)) | |
| 206 | ||
| 207 | let sameDay = render("Range <2024-01-15 Mon 10:00-11:45>.") | |
| 208 | #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#)) | |
| 209 | #expect(sameDay.contains("–")) | |
| 210 | #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#)) | |
| 211 | ||
| 212 | let multiDay = render("Span <2024-01-15 Mon>--<2024-01-20 Sat>.") | |
| 213 | #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#)) | |
| 214 | #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#)) | |
| 215 | #expect(multiDay.contains("–")) | |
| 216 | } | |
| 217 | ||
| 218 | @Test | |
| 219 | func timestampRepeaterDroppedAndNonTimestampsLiteral() { | |
| 220 | #expect(render("Repeats <2024-01-15 Mon +1w>.").contains( | |
| 221 | #"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#)) | |
| 222 | let literal = render("Compare 3 < 4 and [not a stamp].") | |
| 223 | #expect(!literal.contains("<time")) | |
| 224 | #expect(literal.contains("[not a stamp]")) | |
| 225 | } | |
| 226 | ||
| 227 | @Test | |
| 228 | func footnoteReferencesAndSection() { | |
| 229 | let html = render(""" | |
| 230 | A claim.[fn:1] Another.[fn:2] | |
| 231 | ||
| 232 | [fn:1] First note. | |
| 233 | [fn:2] Second with /emphasis/. | |
| 234 | """) | |
| 235 | // References number in textual order and link to the section. | |
| 236 | #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup>"##)) | |
| 237 | #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-2" href="#fn-2">2</a></sup>"##)) | |
| 238 | // The section renders once, in order, with the definitions and back-links. | |
| 239 | #expect(html.contains(#"<section class="footnotes""#)) | |
| 240 | #expect(html.contains(#"<li id="fn-1"><p>First note.</p>"#)) | |
| 241 | #expect(html.contains("Second with <em>emphasis</em>")) | |
| 242 | #expect(html.contains(##"href="#fnr-2""##)) | |
| 243 | // fn-1 must appear before fn-2 in the section. | |
| 244 | let firstIndex = html.range(of: #"id="fn-1""#)!.lowerBound | |
| 245 | let secondIndex = html.range(of: #"id="fn-2""#)!.lowerBound | |
| 246 | #expect(firstIndex < secondIndex) | |
| 247 | } | |
| 248 | ||
| 249 | @Test | |
| 250 | func inlineFootnoteNotWrappedInParagraph() { | |
| 251 | let html = render("See here.[fn:x:defined inline]") | |
| 252 | #expect(html.contains(##"href="#fn-1">1</a>"##)) | |
| 253 | // Inline footnote content sits directly in the <li>, not wrapped in <p>. | |
| 254 | #expect(html.contains(#"<li id="fn-1">defined inline "#)) | |
| 255 | #expect(!html.contains("<li id=\"fn-1\"><p>defined inline")) | |
| 256 | } | |
| 194 | 257 | } |