krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.21.0: tests/constructs.rs · raw
1//! Golden-file coverage of the v1 scope line (README §"v1 scope").
2//!
3//! Two halves, and the second is the point:
4//!
5//! - **IN** — every construct the v1 scope claims gets an element-tree snapshot (parser
6//! correctness) and a rendered-HTML snapshot (renderer correctness).
7//! - **OUT** — every construct the v1 scope explicitly excludes gets an assertion that it
8//! *degrades predictably*: parsed and ignored, content preserved where that is the
9//! honest fallback, never a crash and never a half-rendered artifact.
10//!
11//! The OUT half is the scope guardrail: it is what defends against this project's stated
12//! #1 risk, creeping back toward all-of-org.
13
14use camino::Utf8PathBuf;
15
16use orgo::model::Document;
17use orgo::parser::parse;
18use orgo::render::{render, Html, SyntectHighlighter};
19use orgo::resolve::ResolvedDoc;
20
21fn parse_fixture(name: &str) -> Document {
22 let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
23 .join("fixtures")
24 .join(name);
25 let source = std::fs::read_to_string(&path).expect("read fixture");
26 // A stable relative path keeps snapshots free of absolute machine paths.
27 parse(Utf8PathBuf::from("fixtures").join(name).as_path(), &source).expect("parse fixture")
28}
29
30fn render_fixture(name: &str) -> String {
31 let document = parse_fixture(name);
32 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
33 html
34}
35
36// ---------------------------------------------------------------------------
37// IN: the constructs v1 promises to handle
38// ---------------------------------------------------------------------------
39
40#[test]
41fn headings_element_tree() {
42 insta::assert_json_snapshot!(parse_fixture("headings.org").root);
43}
44
45#[test]
46fn headings_html() {
47 insta::assert_snapshot!(render_fixture("headings.org"));
48}
49
50#[test]
51fn lists_element_tree() {
52 insta::assert_json_snapshot!(parse_fixture("lists.org").root);
53}
54
55#[test]
56fn lists_html() {
57 insta::assert_snapshot!(render_fixture("lists.org"));
58}
59
60#[test]
61fn blocks_html() {
62 insta::assert_snapshot!(render_fixture("blocks.org"));
63}
64
65#[test]
66fn timestamps_element_tree() {
67 insta::assert_json_snapshot!(parse_fixture("timestamps.org").root);
68}
69
70#[test]
71fn timestamps_html() {
72 insta::assert_snapshot!(render_fixture("timestamps.org"));
73}
74
75#[test]
76fn images_html() {
77 insta::assert_snapshot!(render_fixture("images.org"));
78}
79
80/// A TODO keyword is a whole word from the configured set, not a prefix: `TODOs are not
81/// a keyword` is a plain title. This is the boundary rule most likely to regress.
82#[test]
83fn todo_keyword_requires_a_word_boundary() {
84 let html = render_fixture("headings.org");
85 assert!(
86 html.contains("<span class=\"todo TODO\">TODO</span> "),
87 "a real TODO keyword is marked up:\n{html}"
88 );
89 assert!(
90 !html.contains("<span class=\"todo TODO\">TODO</span> s are"),
91 "`TODOs` must not be split into a keyword plus a title:\n{html}"
92 );
93}
94
95/// Nesting is by indentation, so a nested list must land *inside* its parent `<li>`.
96#[test]
97fn nested_list_is_nested_in_the_parent_item() {
98 let html = render_fixture("lists.org");
99 assert!(
100 html.contains("<li>outer item<ul>"),
101 "an indented sub-list belongs to the item above it:\n{html}"
102 );
103}
104
105/// The caption supplies alt text, but an explicit `:alt` must win — emitting both
106/// would put two `alt` attributes on one tag.
107#[test]
108fn explicit_alt_attribute_replaces_the_caption_derived_one() {
109 let html = render_fixture("images.org");
110 assert!(
111 html.contains("<img src=\"cat.jpg\" alt=\"a cat, sitting\" loading=\"lazy\">"),
112 "a quoted `:alt` should be the only alt attribute:\n{html}"
113 );
114 for line in html.lines() {
115 assert!(
116 line.matches(" alt=").count() <= 1,
117 "no tag may carry two alt attributes:\n{line}"
118 );
119 }
120}
121
122/// Keywords in the file preamble are *copied* into the metadata map, not removed from
123/// the body. Removing them used to merge the paragraphs either side of a keyword and
124/// strand `#+CAPTION:` away from the image below it — content damage from a metadata
125/// step, in the one region of a file where every real document has keywords.
126#[test]
127fn preamble_keywords_do_not_disturb_the_content_around_them() {
128 let source = "#+TITLE: T\n\nOne.\n#+SOMEKEY: v\nTwo.\n\n#+CAPTION: shot\n[[file:a.png]]\n";
129 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
130 assert!(
131 document
132 .keywords
133 .entries
134 .iter()
135 .any(|(k, v)| k == "TITLE" && v == "T"),
136 "document metadata is still collected"
137 );
138 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
139 assert!(
140 html.contains("<p>One.</p>") && html.contains("<p>Two.</p>"),
141 "a keyword between two paragraphs must not merge them:\n{html}"
142 );
143 assert!(
144 html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"),
145 "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}"
146 );
147}
148
149/// Highlighting must emit CSS classes, never inline styles, so themes live in the
150/// stylesheet (spec §3.2) — and the stylesheet the classes refer to must exist.
151#[test]
152fn highlighting_emits_classes_not_inline_styles() {
153 let html = render_fixture("blocks.org");
154 assert!(
155 html.contains("<span class=\"storage type function python\">"),
156 "python source should be tokenized into classed spans:\n{html}"
157 );
158 assert!(
159 !html.contains("style=\""),
160 "highlighting must not emit inline styles:\n{html}"
161 );
162 assert!(
163 orgo::render::syntax_css("InspiredGitHub")
164 .expect("a built-in theme")
165 .contains(".storage"),
166 "the generated stylesheet must define the emitted classes"
167 );
168}
169
170/// An unknown language is not an error: the block keeps its content, escaped.
171#[test]
172fn unknown_source_language_falls_back_to_plain_code() {
173 let doc = "#+BEGIN_SRC nosuchlang\n<not markup> & such\n#+END_SRC\n";
174 let document = parse(Utf8PathBuf::from("t.org").as_path(), doc).expect("parse");
175 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
176 assert_eq!(
177 html,
178 "<pre><code class=\"language-nosuchlang\"><not markup> & such</code></pre>\n"
179 );
180}
181
182// ---------------------------------------------------------------------------
183// OUT: the constructs v1 explicitly excludes must degrade, not explode
184// ---------------------------------------------------------------------------
185
186/// The whole OUT fixture parses and renders. This is the crash gate.
187#[test]
188fn out_of_scope_fixture_renders_without_crashing() {
189 let html = render_fixture("outofscope.org");
190 assert!(!html.is_empty(), "an out-of-scope document still renders");
191}
192
193#[test]
194fn out_of_scope_html() {
195 insta::assert_snapshot!(render_fixture("outofscope.org"));
196}
197
198/// Babel is never executed and `#+RESULTS:` blocks are never trusted: the source block
199/// renders as code, and its stale results do not reach the page.
200#[test]
201fn babel_is_not_executed_and_results_are_dropped() {
202 let html = render_fixture("outofscope.org");
203 assert!(
204 html.contains("the block renders; :results is never executed"),
205 "the source block itself still renders:\n{html}"
206 );
207 assert!(
208 !html.contains("stale output from a previous evaluation"),
209 "a `#+RESULTS:` block must not be emitted:\n{html}"
210 );
211}
212
213/// `#+TBLFM:` is inert: the table renders with the values as written, and the formula
214/// is neither evaluated nor printed.
215#[test]
216fn table_formulas_are_inert() {
217 let html = render_fixture("outofscope.org");
218 assert!(html.contains("<table>"), "the table still renders:\n{html}");
219 assert!(
220 !html.contains("vsum"),
221 "the `#+TBLFM:` formula must not reach the page:\n{html}"
222 );
223}
224
225/// LaTeX, macros and radio targets have no v1 semantics, so they survive as the literal
226/// text the author typed — lossless, and obviously unhandled to a reader.
227#[test]
228fn latex_macros_and_radio_targets_stay_literal() {
229 let html = render_fixture("outofscope.org");
230 for literal in ["$x^2 + y^2$", "E = mc^2", "{{{author}}}", "\\notanentity"] {
231 assert!(
232 html.contains(literal),
233 "`{literal}` should survive as literal text:\n{html}"
234 );
235 }
236}
237
238/// Drawers other than PROPERTIES are captured by the parser and dropped by the
239/// renderer — including LOGBOOK clock lines, which are agenda state, not content.
240#[test]
241fn drawers_are_parsed_and_dropped() {
242 let html = render_fixture("outofscope.org");
243 assert!(
244 !html.contains("CLOCK:"),
245 "LOGBOOK contents must not be emitted:\n{html}"
246 );
247 assert!(
248 !html.contains("Drawer contents are captured and dropped"),
249 "generic drawer contents must not be emitted:\n{html}"
250 );
251}
252
253/// A non-HTML export block is dropped whole: emitting LaTeX into an HTML page would be
254/// worse than emitting nothing.
255#[test]
256fn non_html_export_blocks_are_dropped() {
257 let html = render_fixture("blocks.org");
258 assert!(
259 html.contains("<aside class=\"raw\">Raw HTML passes through.</aside>"),
260 "an `html` export block passes through verbatim:\n{html}"
261 );
262 assert!(
263 !html.contains("\\emph"),
264 "a `latex` export block must be dropped:\n{html}"
265 );
266}
267
268/// A verse block keeps its line breaks, and a block with an unrecognised name becomes a
269/// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER
270/// can carry content.
271#[test]
272fn verse_and_special_blocks_keep_their_content() {
273 let html = render_fixture("blocks.org");
274 assert!(
275 html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"),
276 "verse keeps its breaks:\n{html}"
277 );
278 assert!(
279 html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"),
280 "a special block holds parsed org:\n{html}"
281 );
282}
283
284/// `#+INCLUDE:` is not expanded — the build must not silently pull in another file.
285#[test]
286fn include_is_not_expanded() {
287 let doc = parse_fixture("outofscope.org");
288 assert!(
289 doc.keywords
290 .entries
291 .iter()
292 .any(|(k, _)| k.eq_ignore_ascii_case("INCLUDE")),
293 "`#+INCLUDE:` is captured as an inert keyword"
294 );
295 let html = render_fixture("outofscope.org");
296 assert!(
297 !html.contains("other.org"),
298 "`#+INCLUDE:` must not be expanded or echoed:\n{html}"
299 );
300}
301
302// ---------------------------------------------------------------------------
303// Parse diagnostics: degrading is fine, degrading *silently* is not
304// ---------------------------------------------------------------------------
305
306fn diagnostics(source: &str) -> Vec<String> {
307 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
308 document
309 .diagnostics
310 .iter()
311 .map(|d| format!("{}: {}", d.line, d.message))
312 .collect()
313}
314
315/// An unterminated block swallows the rest of the file. The parser's contract is to
316/// degrade rather than crash, so it still returns a document — but a silent one would
317/// mean a build that reports success while deleting most of a page.
318#[test]
319fn unterminated_block_is_reported_with_its_line() {
320 let source = "#+TITLE: T\n\nIntro.\n\n* Section\n\n#+BEGIN_SRC rust\nfn main() {}\n\n* Vanishes\n";
321 let found = diagnostics(source);
322 assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
323 assert!(
324 found[0].starts_with("7: unterminated `#+BEGIN_SRC` block"),
325 "must name the line the block opened on: {found:?}"
326 );
327}
328
329/// The same failure mode, and quieter: drawers render to nothing, so an unterminated one
330/// deletes the rest of the file without even leaving a code block behind.
331#[test]
332fn unterminated_drawer_is_reported_with_its_line() {
333 let found = diagnostics("#+TITLE: T\n\n* Head\n:LOGBOOK:\nCLOCK: x\n\n* Lost\n");
334 assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
335 assert!(
336 found[0].starts_with("4: unterminated `:LOGBOOK:` drawer"),
337 "must name the drawer and its line: {found:?}"
338 );
339}
340
341/// A stray terminator usually means the matching `#+BEGIN_` above it is misspelled.
342#[test]
343fn stray_block_end_is_reported_with_its_line() {
344 let found = diagnostics("#+TITLE: T\n\nText.\n\n#+END_SRC\n\nMore.\n");
345 assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
346 assert!(
347 found[0].starts_with("5: stray `#+END_SRC`"),
348 "must name the stray terminator and its line: {found:?}"
349 );
350}
351
352/// Line numbers must survive nesting. A block inside a list item inside a section is
353/// several levels of re-parsed, re-indented, reconstructed lines away from the file, and
354/// a diagnostic that points at the wrong line is worse than none.
355#[test]
356fn diagnostic_lines_survive_nesting() {
357 let source = concat!(
358 "#+TITLE: T\n", // 1
359 "\n", // 2
360 "* Section\n", // 3
361 "\n", // 4
362 "- an item\n", // 5
363 "\n", // 6
364 " #+BEGIN_SRC sh\n", // 7
365 " echo hi\n", // 8
366 );
367 let found = diagnostics(source);
368 assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
369 assert!(
370 found[0].starts_with("7: unterminated"),
371 "the line must be the real file line, not an offset into a nested slice: {found:?}"
372 );
373}
374
375/// Every fixture that is meant to be well-formed must parse without complaint —
376/// otherwise the diagnostics are crying wolf on ordinary documents.
377#[test]
378fn well_formed_fixtures_produce_no_diagnostics() {
379 for name in [
380 "minimal.org",
381 "core.org",
382 "elements.org",
383 "table.org",
384 "footnote.org",
385 "headings.org",
386 "lists.org",
387 "blocks.org",
388 "timestamps.org",
389 "images.org",
390 ] {
391 let document = parse_fixture(name);
392 assert!(
393 document.diagnostics.is_empty(),
394 "{name} should parse cleanly, got {:?}",
395 document.diagnostics
396 );
397 }
398
399 // The out-of-scope fixture is the exception, and only for the one construct that is
400 // *meant* to announce itself: an unexpanded `#+INCLUDE:` means content is missing
401 // from the page, which is worth a line in the build output.
402 let out = parse_fixture("outofscope.org");
403 let messages: Vec<&str> = out.diagnostics.iter().map(|d| d.message.as_str()).collect();
404 assert_eq!(messages.len(), 1, "one diagnostic, not a pile: {messages:?}");
405 assert!(
406 messages[0].contains("#+INCLUDE:") && messages[0].contains("not expanded"),
407 "and it is the include: {messages:?}"
408 );
409}
410
411// ---------------------------------------------------------------------------
412// Bundled syntax definitions, and org's comma escape
413// ---------------------------------------------------------------------------
414
415/// syntect bundles neither TOML nor Org. Both are gaps this project hits on its own
416/// first documentation page: every config example is TOML, and a tool for org users gets
417/// written about in org.
418#[test]
419fn toml_and_org_blocks_are_highlighted() {
420 for (lang, code, expect_scope) in [
421 (
422 "toml",
423 "# comment\n[site]\ntitle = \"x\"\nport = 3000\nok = true\n",
424 "entity name section toml",
425 ),
426 (
427 // The heading is comma-escaped, which org *requires* inside a block: an
428 // unescaped `*` at column 0 ends the block in Emacs too, verified against it.
429 "org",
430 ",#+TITLE: A page\n\n,* TODO [#A] Heading :tag:\n\nSome *bold* text.\n",
431 "markup heading org",
432 ),
433 ] {
434 let source = format!("#+BEGIN_SRC {lang}\n{code}#+END_SRC\n");
435 let document = parse(Utf8PathBuf::from("t.org").as_path(), &source).expect("parse");
436 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
437
438 assert!(
439 html.contains(&format!("class=\"language-{lang} highlight\"")),
440 "{lang} should be highlighted, not fall back to plain code:\n{html}"
441 );
442 assert!(
443 html.contains(expect_scope),
444 "{lang} should produce the scope {expect_scope:?}:\n{html}"
445 );
446 }
447}
448
449/// TOML's lexical corners: a table array is not a table, a date is not an integer, and a
450/// comment is not a table header.
451#[test]
452fn the_toml_syntax_distinguishes_its_shapes() {
453 let code = "#+BEGIN_SRC toml\n# note\n[[collections]]\nwhen = 2026-08-11\nn = 12\ns = \"q\"\nb = false\n#+END_SRC\n";
454 let document = parse(Utf8PathBuf::from("t.org").as_path(), code).expect("parse");
455 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
456
457 for scope in [
458 "comment line number-sign toml",
459 "entity name section toml",
460 "constant numeric date toml",
461 "string quoted double toml",
462 "constant language toml",
463 ] {
464 assert!(html.contains(scope), "expected scope {scope:?}:\n{html}");
465 }
466}
467
468/// Org escapes a line inside a block that would look like structure by prefixing a
469/// comma, and the exporter removes it. Without this, documentation *about* org shows the
470/// escape characters its author had to type — to exactly the audience most likely to
471/// notice. Verified against Emacs, which strips them.
472#[test]
473fn the_comma_escape_is_removed_from_block_content() {
474 let source = concat!(
475 "#+BEGIN_SRC org\n",
476 ",#+TITLE: A page\n",
477 ",* A heading\n",
478 ",,* not a heading, one comma removed\n",
479 "plain line\n",
480 "#+END_SRC\n",
481 );
482 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
483 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
484 // Highlighting splits the line across spans, so compare the text, not the markup.
485 let text = strip_tags(&html);
486
487 assert!(text.contains("#+TITLE: A page"), "the comma is gone:\n{html}");
488 assert!(!text.contains(",#+TITLE:"), "and not merely moved:\n{html}");
489 assert!(
490 text.contains(",* not a heading"),
491 "a doubled comma loses exactly one:\n{html}"
492 );
493 assert!(text.contains("plain line"), "other lines are untouched:\n{html}");
494}
495
496/// Text content of an HTML fragment, with tags removed and entities decoded.
497fn strip_tags(html: &str) -> String {
498 let mut text = String::new();
499 let mut rest = html;
500 while let Some(open) = rest.find('<') {
501 text.push_str(&rest[..open]);
502 match rest[open..].find('>') {
503 Some(close) => rest = &rest[open + close + 1..],
504 None => break,
505 }
506 }
507 text.push_str(rest);
508 text.replace("&", "&").replace("<", "<").replace(">", ">")
509}
510
511/// A comma that is not an escape is content, and must survive.
512#[test]
513fn an_ordinary_leading_comma_is_not_stripped() {
514 let source = "#+BEGIN_SRC text\n, a list continuation\n,not an escape\n#+END_SRC\n";
515 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
516 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
517 let text = strip_tags(&html);
518 assert!(text.contains(", a list continuation"), "{html}");
519 assert!(text.contains(",not an escape"), "{html}");
520}
521
522// ---------------------------------------------------------------------------
523// Export-time text conversions
524// ---------------------------------------------------------------------------
525
526fn html_of(source: &str) -> String {
527 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
528 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
529 html
530}
531
532/// Org converts dash runs and ellipses in prose. A reader of the published page should
533/// see the typography the author meant, not the ASCII they had to type.
534#[test]
535fn special_strings_become_real_punctuation() {
536 let html = html_of("An em---dash, an en--dash, and an ellipsis...\n");
537 assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}");
538 assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}");
539 assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}");
540}
541
542/// A shell transcript is not prose. `--verbose` inside code has to survive intact, or
543/// copying a command off the page produces one that does not run.
544#[test]
545fn special_strings_leave_code_alone() {
546 let html = html_of(
547 "Prose --dash and ~ls --all~ and =grep --color=.\n\n\
548 #+BEGIN_SRC sh\nls --all\n#+END_SRC\n",
549 );
550 assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}");
551 assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}");
552 assert!(html.contains("--color"), "verbatim:\n{html}");
553 // The source block's `--all` is split across highlighting spans, so count the
554 // conversion itself: exactly one en dash on the page, the one in the prose.
555 assert_eq!(
556 html.matches('\u{2013}').count(),
557 1,
558 "nothing inside code converted:\n{html}"
559 );
560}
561
562/// `#+OPTIONS: -:nil` is how a document opts out, and orgo honours org's own switch
563/// rather than inventing one.
564#[test]
565fn a_document_can_turn_special_strings_off() {
566 let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n");
567 assert!(html.contains("em---dash"), "left alone:\n{html}");
568 assert!(html.contains("ellipsis..."), "left alone:\n{html}");
569}
570
571/// Sub- and superscripts, including the braceless form — which is what makes
572/// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file.
573#[test]
574fn sub_and_superscripts_convert() {
575 let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n");
576 assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}");
577 assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}");
578 assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}");
579}
580
581/// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes
582/// *before* the marker: emphasis follows whitespace, a script follows a word.
583#[test]
584fn underline_still_wins_where_org_says_it_does() {
585 let html = html_of("Some _underlined_ text.\n");
586 assert!(html.contains("<u>underlined</u>"), "underline:\n{html}");
587 assert!(!html.contains("<sub>"), "not a subscript:\n{html}");
588}
589
590/// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form.
591#[test]
592fn a_document_can_restrict_sub_and_superscripts() {
593 let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n");
594 assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}");
595
596 let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n");
597 assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}");
598 assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}");
599}
600
601/// LaTeX is passed through for a typesetter, so the text conversions must not reach
602/// inside it: `x^2` in `$…$` is mathematics, not markup.
603#[test]
604fn latex_fragments_are_left_intact() {
605 let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n");
606 for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] {
607 assert!(html.contains(literal), "`{literal}` survives:\n{html}");
608 }
609}
610
611/// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end
612/// with a space, which is what keeps prices out of the math.
613#[test]
614fn dollar_amounts_are_not_latex() {
615 let html = html_of("It cost $5 or $6 --- a bargain.\n");
616 assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}");
617}
618
619/// Org exports outline levels relative to the file's own shallowest heading, so a
620/// document written entirely under `**` is a document of top-level sections.
621#[test]
622fn heading_levels_are_relative_to_the_shallowest_heading() {
623 let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n");
624 assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}");
625 assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}");
626}
627
628/// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org.
629/// Rendering its contents as literal text loses the markup the author wrote.
630#[test]
631fn a_special_block_holds_org_not_text() {
632 let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n");
633 assert!(html.contains("<div class=\"note\">"), "named div:\n{html}");
634 assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}");
635}
636
637/// A path that starts with `~` inside `~…~` verbatim: the body may open with the same
638/// character as the marker, and org says so.
639#[test]
640fn verbatim_can_start_with_its_own_marker() {
641 let html = html_of("Edit ~~/.config/doom/config.el~ now.\n");
642 assert!(
643 html.contains("<code>~/.config/doom/config.el</code>"),
644 "the leading ~ belongs to the path:\n{html}"
645 );
646}
647
648/// Org's special first column holds export markers, not data: `/` marks a column group,
649/// `#` a row to recalculate. Publishing them puts a column of punctuation on the page.
650#[test]
651fn a_tables_special_column_and_marker_rows_are_dropped() {
652 let html = html_of(
653 "| N | N^2 |\n\
654 | / | < |\n\
655 | 1 | 1 |\n",
656 );
657 assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}");
658 assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}");
659
660 // Every row marked, so the column itself goes too.
661 let all_marked = html_of(
662 "| # | exp(x) | 1 |\n\
663 | # | exp(x) | 2 |\n",
664 );
665 assert!(
666 !all_marked.contains(">#<"),
667 "a wholly-special column is dropped:\n{all_marked}"
668 );
669 assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}");
670}
671
672/// An affiliated keyword belongs to the element *immediately* below it. Someone who
673/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next
674/// image instead would put the wrong words under the wrong picture.
675#[test]
676fn a_blank_line_ends_a_captions_association() {
677 let html = html_of(
678 "[[file:one.png]]\n\
679 #+CAPTION: stranded\n\
680 \n\
681 [[file:two.png]]\n",
682 );
683 assert!(
684 !html.contains("stranded"),
685 "an orphaned caption attaches to nothing:\n{html}"
686 );
687
688 let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n");
689 assert!(
690 attached.contains("<figcaption>"),
691 "a caption directly above its image still works:\n{attached}"
692 );
693}
694
695/// Org's entity table, taken from Emacs' own `org-entities` so the mapping is not a
696/// hand-typed approximation of 400 entries.
697#[test]
698fn entities_become_their_characters() {
699 let html = html_of("Greek \\alpha and \\beta{}s, an arrow \\rarr, and 20\\deg today.\n");
700 assert!(html.contains("α"), "alpha:\n{html}");
701 // `{}` is the explicit terminator and must not survive into the text.
702 assert!(html.contains("βs"), "beta with {{}}:\n{html}");
703 assert!(html.contains("→"), "arrow:\n{html}");
704 assert!(html.contains("20°"), "degree:\n{html}");
705}
706
707/// A name org does not know is a typo, and a typo should look like one rather than
708/// disappear. `\alphabet` is not a Greek letter followed by "bet", either.
709#[test]
710fn unknown_entities_and_longer_words_stay_literal() {
711 let html = html_of("Neither \\notanentity nor \\alphabet is an entity.\n");
712 assert!(html.contains("\\notanentity"), "unknown stays:\n{html}");
713 assert!(html.contains("\\alphabet"), "no prefix match:\n{html}");
714}
715
716/// `#+OPTIONS: e:nil` is org's own switch for turning entities off.
717#[test]
718fn a_document_can_turn_entities_off() {
719 let html = html_of("#+OPTIONS: e:nil\n\nGreek \\alpha stays.\n");
720 assert!(html.contains("\\alpha"), "left alone:\n{html}");
721 assert!(!html.contains("α"), "not converted:\n{html}");
722}
723
724/// A caption above a table becomes a numbered `<caption>`, as it does for figures.
725#[test]
726fn tables_take_a_numbered_caption() {
727 let html = html_of(
728 "#+CAPTION: Quarterly figures\n| Q | Rev |\n\n\
729 #+CAPTION: Second table\n| A | B |\n",
730 );
731 assert!(
732 html.contains("<caption><span class=\"table-number\">Table 1: </span>Quarterly figures"),
733 "first table:\n{html}"
734 );
735 assert!(html.contains("Table 2: </span>Second table"), "second:\n{html}");
736}
737
738/// `#+INCLUDE:` is not expanded, and says so. Silently dropping it publishes a page with
739/// content missing and nobody told.
740#[test]
741fn an_unexpanded_include_reports_itself() {
742 let doc = parse(
743 Utf8PathBuf::from("t.org").as_path(),
744 "#+TITLE: T\n\n#+INCLUDE: \"other.org\" :lines \"5-10\"\n\nBody.\n",
745 )
746 .expect("parse");
747 assert_eq!(doc.diagnostics.len(), 1, "{:?}", doc.diagnostics);
748 assert_eq!(doc.diagnostics[0].line, 3, "the line it is on");
749 assert!(
750 doc.diagnostics[0].message.contains("other.org"),
751 "names the file: {:?}",
752 doc.diagnostics[0]
753 );
754}
755
756/// A back-link whose whole visible content is `↩` has that glyph as its whole accessible
757/// name, so a screen reader announces "left arrow with hook" once per note and the reader
758/// cannot tell which reference each one returns to.
759#[test]
760fn footnote_links_and_section_are_labelled() {
761 let html = html_of("Text[fn:1] and more[fn:2].\n\n[fn:1] First.\n\n[fn:2] Second.\n");
762 assert!(
763 html.contains("<section class=\"footnotes\" aria-label=\"Footnotes\">"),
764 "the landmark is named:\n{html}"
765 );
766 assert!(
767 html.contains("aria-label=\"Back to reference 1\"")
768 && html.contains("aria-label=\"Back to reference 2\""),
769 "each back-link says where it goes:\n{html}"
770 );
771}