Skip to main content

plates_render/
body.rs

1//! Body prose → HTML, in whichever grammar the document is written in.
2//!
3//! Three stages run in order:
4//! 1. [`preprocess_custom_syntax`] rewrites Diaryx-specific syntax (highlights,
5//!    spoilers, HTML embeds) into raw HTML, skipping fenced/inline code.
6//! 2. [`render_body`] parses the result as the document's [`ContentFormat`] and
7//!    renders it, via `twig` (through [`prov::render_html`]).
8//! 3. With the `syntax-highlighting` feature, [`crate::syntax`] colours the
9//!    fenced code blocks in that HTML. Third rather than woven into stage two
10//!    because `prov::render_html` is one string-to-string call with no node
11//!    hook — and being a pass over the output is what lets it cover all three
12//!    grammars, hand-written HTML bodies included, with one implementation.
13//!
14//! ## Why twig rather than a Markdown-only parser
15//!
16//! This crate used to run comrak, which meant Diaryx could only ever publish
17//! Markdown, and meant the publisher parsed a document with a different engine
18//! than the editor did — the editor has always been twig, through `leaf`. One
19//! engine for three grammars is the whole reason `content_format` can exist:
20//! `twig` is already linked into every build (via `prov` *and* `leaf`), it
21//! ships a `wasm32-unknown-unknown` package so this crate stays portable to the
22//! Cloudflare worker, and it covers what comrak covered — tables,
23//! strikethrough, tasklists, footnotes, autolinks, raw-HTML passthrough.
24//!
25//! Its HTML is not byte-identical to comrak's: tasklists come out as
26//! `<ul class="task-list">` and footnotes as `role="doc-endnotes"` with `#fn1`
27//! anchors rather than comrak's `#fn-1`. `html_format_css.css` styles both
28//! spellings, so a site published before this change and one published after
29//! render the same.
30
31use prov::ContentFormat;
32
33/// Render a document body to HTML.
34///
35/// `format` is the document's own grammar — taken from its extension, not from
36/// the vault's `content_format`, because a vault may hold both (an imported
37/// `.html` artifact beside a `.md` transcription is the normal case, not the
38/// exotic one).
39///
40/// A body twig cannot parse renders as escaped source in a `<pre>` rather than
41/// failing the page: a publish that drops one document's prose on the floor is
42/// worse than one that shows it unformatted, and the alternative — comrak's
43/// infallible signature — was only infallible because it silently accepted
44/// anything as Markdown.
45pub fn render_body(body: &str, format: ContentFormat) -> String {
46    let html = render_markup(body, format);
47    // The built-in grammars. A caller with its own reaches for
48    // [`render_body_with`]; this spelling stays the one that needs no setup.
49    #[cfg(feature = "syntax-highlighting")]
50    let html = crate::syntax::highlight_code_blocks(&html, crate::syntax::Syntaxes::bundled());
51    html
52}
53
54/// Render a document body to HTML, highlighting its code with `syntaxes`.
55///
56/// What [`render_body`] does, against a grammar set the caller assembled —
57/// which is how a site publishes code in a language the built-in set has no
58/// grammar for. Building that set is the expensive half, so build it once and
59/// pass it to every page rather than once per page. See [`crate::syntax`].
60#[cfg(feature = "syntax-highlighting")]
61pub fn render_body_with(
62    body: &str,
63    format: ContentFormat,
64    syntaxes: &crate::syntax::Syntaxes,
65) -> String {
66    crate::syntax::highlight_code_blocks(&render_markup(body, format), syntaxes)
67}
68
69/// Stages one and two: the document's own grammar, through twig, with no
70/// colour applied yet.
71fn render_markup(body: &str, format: ContentFormat) -> String {
72    let mut preprocessed = preprocess_custom_syntax(body, format);
73    // twig drops the *content* of a Djot raw inline span (`` `…`{=html} ``) when
74    // the source does not end in a newline — `a `x`{=html} b` renders as
75    // `<p>a  b</p>`. A document whose last line is unterminated is ordinary, so
76    // this is not only a test artifact: without the newline, a highlight on the
77    // final line of a Djot entry would silently vanish from the published page.
78    // Terminating the source is semantically neutral in all three grammars.
79    if !preprocessed.ends_with('\n') {
80        preprocessed.push('\n');
81    }
82    prov::render_html(&preprocessed, format).unwrap_or_else(|_| {
83        format!(
84            "<pre class=\"diaryx-unrendered\">{}</pre>\n",
85            html_escape(body)
86        )
87    })
88}
89
90/// Pre-process Diaryx's custom syntax (highlights, spoilers, HTML embeds) into
91/// raw HTML before the body is parsed. Every stretch the document's own parser
92/// calls code — or raw HTML — is copied through untouched, so a fence showing
93/// `==highlight==` stays a fence showing `==highlight==`.
94///
95/// Runs for Markdown and Djot. It is deliberately the *same* syntax in both:
96/// someone who switches a vault's `content_format`
97/// should not find that `==highlight==` stopped working. Djot's native
98/// `{=highlight=}` still works too — twig parses it — it just renders a plain
99/// `<mark>` without Diaryx's colour classes.
100///
101/// HTML bodies are returned untouched. `==` and `||` are literal text there,
102/// and a body that is already HTML has no need of an escape hatch into it.
103pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
104    if format == ContentFormat::Html {
105        return source.to_string();
106    }
107    let markdown = source;
108    let bytes = markdown.as_bytes();
109    let len = bytes.len();
110    let mut out = String::with_capacity(len);
111    let mut i = 0;
112    // Which stretches of the source are opaque, from twig rather than from a
113    // fence-counter of this crate's own. The hand-rolled version knew only
114    // ``` fences and single backticks, so `~~~`, an indented block, a run of two
115    // backticks and a fence inside a list item all leaked their contents to the
116    // scanners below. Spans are computed once, against the *original* text: the
117    // rewrite only ever appends to `out`, so `i` never stops indexing the source
118    // these offsets were taken from.
119    //
120    // A body twig cannot parse degrades to "nothing is code", matching
121    // [`render_body`], which shows such a body as source anyway.
122    let code = prov::code_spans(source, format).unwrap_or_default();
123    let mut next_code = 0;
124
125    while i < len {
126        while next_code < code.len() && code[next_code].end <= i {
127            next_code += 1;
128        }
129        if let Some(span) = code.get(next_code)
130            && span.start <= i
131        {
132            out.push_str(&markdown[i..span.end]);
133            i = span.end;
134            continue;
135        }
136
137        // An escaped opener is text, not syntax. Both characters are emitted
138        // verbatim so the body's own parser does the unescaping — `\!` renders
139        // as `!` in Markdown and Djot alike — which is the difference between
140        // `\![x](y.html)` reading as a literal embed and becoming an island.
141        // `\\` is consumed as a pair so an escaped backslash does not shield the
142        // opener after it.
143        if bytes[i] == b'\\'
144            && let Some(next) = bytes.get(i + 1)
145            && matches!(next, b'\\' | b'!' | b'=' | b'|')
146        {
147            out.push_str(&markdown[i..i + 2]);
148            i += 2;
149            continue;
150        }
151
152        // Try HTML embed: ![alt](path.html) or ![alt](path.htm)
153        if bytes[i] == b'!'
154            && i + 1 < len
155            && bytes[i + 1] == b'['
156            && let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
157        {
158            out.push_str(&raw_inline(&html, format));
159            i += consumed;
160            continue;
161        }
162
163        // Try highlight: ==text== or =={color}text==
164        if i + 1 < len
165            && bytes[i] == b'='
166            && bytes[i + 1] == b'='
167            && let Some((html, consumed)) = try_parse_highlight(&markdown[i..])
168        {
169            out.push_str(&raw_inline(&html, format));
170            i += consumed;
171            continue;
172        }
173
174        // Try spoiler: ||text||
175        if i + 1 < len
176            && bytes[i] == b'|'
177            && bytes[i + 1] == b'|'
178            && let Some((html, consumed)) = try_parse_spoiler(&markdown[i..])
179        {
180            out.push_str(&raw_inline(&html, format));
181            i += consumed;
182            continue;
183        }
184
185        out.push(markdown[i..].chars().next().unwrap());
186        i += markdown[i..].chars().next().unwrap().len_utf8();
187    }
188
189    out
190}
191
192/// Wrap a generated HTML fragment so the body's own parser passes it through
193/// verbatim instead of escaping it.
194///
195/// Markdown needs nothing — twig emits inline raw HTML as-is. Djot does not:
196/// a bare `<mark>` comes out as `&lt;mark&gt;`, and the only way in is an inline
197/// raw span, `` `…`{=html} ``. The fence is one backtick longer than the longest
198/// run inside the fragment, because the highlight/spoiler scanners can swallow a
199/// backtick that the inline-code branch didn't reach first (`==a ` b==`), and a
200/// fence the content also contains would close the span early.
201fn raw_inline(html: &str, format: ContentFormat) -> String {
202    if format != ContentFormat::Djot {
203        return html.to_string();
204    }
205    let longest = html
206        .split(|c| c != '`')
207        .map(|run| run.len())
208        .max()
209        .unwrap_or(0);
210    let fence = "`".repeat(longest + 1);
211    // Djot reads a leading/trailing backtick as part of the fence unless a
212    // space separates them; the space is not part of the raw content.
213    let pad = if html.starts_with('`') || html.ends_with('`') {
214        " "
215    } else {
216        ""
217    };
218    format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
219}
220
221/// Try to parse a highlight starting at `==`. Returns `(html, bytes_consumed)`.
222fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
223    const VALID_COLORS: &[&str] = &[
224        "red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
225    ];
226
227    if !s.starts_with("==") {
228        return None;
229    }
230
231    let after_open = &s[2..];
232    if after_open.is_empty() || after_open.starts_with("==") {
233        return None;
234    }
235
236    let (color, content_start) = if after_open.starts_with('{') {
237        let close_brace = after_open.find('}')?;
238        let color_name = &after_open[1..close_brace];
239        if !VALID_COLORS.contains(&color_name) {
240            return None;
241        }
242        (color_name, close_brace + 1)
243    } else {
244        ("yellow", 0)
245    };
246
247    let content_region = &after_open[content_start..];
248    let close_pos = content_region.find("==")?;
249    if close_pos == 0 {
250        return None;
251    }
252
253    let content = &content_region[..close_pos];
254    if content.contains('\n') {
255        return None;
256    }
257
258    let total_consumed = 2 + content_start + close_pos + 2;
259    let html = format!(
260        r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
261        color = color,
262        content = html_escape(content),
263    );
264
265    Some((html, total_consumed))
266}
267
268/// Try to parse a spoiler starting at `||`. Returns `(html, bytes_consumed)`.
269fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
270    if !s.starts_with("||") {
271        return None;
272    }
273
274    let after_open = &s[2..];
275    if after_open.is_empty() || after_open.starts_with("||") {
276        return None;
277    }
278
279    let close_pos = after_open.find("||")?;
280    if close_pos == 0 {
281        return None;
282    }
283
284    let content = &after_open[..close_pos];
285    if content.contains('|') || content.contains('\n') {
286        return None;
287    }
288
289    let total_consumed = 2 + close_pos + 2;
290    let html = format!(
291        r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
292        content = html_escape(content),
293    );
294
295    Some((html, total_consumed))
296}
297
298/// The height range an island is allowed to occupy, in CSS pixels.
299///
300/// The same clamp the parent-side resize bridge applies to a measurement from
301/// the frame (see `HtmlRenderer::interactivity_script`), applied here to the
302/// authored `{height=…}` so the two cannot disagree about what an island may be:
303/// a one-pixel embed is invisible, and one taller than any screen is a scroll
304/// trap in a page that already scrolls.
305const ISLAND_MIN_HEIGHT: u32 = 200;
306const ISLAND_MAX_HEIGHT: u32 = 4000;
307
308/// Try to parse an HTML embed starting at `![`. Returns `(html, bytes_consumed)`.
309///
310/// Matches `![alt](path.html)` or `![alt](path.htm)`, optionally followed by an
311/// attribute block, and converts it to a sandboxed `<iframe>` tag. This runs
312/// before the body's own parser so the raw HTML is passed through unchanged.
313///
314/// The only attribute is `{height=400}`, which sets the frame's initial
315/// `min-height` — what the reader sees before the resize bridge has measured the
316/// document, and what they keep seeing if it loads no child script. An attribute
317/// block spelling anything else leaves the whole embed unmatched, so it falls
318/// through to ordinary image parsing: an unknown attribute is more likely a
319/// syntax this version has not learned than a mistake worth eating the embed
320/// over, and a visible `![demo](x.html){wdith=400}` is a legible way to say so.
321fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
322    if !s.starts_with("![") {
323        return None;
324    }
325
326    let after_bang = &s[2..];
327    let close_bracket = after_bang.find(']')?;
328    let alt = &after_bang[..close_bracket];
329
330    let after_bracket = &after_bang[close_bracket + 1..];
331    if !after_bracket.starts_with('(') {
332        return None;
333    }
334
335    let after_paren = &after_bracket[1..];
336    let close_paren = after_paren.find(')')?;
337    let path = after_paren[..close_paren].trim();
338
339    // Only match .html / .htm extensions
340    let lower = path.to_lowercase();
341    if !lower.ends_with(".html") && !lower.ends_with(".htm") {
342        return None;
343    }
344
345    let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
346    let mut min_height = ISLAND_MIN_HEIGHT;
347    let after_embed = &s[total_consumed..];
348    if after_embed.starts_with('{') {
349        let close_brace = after_embed.find('}')?;
350        min_height = parse_island_height(&after_embed[1..close_brace])?;
351        total_consumed += close_brace + 1;
352    }
353
354    let html = format!(
355        r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
356        html_escape(path),
357        html_escape(alt),
358        min_height,
359    );
360
361    Some((html, total_consumed))
362}
363
364/// Read an island's attribute block. `None` for anything but `height=<integer>`,
365/// which unmatches the embed rather than silently dropping the attribute.
366fn parse_island_height(attributes: &str) -> Option<u32> {
367    let value = attributes.trim().strip_prefix("height")?.trim_start();
368    let value = value.strip_prefix('=')?.trim();
369    let height: u32 = value.parse().ok()?;
370    Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
371}
372
373/// Escape HTML special characters.
374fn html_escape(s: &str) -> String {
375    s.replace('&', "&amp;")
376        .replace('<', "&lt;")
377        .replace('>', "&gt;")
378        .replace('"', "&quot;")
379        .replace('\'', "&#39;")
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    /// The Markdown case, which is what every existing test asserted before a
387    /// body had a grammar to be in.
388    fn preprocess(source: &str) -> String {
389        preprocess_custom_syntax(source, ContentFormat::Markdown)
390    }
391
392    #[test]
393    fn highlight_default_color() {
394        let out = preprocess("a ==hi== b");
395        assert_eq!(
396            out,
397            r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
398        );
399    }
400
401    #[test]
402    fn highlight_named_color() {
403        let out = preprocess("=={red}danger==");
404        assert!(out.contains(r#"data-highlight-color="red""#));
405        assert!(out.contains("highlight-red"));
406        assert!(out.contains(">danger<"));
407    }
408
409    #[test]
410    fn highlight_invalid_color_is_left_alone() {
411        let out = preprocess("=={mauve}x==");
412        assert_eq!(out, "=={mauve}x==");
413    }
414
415    #[test]
416    fn spoiler_basic() {
417        let out = preprocess("||secret||");
418        assert_eq!(
419            out,
420            r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
421        );
422    }
423
424    #[test]
425    fn html_embed_becomes_iframe() {
426        let out = preprocess("![demo](island.html)");
427        assert!(out.contains(r#"<iframe src="island.html""#));
428        assert!(out.contains(r#"title="demo""#));
429        assert!(out.contains(r#"class="diaryx-island""#));
430    }
431
432    /// The initial height an island opens at, before — or without — a
433    /// measurement from the document inside it.
434    #[test]
435    fn html_embed_takes_an_authored_height() {
436        let out = preprocess("![demo](island.html){height=520}");
437        assert!(out.contains("min-height:520px"), "got {out}");
438        assert!(
439            !out.contains("{height=520}"),
440            "the block is consumed: {out}"
441        );
442    }
443
444    /// The same range the resize bridge clamps a measurement to, so an island
445    /// cannot open at a size it would never be allowed to reach.
446    #[test]
447    fn an_authored_height_is_clamped_to_the_bridges_range() {
448        assert!(preprocess("![d](i.html){height=10}").contains("min-height:200px"));
449        assert!(preprocess("![d](i.html){height=99999}").contains("min-height:4000px"));
450    }
451
452    /// An attribute this version does not know leaves the embed unmatched, so
453    /// the reader sees the syntax rather than an island silently missing the
454    /// thing it was asked for.
455    #[test]
456    fn an_unknown_island_attribute_leaves_the_embed_alone() {
457        let source = "![demo](island.html){wdith=400}";
458        assert_eq!(preprocess(source), source);
459        assert_eq!(
460            preprocess("![demo](island.html){height=tall}"),
461            "![demo](island.html){height=tall}"
462        );
463    }
464
465    /// `\!` is an escape in every grammar this preprocessor runs for, so an
466    /// escaped embed is text about an embed — a line of documentation, most
467    /// likely — and turning it into an island was the scanner reading past the
468    /// backslash it should have stopped at.
469    #[test]
470    fn an_escaped_embed_is_not_an_island() {
471        let out = preprocess(r"Write \![alt](page.html) to embed one.");
472        assert_eq!(out, r"Write \![alt](page.html) to embed one.");
473        assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
474
475        // The same for the other openers, and an escaped backslash still shields
476        // nothing but itself.
477        assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
478        assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
479        assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
480    }
481
482    #[test]
483    fn inline_code_is_untouched() {
484        let out = preprocess("`==not a highlight==`");
485        assert_eq!(out, "`==not a highlight==`");
486    }
487
488    #[test]
489    fn fenced_code_is_untouched() {
490        let input = "```\n==no==\n||no||\n```";
491        let out = preprocess(input);
492        assert_eq!(out, input);
493    }
494
495    /// The spellings of code a hand-rolled backtick counter never knew: a tilde
496    /// fence, an indented block, a two-backtick inline span, and a fence nested
497    /// in a list item. Each used to have its contents rewritten.
498    #[test]
499    fn every_spelling_of_code_is_untouched() {
500        for input in [
501            "~~~\n==no==\n~~~",
502            "para\n\n    ==no==\n    ![x](i.html)\n\npost",
503            "a ``==no==`` b",
504            "- item\n\n  ```\n  ==no==\n  ```\n",
505        ] {
506            assert_eq!(preprocess(input), input, "input: {input:?}");
507        }
508    }
509
510    /// Djot fences the same way, and the code mask comes from the document's own
511    /// grammar — so this holds for a Djot body without a second scanner.
512    #[test]
513    fn djot_fenced_code_is_untouched() {
514        let input = "```\n==no==\n||no||\n```\n";
515        assert_eq!(
516            preprocess_custom_syntax(input, ContentFormat::Djot),
517            input,
518            "a djot fence is code too"
519        );
520        let out = preprocess_custom_syntax("```\n==no==\n```\n\n==yes==\n", ContentFormat::Djot);
521        assert!(out.contains("```\n==no==\n```"), "fence intact: {out}");
522        assert!(
523            out.contains("highlight-mark"),
524            "prose still rewritten: {out}"
525        );
526    }
527
528    #[test]
529    fn escapes_content() {
530        let out = preprocess("==<b>&\"==");
531        assert!(out.contains("&lt;b&gt;&amp;&quot;"));
532    }
533
534    #[test]
535    fn markdown_renders_basics() {
536        let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
537        assert!(html.contains("<h1>"));
538        assert!(html.contains("<del>struck</del>"));
539    }
540
541    /// The whole comrak feature set this crate used to enable by hand
542    /// (`strikethrough`, `table`, `autolink`, `tasklist`, `footnotes`,
543    /// `unsafe`), asserted against twig so a regression in the engine that
544    /// replaced it cannot land quietly.
545    #[test]
546    fn markdown_still_covers_what_comrak_was_configured_for() {
547        let src = "~~struck~~\n\n\
548                   | a | b |\n|---|---|\n| 1 | 2 |\n\n\
549                   - [ ] todo\n- [x] done\n\n\
550                   A note.[^1]\n\n[^1]: The note.\n\n\
551                   <div class=\"raw\">passed through</div>\n\n\
552                   https://example.test\n\n```rust\nlet x = 1;\n```\n";
553        let html = render_body(src, ContentFormat::Markdown);
554        assert!(html.contains("<del>struck</del>"), "strikethrough");
555        assert!(
556            html.contains("<table>") && html.contains("<th>a</th>"),
557            "tables"
558        );
559        assert!(html.contains("type=\"checkbox\""), "tasklists");
560        assert!(html.contains("checked"), "a checked tasklist item");
561        assert!(html.contains("The note."), "footnote text");
562        assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
563        assert!(
564            html.contains("<a href=\"https://example.test\""),
565            "autolinks"
566        );
567        assert!(html.contains("language-rust"), "fenced code language");
568    }
569
570    /// Stage three, end to end and in all three grammars: the pre-processor
571    /// keeps its hands off fenced code, twig tags it with the language, and the
572    /// highlighter colours it — none of the three knowing about the others.
573    #[cfg(feature = "syntax-highlighting")]
574    #[test]
575    fn fenced_code_is_highlighted_in_every_grammar() {
576        for (format, src) in [
577            (ContentFormat::Markdown, "```rust\nlet x = 1;\n```\n"),
578            (ContentFormat::Djot, "```rust\nlet x = 1;\n```\n"),
579            (
580                ContentFormat::Html,
581                "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
582            ),
583        ] {
584            let html = render_body(src, format);
585            assert!(
586                html.contains(crate::syntax::HIGHLIGHTED_CLASS),
587                "{format:?} left it uncoloured: {html}"
588            );
589            assert!(html.contains("plates-storage"), "{format:?}: {html}");
590        }
591    }
592
593    /// The escaping twig applied has to survive the round trip, or a code block
594    /// starts publishing tags instead of showing them.
595    #[cfg(feature = "syntax-highlighting")]
596    #[test]
597    fn highlighting_does_not_unescape_the_page() {
598        let html = render_body(
599            "```rust\nlet s = \"<b>&amp;</b>\";\n```\n",
600            ContentFormat::Markdown,
601        );
602        assert!(!html.contains("<b>"), "a tag reached the page: {html}");
603        assert!(html.contains("&lt;b&gt;"), "still escaped: {html}");
604    }
605
606    /// A grammar the site supplied itself, reaching the page through the one
607    /// call that takes one.
608    #[cfg(feature = "syntax-highlighting")]
609    #[test]
610    fn a_site_grammar_reaches_a_rendered_body() {
611        let syntaxes = crate::syntax::Syntaxes::with_custom([(
612            "wat.sublime-syntax",
613            "name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n  main:\n    - match: ';;.*$'\n      scope: comment.line.wat\n",
614        )]);
615        let html = render_body_with(
616            "```wat\n;; a note\n```\n",
617            ContentFormat::Markdown,
618            &syntaxes,
619        );
620        assert!(html.contains("plates-comment"), "{html}");
621    }
622
623    #[test]
624    fn markdown_passes_preprocessed_raw_html_through() {
625        let html = render_body("==hi==", ContentFormat::Markdown);
626        assert!(html.contains("<mark"), "got {html}");
627    }
628
629    /// Djot escapes a bare tag, so the same custom syntax has to arrive as an
630    /// inline raw span. This is the assertion that the Djot path is not just
631    /// the Markdown path with a different parser.
632    #[test]
633    fn djot_custom_syntax_survives_as_raw_html() {
634        let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
635        assert!(
636            html.contains("<mark"),
637            "highlight reached the output: {html}"
638        );
639        assert!(html.contains("data-spoiler"), "spoiler too: {html}");
640        assert!(!html.contains("&lt;mark"), "and was not escaped: {html}");
641    }
642
643    #[test]
644    fn djot_renders_its_own_grammar() {
645        let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
646        assert!(html.contains("<em>emph</em>"));
647        assert!(html.contains("<mark>native</mark>"));
648    }
649
650    /// A fragment carrying a backtick would close a one-backtick raw span early.
651    #[test]
652    fn djot_raw_span_outruns_backticks_in_the_content() {
653        let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
654        assert!(out.starts_with("``"), "fence outgrew the content: {out}");
655        assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
656        let html = render_body("==a ` b==", ContentFormat::Djot);
657        assert!(html.contains("<mark"), "still a highlight: {html}");
658    }
659
660    #[test]
661    fn html_bodies_are_left_alone() {
662        // `==` and `||` are literal text in an HTML body, not Diaryx syntax.
663        let src = "<p>a == b || c</p>";
664        assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
665        let html = render_body(src, ContentFormat::Html);
666        assert!(html.contains("a == b || c"), "got {html}");
667        assert!(!html.contains("<mark"));
668    }
669}