krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit fc52040bbf

fc52040bbf824c0cb661476947e6ca73fa99e34d

parent: a1aa1a5882

Unsigned

cmc <hello@cleberg.net> · 2026-08-27 18:56 UTC

OrgSwift: autolink bare URLs, render file: images and figures

core: bare http(s) URLs in text are autolinked (trailing punctuation excluded);
task checkboxes render as <code>[ ]</code> like org's exporter, not <input>.
images: file: prefix stripped and relative src/href allowed (scheme-less paths
are safe); a standalone image is <p><img>, promoted to <figure> by #+CAPTION or
#+ATTR_HTML with a numbered <figcaption>; described image links render as links.
Both corpus cases now match orgo (7/12); GAPS.md updated.

Layout: unified · split

GAPS.md +15 −7
@@ -5,18 +5,16 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run
5orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — 5orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` —
6so that only real rendering differences remain. 6so that only real rendering differences remain.
7 7
8Of the 12 corpus cases, **5 match orgo exactly (`table`, `timestamps`, `footnote`, 8Of the 12 corpus cases, **7 match orgo exactly (`table`, `timestamps`, `footnote`,
9`minimal`, `headings`)** and 7 diverge. Each divergence below is a missing capability, 9`minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing
10recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one 10capability, recorded in the `expectations` map in
11is closed, its case flips to matching and the test fails until it is moved to `.matches` — 11`Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching
12that is how this list stays honest. 12and the test fails until it is moved to `.matches` — that is how this list stays honest.
13 13
14This is the backlog the shared package exists to work through. Roughly in value order: 14This 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| `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. |
19| `core` | Bare URLs in running text are not autolinked. |
20| `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. | 18| `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. |
21| `lists` | List nesting deeper than one level is flattened. | 19| `lists` | List nesting deeper than one level is flattened. |
22| `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). | 20| `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). |
@@ -48,3 +46,13 @@ they are presentation policy, not parser capability:
48 title and rendered as `<span class="tag">`. TODO/DONE keywords and priority cookies are 46 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, 47 still emitted as plain title text (orgo wraps them in styled spans); that is a separate,
50 cosmetic difference the skeleton does not distinguish. 48 cosmetic difference the skeleton does not distinguish.
49- **`core`** — bare `http(s)://` URLs in running text are autolinked (trailing sentence
50 punctuation left outside the link), and task checkboxes render as `<code>[ ]</code>` /
51 `<code>[X]</code>` / `<code>[-]</code>` the way org's HTML exporter emits them, rather
52 than as `<input type="checkbox">`.
53- **`images`** — `[[file:…]]` links have the `file:` prefix stripped and relative image
54 `src`/link `href` are allowed (a scheme-less path cannot carry a `javascript:` payload).
55 A standalone image line becomes `<p><img></p>`; an affiliated `#+CAPTION`/`#+ATTR_HTML`
56 promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present.
57 `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and
58 a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image.
README.md +6 −6
@@ -12,12 +12,12 @@ shared across apps.
12Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with 12Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with
13nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` 13nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:`
14alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, 14alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks,
15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links 15`#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME`
16and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps 16figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` →
17(`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes 17link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive,
18(references, inline, and definitions → a `<section class="footnotes">`), and 18times, and ranges → `<time>`), footnotes (references, inline, and definitions →
19inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, 19a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`,
20`_underline_`, email autolinks). 20`~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks).
21 21
22Output is sanitized: only `http`/`https`/`mailto` link schemes and 22Output is sanitized: only `http`/`https`/`mailto` link schemes and
23`http`/`https` image schemes are allowed; everything else is dropped. 23`http`/`https` image schemes are allowed; everything else is dropped.
Sources/OrgSwift/Escaping.swift +12 −6
@@ -43,14 +43,20 @@ private func sanitizeReadmeURLString(
43 return escapeHTMLAttribute(trimmedURL) 43 return escapeHTMLAttribute(trimmedURL)
44 } 44 }
45 45
46 guard let components = URLComponents(string: trimmedURL), 46 if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() {
47 let scheme = components.scheme?.lowercased(), 47 guard allowedSchemes.contains(scheme),
48 allowedSchemes.contains(scheme), 48 let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else {
49 let sanitizedURL = components.url?.absoluteString else { 49 return nil
50 return nil 50 }
51 return escapeHTMLAttribute(sanitizedURL)
51 } 52 }
52 53
53 return escapeHTMLAttribute(sanitizedURL) 54 // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a
55 // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a
56 // resolver turn these into absolute URLs before they reach here. Reject only the
57 // protocol-relative `//host` form, which points off-origin.
58 guard !trimmedURL.hasPrefix("//") else { return nil }
59 return escapeHTMLAttribute(trimmedURL)
54} 60}
55 61
56// MARK: - Regex Helpers 62// MARK: - Regex Helpers
Sources/OrgSwift/Inline.swift +18
@@ -75,6 +75,24 @@ func processOrgInline(
75 } 75 }
76 } 76 }
77 77
78 // Bare URLs in running text become links. Bracketed `[[…]]` links are already
79 // protected above, so this only sees truly bare URLs; trailing sentence punctuation is
80 // left outside the link.
81 result = protectMatches(
82 in: result,
83 pattern: #"https?://[^\s<>()\[\]]+"#,
84 protectedFragments: &protectedFragments
85 ) { match, nsText in
86 var url = nsText.substring(with: match.range)
87 var trailing = ""
88 while let last = url.last, ".,;:!?".contains(last) {
89 trailing = String(last) + trailing
90 url.removeLast()
91 }
92 guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing }
93 return #"<a href="\#(safe)">\#(url)</a>"# + trailing
94 }
95
78 result = protectMatches( 96 result = protectMatches(
79 in: result, 97 in: result,
80 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, 98 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
Sources/OrgSwift/Links.swift +93 −4
@@ -63,12 +63,19 @@ private func parseOrgLink(
63 return nil 63 return nil
64} 64}
65 65
66/// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same
67/// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL.
68func normalizeOrgLinkTarget(_ target: String) -> String {
69 target.hasPrefix("file:") ? String(target.dropFirst(5)) : target
70}
71
66private func renderOrgLink( 72private func renderOrgLink(
67 destination: String, 73 destination rawDestination: String,
68 label: String?, 74 label: String?,
69 imageURLResolver: ((String) -> String?)? = nil, 75 imageURLResolver: ((String) -> String?)? = nil,
70 linkURLResolver: ((String) -> String?)? = nil 76 linkURLResolver: ((String) -> String?)? = nil
71) -> String { 77) -> String {
78 let destination = normalizeOrgLinkTarget(rawDestination)
72 if let label, label.hasPrefix("[["), label.hasSuffix("]]") { 79 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
73 let source = String(label.dropFirst(2).dropLast(2)) 80 let source = String(label.dropFirst(2).dropLast(2))
74 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { 81 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
@@ -80,9 +87,12 @@ private func renderOrgLink(
80 } 87 }
81 } 88 }
82 89
83 if let imageHTML = makeOrgImageHTML( 90 // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a
91 // link whose text happens to point at an image — org renders it as a link, not an
92 // image, so only take the image path when there is no description.
93 if label == nil, let imageHTML = makeOrgImageHTML(
84 source: destination, 94 source: destination,
85 alt: label, 95 alt: nil,
86 imageURLResolver: imageURLResolver 96 imageURLResolver: imageURLResolver
87 ) { 97 ) {
88 return imageHTML 98 return imageHTML
@@ -100,10 +110,11 @@ private func renderOrgLink(
100} 110}
101 111
102func makeOrgImageHTML( 112func makeOrgImageHTML(
103 source: String, 113 source rawSource: String,
104 alt: String?, 114 alt: String?,
105 imageURLResolver: ((String) -> String?)? 115 imageURLResolver: ((String) -> String?)?
106) -> String? { 116) -> String? {
117 let source = normalizeOrgLinkTarget(rawSource)
107 guard isRenderableImageSource(source) else { return nil } 118 guard isRenderableImageSource(source) else { return nil }
108 let resolvedSource = imageURLResolver?(source) ?? source 119 let resolvedSource = imageURLResolver?(source) ?? source
109 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil } 120 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
@@ -117,6 +128,84 @@ private func isRenderableImageSource(_ source: String) -> Bool {
117 .contains(where: { lowercased.hasSuffix($0) }) 128 .contains(where: { lowercased.hasSuffix($0) })
118} 129}
119 130
131/// If a whole line is just an image link with no description (`[[file:x.png]]`), return its
132/// path (with `file:` stripped). A link carrying a description is not a standalone image.
133func standaloneOrgImage(in line: String) -> String? {
134 let trimmed = line.trimmingCharacters(in: .whitespaces)
135 guard trimmed.hasPrefix("[[") && trimmed.hasSuffix("]]") else { return nil }
136 let inner = String(trimmed.dropFirst(2).dropLast(2))
137 guard !inner.contains("][") else { return nil }
138 let path = normalizeOrgLinkTarget(inner)
139 guard isRenderableImageSource(path) else { return nil }
140 return path
141}
142
143/// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup
144/// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining
145/// `#+ATTR_HTML` pairs become attributes.
146func makeFigureImageHTML(
147 path: String,
148 caption: String?,
149 attrHtml: String?,
150 imageURLResolver: ((String) -> String?)?
151) -> String {
152 let resolved = imageURLResolver?(path) ?? path
153 let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path)
154 let attrs = attrHtml.map(parseAttrHtml) ?? []
155
156 let alt: String
157 if let caption {
158 alt = stripOrgEmphasis(caption)
159 } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value {
160 alt = attrAlt
161 } else {
162 alt = ""
163 }
164
165 var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""#
166 for (key, value) in attrs where key != "alt" {
167 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
168 }
169 html += ">"
170 return html
171}
172
173/// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`).
174private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] {
175 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
176 return []
177 }
178 let ns = value as NSString
179 var result: [(String, String)] = []
180 for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) {
181 let key = ns.substring(with: m.range(at: 1)).lowercased()
182 var v = ns.substring(with: m.range(at: 2))
183 if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") {
184 v = String(v.dropFirst().dropLast())
185 }
186 result.append((key, v))
187 }
188 return result
189}
190
191/// Strip paired org emphasis markers for plain-text uses like an image `alt`.
192private func stripOrgEmphasis(_ s: String) -> String {
193 guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else {
194 return s
195 }
196 var result = s
197 for _ in 0..<3 {
198 let ns = result as NSString
199 let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length))
200 if matches.isEmpty { break }
201 for m in matches.reversed() {
202 let inner = ns.substring(with: m.range(at: 2))
203 result = (result as NSString).replacingCharacters(in: m.range, with: inner)
204 }
205 }
206 return result
207}
208
120// MARK: - Relative link/image resolution 209// MARK: - Relative link/image resolution
121 210
122func resolveRepositoryLinkURL( 211func resolveRepositoryLinkURL(
Sources/OrgSwift/Lists.swift +5 −2
@@ -136,11 +136,14 @@ func renderTaskListItem(
136 let prefix = String(trimmed.prefix(4)) 136 let prefix = String(trimmed.prefix(4))
137 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) 137 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
138 138
139 // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>.
139 switch prefix { 140 switch prefix {
140 case "[ ] ": 141 case "[ ] ":
141 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"# 142 return #"<code>[&nbsp;]</code> \#(inlineRenderer(remainder))"#
142 case "[x] ", "[X] ": 143 case "[x] ", "[X] ":
143 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"# 144 return #"<code>[X]</code> \#(inlineRenderer(remainder))"#
145 case "[-] ":
146 return #"<code>[-]</code> \#(inlineRenderer(remainder))"#
144 default: 147 default:
145 return inlineRenderer(text) 148 return inlineRenderer(text)
146 } 149 }
Sources/OrgSwift/OrgRenderer.swift +38 −1
@@ -174,8 +174,10 @@ func orgToHTML(
174 var verseLines: [String] = [] 174 var verseLines: [String] = []
175 var pendingBlockName: String? 175 var pendingBlockName: String?
176 var pendingBlockCaption: String? 176 var pendingBlockCaption: String?
177 var pendingAttrHtml: String?
177 var activeBlockCaption: String? 178 var activeBlockCaption: String?
178 var isWrappingBlockFigure = false 179 var isWrappingBlockFigure = false
180 var figureNumber = 0
179 let footnotes = FootnoteCollector() 181 let footnotes = FootnoteCollector()
180 182
181 func beginPendingBlockWrapperIfNeeded() { 183 func beginPendingBlockWrapperIfNeeded() {
@@ -382,6 +384,9 @@ func orgToHTML(
382 case "name": 384 case "name":
383 pendingBlockName = directive.value 385 pendingBlockName = directive.value
384 continue 386 continue
387 case "attr_html":
388 pendingAttrHtml = directive.value
389 continue
385 case "options", "property": 390 case "options", "property":
386 continue 391 continue
387 default: 392 default:
@@ -389,6 +394,37 @@ func orgToHTML(
389 } 394 }
390 } 395 }
391 396
397 // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML
398 // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a
399 // description ([[file:x][label]]) is an ordinary link and falls through to the
400 // paragraph path instead.
401 if let imagePath = standaloneOrgImage(in: trimmed) {
402 closeQuoteBlock()
403 flushBlockState()
404 let img = makeFigureImageHTML(
405 path: imagePath,
406 caption: pendingBlockCaption,
407 attrHtml: pendingAttrHtml,
408 imageURLResolver: imageURLResolver
409 )
410 if pendingBlockCaption != nil || pendingAttrHtml != nil {
411 html += "<figure>" + img
412 if let caption = pendingBlockCaption {
413 figureNumber += 1
414 html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"#
415 + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
416 + "</figcaption>"
417 }
418 html += "</figure>\n"
419 } else {
420 html += "<p>" + img + "</p>\n"
421 }
422 pendingBlockCaption = nil
423 pendingBlockName = nil
424 pendingAttrHtml = nil
425 continue
426 }
427
392 if trimmed.lowercased().hasPrefix("#+begin_src") { 428 if trimmed.lowercased().hasPrefix("#+begin_src") {
393 closeQuoteBlock() 429 closeQuoteBlock()
394 flushBlockState() 430 flushBlockState()
@@ -550,9 +586,10 @@ func orgToHTML(
550 } 586 }
551 587
552 // Regular text 588 // Regular text
553 if pendingBlockName != nil || pendingBlockCaption != nil { 589 if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil {
554 pendingBlockName = nil 590 pendingBlockName = nil
555 pendingBlockCaption = nil 591 pendingBlockCaption = nil
592 pendingAttrHtml = nil
556 } 593 }
557 paragraph.append(line) 594 paragraph.append(line)
558 } 595 }
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
@@ -51,11 +51,11 @@ private enum Expectation {
51private let expectations: [String: Expectation] = [ 51private let expectations: [String: Expectation] = [
52 "table": .matches, 52 "table": .matches,
53 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), 53 "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"),
54 "core": .diverges("bare URLs are not autolinked"), 54 "core": .matches,
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": .matches, 57 "headings": .matches,
58 "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), 58 "images": .matches,
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": .matches, 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"),
Tests/OrgSwiftTests/OrgRendererTests.swift +73
@@ -286,4 +286,77 @@ struct OrgRendererTests {
286 #expect(html.contains("<h1>Just a heading</h1>")) 286 #expect(html.contains("<h1>Just a heading</h1>"))
287 #expect(!html.contains("class=\"tag\"")) 287 #expect(!html.contains("class=\"tag\""))
288 } 288 }
289
290 @Test
291 func bareUrlBecomesLink() {
292 let html = render("See https://example.com now.")
293 #expect(html.contains(#"<a href="https://example.com">https://example.com</a>"#))
294 // Trailing sentence punctuation stays outside the link.
295 let end = render("End at https://example.com.")
296 #expect(end.contains(#"<a href="https://example.com">https://example.com</a>."#))
297 }
298
299 @Test
300 func taskCheckboxesRenderAsCode() {
301 let html = render("""
302 - [ ] todo
303 - [X] done
304 - [-] partial
305 """)
306 #expect(html.contains("<code>[&nbsp;]</code> todo"))
307 #expect(html.contains("<code>[X]</code> done"))
308 #expect(html.contains("<code>[-]</code> partial"))
309 #expect(!html.contains("<input"))
310 }
311
312 @Test
313 func bareFileImageIsImgInParagraph() {
314 let html = render("[[file:diagram.png]]")
315 #expect(html.contains(#"<p><img src="diagram.png" alt=""></p>"#))
316 }
317
318 @Test
319 func describedFileImageIsALink() {
320 // A description turns an image target into a link, not an inline image.
321 let html = render("[[file:diagram.png][the diagram]]")
322 #expect(html.contains(#"<a href="diagram.png">the diagram</a>"#))
323 #expect(!html.contains("<img"))
324 }
325
326 @Test
327 func captionPromotesImageToFigure() {
328 let html = render("""
329 #+CAPTION: A /stylised/ chart
330 [[file:chart.png]]
331 """)
332 #expect(html.contains("<figure><img src=\"chart.png\""))
333 #expect(html.contains(#"<figcaption><span class="figure-number">Figure 1: </span>A <em>stylised</em> chart</figcaption>"#))
334 // alt is the caption with markup stripped.
335 #expect(html.contains(#"alt="A stylised chart""#))
336 }
337
338 @Test
339 func attrHtmlPromotesImageToFigureWithoutCaption() {
340 let html = render("""
341 #+ATTR_HTML: :alt "a cat, sitting" :loading lazy
342 [[file:cat.jpg]]
343 """)
344 #expect(html.contains("<figure><img src=\"cat.jpg\""))
345 #expect(html.contains(#"alt="a cat, sitting""#))
346 #expect(html.contains(#"loading="lazy""#))
347 #expect(!html.contains("<figcaption"))
348 }
349
350 @Test
351 func figuresNumberSequentially() {
352 let html = render("""
353 #+CAPTION: First
354 [[file:a.png]]
355
356 #+CAPTION: Second
357 [[file:b.png]]
358 """)
359 #expect(html.contains("Figure 1: </span>First"))
360 #expect(html.contains("Figure 2: </span>Second"))
361 }
289} 362}