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

html library org-mode swift

Tests/OrgSwiftTests/OrgRendererTests.swift

main
org-swift/Tests/OrgSwiftTests/OrgRendererTests.swift history · blame · raw

502 lines · 17148 bytes

  1import Foundation
  2import Testing
  3@testable import OrgSwift
  4
  5private func render(_ source: String, options: OrgRenderOptions = .init()) -> String {
  6    OrgRenderer.renderToHTML(source, options: options)
  7}
  8
  9struct OrgRendererTests {
 10
 11    @Test
 12    func orgDeepHeading() {
 13        let html = render("**** Level 4 Heading")
 14
 15        #expect(html.contains("<h4>"))
 16    }
 17
 18    @Test
 19    func orgCommentLinesIgnored() {
 20        let html = render("# This is a comment\nNormal text")
 21
 22        #expect(!html.contains("This is a comment"))
 23        #expect(html.contains("Normal text"))
 24    }
 25
 26    @Test
 27    func orgStrikethrough() {
 28        let html = render("+deleted text+")
 29
 30        #expect(html.contains("<del>"))
 31    }
 32
 33    @Test
 34    func orgExampleBlock() {
 35        let html = render("#+begin_example\nhello world\n#+end_example")
 36
 37        // Example blocks are a bare <pre> (org's exporter uses no inner <code>).
 38        #expect(html.contains("<pre>hello world"))
 39        #expect(!html.contains("<pre><code>"))
 40    }
 41
 42    @Test
 43    func orgTitleKeyword() {
 44        let html = render("#+TITLE: My Document\nBody text")
 45
 46        #expect(html.contains("org-title"))
 47        #expect(html.contains("My Document"))
 48    }
 49
 50    @Test
 51    func orgItalicDoesNotMatchURLPaths() {
 52        let html = render("[[https://example.com/path/to/file]]")
 53
 54        #expect(!html.contains("<em>"))
 55    }
 56
 57    @Test
 58    func orgWrappedBulletNormalizesLines() {
 59        let html = render("- First line\n  continues here")
 60
 61        #expect(html.contains("<li>First line continues here</li>"))
 62    }
 63
 64    @Test
 65    func orgHeaderKeywordsIgnored() {
 66        let html = render("""
 67        #+OPTIONS: toc:nil
 68        #+PROPERTY: header-args :results output
 69        Body text
 70        """)
 71
 72        #expect(!html.contains("#+OPTIONS"))
 73        #expect(!html.contains("#+PROPERTY"))
 74        #expect(html.contains("<p>Body text</p>"))
 75    }
 76
 77    @Test
 78    func orgVerseBlock() {
 79        let html = render("""
 80        #+begin_verse
 81        There is a line.
 82          And an indented line.
 83        #+end_verse
 84        """)
 85
 86        // Verse renders as <p class="verse"> with <br> between lines, matching org.
 87        #expect(html.contains(#"<p class="verse">"#))
 88        #expect(html.contains("There is a line.<br>"))
 89        #expect(html.contains("And an indented line."))
 90        #expect(!html.contains("#+begin_verse"))
 91    }
 92
 93    @Test
 94    func orgNamedBlockRendersCaption() {
 95        let html = render("""
 96        #+CAPTION: Build output
 97        #+NAME: build-log
 98        #+begin_example
 99        hello world
100        #+end_example
101        """)
102
103        #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
104        #expect(html.contains("<figcaption>Build output</figcaption>"))
105        #expect(!html.contains("#+CAPTION"))
106        #expect(!html.contains("#+NAME"))
107    }
108
109    @Test
110    func orgLinkedImageRenders() {
111        let html = render("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
112
113        #expect(html.contains(#"<a href="https://example.com">"#))
114        #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
115    }
116
117    @Test
118    func orgLinkedRelativeImageRenders() {
119        // In hutch this passed an explicit imageURLResolver closure; here the same
120        // resolution is expressed through OrgRenderOptions (owner/repositoryName/host).
121        let html = render(
122            "[[https://example.com/docs][[./images/badge.svg]]]",
123            options: OrgRenderOptions(owner: "~ccleberg", repositoryName: "Hutch")
124        )
125
126        #expect(html.contains(#"<a href="https://example.com/docs">"#))
127        #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
128    }
129
130    @Test
131    func orgNestedBulletListRendersNestedMarkup() {
132        let html = render("""
133        * Lists
134        ** Unordered
135        - First bullet
136        - Second bullet
137        - Third bullet with /italic/ and *bold*
138        - Bullet with wrapped
139          continuation line
140        - Bullet with nested list
141          - Nested child one
142          - Nested child two
143        - Bullet with inline code ~let x = 1~
144        """)
145
146        #expect(html.contains("<ul>"))
147        #expect(html.contains("<li>Bullet with nested list\n<ul>"))
148        #expect(html.contains("<li>Nested child one</li>"))
149        #expect(html.contains("<li>Nested child two</li>"))
150        #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
151    }
152
153    @Test
154    func orgTableAlignmentRendersStyles() {
155        let html = render("""
156        | Left | Center | Right |
157        |:-----+:-----:+------:|
158        | a    | b      | c     |
159        | 1    | 2      | 3     |
160        """)
161
162        #expect(html.contains("text-align: left;"))
163        #expect(html.contains("text-align: center;"))
164        #expect(html.contains("text-align: right;"))
165    }
166
167    @Test
168    func metadataHeaderShownByDefault() {
169        let html = render("#+TITLE: My Doc\n\nBody.")
170        #expect(html.contains("<h1 class=\"org-title\">My Doc</h1>"))
171    }
172
173    @Test
174    func metadataHeaderSuppressed() {
175        let html = render("#+TITLE: My Doc\n\nBody.", options: OrgRenderOptions(metadataHeader: false))
176        #expect(!html.contains("org-title"))
177        #expect(!html.contains("My Doc"))
178        #expect(html.contains("<p>Body.</p>"))
179    }
180
181    @Test
182    func headingLevelOffsetShiftsAndClamps() {
183        let base = render("* Top\n\n** Sub")
184        #expect(base.contains("<h1>Top</h1>"))
185        #expect(base.contains("<h2>Sub</h2>"))
186
187        let shifted = render("* Top\n\n** Sub", options: OrgRenderOptions(headingLevelOffset: 1))
188        #expect(shifted.contains("<h2>Top</h2>"))
189        #expect(shifted.contains("<h3>Sub</h3>"))
190
191        // A six-star heading with a +1 offset clamps to h6, never h7.
192        let deep = render("****** Deep", options: OrgRenderOptions(headingLevelOffset: 1))
193        #expect(deep.contains("<h6>Deep</h6>"))
194        #expect(!deep.contains("<h7"))
195    }
196
197    @Test
198    func timestampActiveAndInactive() {
199        let html = render("An active <2024-01-15 Mon> and inactive [2024-01-15 Mon].")
200        #expect(html.contains(#"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#))
201        #expect(html.contains(#"<time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>"#))
202    }
203
204    @Test
205    func timestampWithTimeAndRanges() {
206        #expect(render("At <2024-01-15 Mon 10:30>.").contains(
207            #"<time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>"#))
208
209        let sameDay = render("Range <2024-01-15 Mon 10:00-11:45>.")
210        #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
211        #expect(sameDay.contains("&#8211;"))
212        #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
213
214        let multiDay = render("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")
215        #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
216        #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
217        #expect(multiDay.contains("&#8211;"))
218    }
219
220    @Test
221    func timestampRepeaterDroppedAndNonTimestampsLiteral() {
222        #expect(render("Repeats <2024-01-15 Mon +1w>.").contains(
223            #"<time class="timestamp" datetime="2024-01-15">2024-01-15</time>"#))
224        let literal = render("Compare 3 < 4 and [not a stamp].")
225        #expect(!literal.contains("<time"))
226        #expect(literal.contains("[not a stamp]"))
227    }
228
229    @Test
230    func footnoteReferencesAndSection() {
231        let html = render("""
232        A claim.[fn:1] Another.[fn:2]
233
234        [fn:1] First note.
235        [fn:2] Second with /emphasis/.
236        """)
237        // References number in textual order and link to the section.
238        #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup>"##))
239        #expect(html.contains(##"<sup class="footnote-ref"><a id="fnr-2" href="#fn-2">2</a></sup>"##))
240        // The section renders once, in order, with the definitions and back-links.
241        #expect(html.contains(#"<section class="footnotes""#))
242        #expect(html.contains(#"<li id="fn-1"><p>First note.</p>"#))
243        #expect(html.contains("Second with <em>emphasis</em>"))
244        #expect(html.contains(##"href="#fnr-2""##))
245        // fn-1 must appear before fn-2 in the section.
246        let firstIndex = html.range(of: #"id="fn-1""#)!.lowerBound
247        let secondIndex = html.range(of: #"id="fn-2""#)!.lowerBound
248        #expect(firstIndex < secondIndex)
249    }
250
251    @Test
252    func inlineFootnoteNotWrappedInParagraph() {
253        let html = render("See here.[fn:x:defined inline]")
254        #expect(html.contains(##"href="#fn-1">1</a>"##))
255        // Inline footnote content sits directly in the <li>, not wrapped in <p>.
256        #expect(html.contains(#"<li id="fn-1">defined inline "#))
257        #expect(!html.contains("<li id=\"fn-1\"><p>defined inline"))
258    }
259
260    @Test
261    func propertyDrawerIsDropped() {
262        let html = render("""
263        * Heading
264        :PROPERTIES:
265        :CUSTOM_ID: first
266        :OWNER: nobody
267        :END:
268
269        Body text.
270        """)
271        #expect(!html.contains("org-properties"))
272        #expect(!html.contains("<dl"))
273        #expect(!html.contains("CUSTOM_ID"))
274        #expect(!html.contains("nobody"))
275        #expect(html.contains("<h1>Heading</h1>"))
276        #expect(html.contains("<p>Body text.</p>"))
277    }
278
279    @Test
280    func headingTagsRenderAsSpans() {
281        let html = render("* Write the parser :work:rust:")
282        #expect(html.contains(#"<h1>Write the parser <span class="tag">work</span> <span class="tag">rust</span></h1>"#))
283    }
284
285    @Test
286    func headingWithoutTagsIsUnchanged() {
287        let html = render("* Just a heading")
288        #expect(html.contains("<h1>Just a heading</h1>"))
289        #expect(!html.contains("class=\"tag\""))
290    }
291
292    @Test
293    func bareUrlBecomesLink() {
294        let html = render("See https://example.com now.")
295        #expect(html.contains(#"<a href="https://example.com">https://example.com</a>"#))
296        // Trailing sentence punctuation stays outside the link.
297        let end = render("End at https://example.com.")
298        #expect(end.contains(#"<a href="https://example.com">https://example.com</a>."#))
299    }
300
301    @Test
302    func taskCheckboxesRenderAsCode() {
303        let html = render("""
304        - [ ] todo
305        - [X] done
306        - [-] partial
307        """)
308        #expect(html.contains("<code>[&nbsp;]</code> todo"))
309        #expect(html.contains("<code>[X]</code> done"))
310        #expect(html.contains("<code>[-]</code> partial"))
311        #expect(!html.contains("<input"))
312    }
313
314    @Test
315    func bareFileImageIsImgInParagraph() {
316        let html = render("[[file:diagram.png]]")
317        #expect(html.contains(#"<p><img src="diagram.png" alt=""></p>"#))
318    }
319
320    @Test
321    func describedFileImageIsALink() {
322        // A description turns an image target into a link, not an inline image.
323        let html = render("[[file:diagram.png][the diagram]]")
324        #expect(html.contains(#"<a href="diagram.png">the diagram</a>"#))
325        #expect(!html.contains("<img"))
326    }
327
328    @Test
329    func imageDescriptionBecomesClickableImage() {
330        // A build-badge: the description is itself an image, so it is the link's content.
331        let badge = render("[[https://example.com/builds][file:https://example.com/badge.svg]]")
332        #expect(badge.contains(#"<a href="https://example.com/builds"><img src="https://example.com/badge.svg" alt=""></a>"#))
333
334        // The double-bracketed form works the same.
335        let bracketed = render("[[https://example.com/builds][[https://example.com/badge.svg]]]")
336        #expect(bracketed.contains(#"<a href="https://example.com/builds"><img"#))
337
338        // A plain-text description that merely looks path-like stays text.
339        let plain = render("[[https://example.com][see diagram.png]]")
340        #expect(plain.contains(#"<a href="https://example.com">see diagram.png</a>"#))
341        #expect(!plain.contains("<img"))
342    }
343
344    @Test
345    func captionPromotesImageToFigure() {
346        let html = render("""
347        #+CAPTION: A /stylised/ chart
348        [[file:chart.png]]
349        """)
350        #expect(html.contains("<figure><img src=\"chart.png\""))
351        #expect(html.contains(#"<figcaption><span class="figure-number">Figure 1: </span>A <em>stylised</em> chart</figcaption>"#))
352        // alt is the caption with markup stripped.
353        #expect(html.contains(#"alt="A stylised chart""#))
354    }
355
356    @Test
357    func attrHtmlPromotesImageToFigureWithoutCaption() {
358        let html = render("""
359        #+ATTR_HTML: :alt "a cat, sitting" :loading lazy
360        [[file:cat.jpg]]
361        """)
362        #expect(html.contains("<figure><img src=\"cat.jpg\""))
363        #expect(html.contains(#"alt="a cat, sitting""#))
364        #expect(html.contains(#"loading="lazy""#))
365        #expect(!html.contains("<figcaption"))
366    }
367
368    @Test
369    func figuresNumberSequentially() {
370        let html = render("""
371        #+CAPTION: First
372        [[file:a.png]]
373
374        #+CAPTION: Second
375        [[file:b.png]]
376        """)
377        #expect(html.contains("Figure 1: </span>First"))
378        #expect(html.contains("Figure 2: </span>Second"))
379    }
380
381    @Test
382    func threeDeepNestedList() {
383        let html = render("""
384        - outer
385          - inner
386            - deepest
387          - second inner
388        - second outer
389        """)
390        #expect(html.contains("<li>outer\n<ul>\n<li>inner\n<ul>\n<li>deepest</li>"))
391        #expect(html.contains("<li>second inner</li>"))
392        #expect(html.contains("<li>second outer</li>"))
393    }
394
395    @Test
396    func nestedOrderedList() {
397        let html = render("""
398        1. first
399        2. second
400           1. point one
401           2. point two
402        3. third
403        """)
404        #expect(html.contains("<li>second\n<ol>\n<li>point one</li>"))
405        #expect(html.contains("<li>third</li>"))
406    }
407
408    @Test
409    func descriptionList() {
410        let html = render("""
411        - term one :: first definition
412        - /marked/ term :: holds inline markup
413        """)
414        #expect(html.contains("<dl>"))
415        #expect(html.contains("<dt>term one</dt>"))
416        #expect(html.contains("<dd>first definition</dd>"))
417        #expect(html.contains("<dt><em>marked</em> term</dt>"))
418    }
419
420    @Test
421    func multiParagraphListItem() {
422        let html = render("""
423        - an item with two paragraphs
424
425          the second paragraph
426
427        - a plain sibling
428        """)
429        #expect(html.contains("<li><p>an item with two paragraphs</p>\n<p>the second paragraph</p></li>"))
430        #expect(html.contains("<li>a plain sibling</li>"))
431    }
432
433    @Test
434    func exportBlockHtmlPassesThroughLatexDropped() {
435        let html = render("""
436        #+BEGIN_EXPORT html
437        <aside>raw</aside>
438        #+END_EXPORT
439
440        #+BEGIN_EXPORT latex
441        \\emph{dropped}
442        #+END_EXPORT
443        """)
444        #expect(html.contains("<aside>raw</aside>"))
445        #expect(!html.contains("dropped"))
446        #expect(!html.contains("BEGIN_EXPORT"))
447    }
448
449    @Test
450    func specialBlockRendersAsNamedDiv() {
451        let html = render("""
452        #+BEGIN_NOTE
453        Contents are *org*, not literal.
454        #+END_NOTE
455        """)
456        #expect(html.contains(#"<div class="note">"#))
457        #expect(html.contains("<p>Contents are <strong>org</strong>, not literal.</p>"))
458    }
459
460    @Test
461    func superscriptRenders() {
462        #expect(render("N^2 here.").contains("N<sup>2</sup>"))
463        #expect(render("H^{2O} molecule.").contains("H<sup>2O</sup>"))
464    }
465
466    @Test
467    func idLinkBecomesFragment() {
468        let html = render("See [[id:abc-123]] and [[id:x][the x]].")
469        #expect(html.contains(##"<a href="#abc-123">abc-123</a>"##))
470        #expect(html.contains(##"<a href="#x">the x</a>"##))
471    }
472
473    @Test
474    func unknownKeywordLinesAreDropped() {
475        let html = render("#+FILETAGS: :sample:\n\nBody.")
476        #expect(!html.contains("FILETAGS"))
477        #expect(!html.contains("sample"))
478        #expect(html.contains("<p>Body.</p>"))
479    }
480
481    @Test
482    func relativeURLsResolveWithConfigurablePathSegments() {
483        // gitbay serves raw bytes at /raw/<ref>/ and the file page at /blob/<ref>/, so an
484        // image resolves against a different segment than a link.
485        let options = OrgRenderOptions(
486            host: "gitbay.org",
487            owner: "krz",
488            repositoryName: "gitbay",
489            ref: "HEAD",
490            readmePath: "README.org",
491            imagePathSegment: "raw",
492            linkPathSegment: "blob"
493        )
494        let html = render("""
495        [[file:docs/logo.png]]
496
497        See [[docs/DESIGN.org][the design]].
498        """, options: options)
499        #expect(html.contains(#"<img src="https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png""#))
500        #expect(html.contains(#"<a href="https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org">the design</a>"#))
501    }
502}