krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.20.2: 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\">&lt;not markup&gt; &amp; 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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
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;"), "alpha:\n{html}");
701    // `{}` is the explicit terminator and must not survive into the text.
702    assert!(html.contains("&beta;s"), "beta with {{}}:\n{html}");
703    assert!(html.contains("&rarr;"), "arrow:\n{html}");
704    assert!(html.contains("20&deg;"), "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("&alpha;"), "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}