Tests/OrgSwiftTests/OrgRendererTests.swift
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("–"))
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("–"))
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>[ ]</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}