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    let rendered = match format {
83        ContentFormat::Markdown => render_markdown(&preprocessed),
84        _ => prov::render_html(&preprocessed, format),
85    };
86    rendered.unwrap_or_else(|_| {
87        format!(
88            "<pre class=\"diaryx-unrendered\">{}</pre>\n",
89            html_escape(body)
90        )
91    })
92}
93
94/// Markdown, with its directive extension on.
95///
96/// A generic directive renders as an element wearing its attributes —
97/// `:::article{.cover}` is `<article class="cover">`, remark-directive's
98/// documented default — which is how a body puts what it knows where a
99/// stylesheet can see it. The alphabet was already spent: `:::vis` and the
100/// template vocabulary are located as directive nodes, so a body has been a
101/// directive-bearing document since either existed. What was missing was the
102/// render honouring the ones it does not itself consume.
103///
104/// **A bare `:word` is prose.** The extension's grammar admits a text directive
105/// with no label and no attributes anywhere a name follows a colon, and prose
106/// writes that constantly — `a:b`, `:tada:`, `key:value` — none of it meaning
107/// an element. So every such node is escaped back to its colon before the
108/// render (`\:` is CommonMark's escaped colon), and only a directive the
109/// author *spelled* — a `[label]`, a `{…}` attribute block, or a `::` / `:::`
110/// block form — becomes markup. The escape is placed off the node's span,
111/// never by matching text, so a `:b` inside a code span is untouched because it
112/// was never a node.
113fn render_markdown(source: &str) -> prov::Result<String> {
114    use prov::twig::{ContainerOrigin, DirectiveForm, Document, Format, Kind, MarkdownExtensions};
115
116    let extensions = MarkdownExtensions {
117        directives: true,
118        ..MarkdownExtensions::default()
119    };
120    let parse = |text: &str| {
121        Document::parse_str_with(text, Format::Markdown, extensions)
122            .map_err(|e| prov::Error::Content(format!("twig parse: {e}")))
123    };
124    let mut doc = parse(source)?;
125
126    let bare: Vec<usize> = doc
127        .nodes()
128        .map_err(|e| prov::Error::Content(format!("twig nodes: {e}")))?
129        .iter()
130        .filter(|n| {
131            matches!(n.kind, Kind::Container)
132                && matches!(n.origin, Some(ContainerOrigin::Directive))
133                && matches!(n.directive_form, Some(DirectiveForm::Text))
134                && n.attrs.is_empty()
135                && n.content_span.as_ref().is_none_or(|c| c.is_empty())
136                && source.as_bytes().get(n.span.start) == Some(&b':')
137        })
138        .map(|n| n.span.start)
139        .collect();
140
141    if !bare.is_empty() {
142        // Back to front, so each insertion leaves the offsets before it true.
143        let mut escaped = source.to_string();
144        for at in bare.into_iter().rev() {
145            escaped.insert(at, '\\');
146        }
147        doc = parse(&escaped)?;
148    }
149
150    let html = doc
151        .render_html()
152        .map_err(|e| prov::Error::Content(format!("twig render: {e}")))?;
153    String::from_utf8(html)
154        .map_err(|e| prov::Error::Content(format!("twig produced non-UTF-8 HTML: {e}")))
155}
156
157/// Pre-process Diaryx's custom syntax (highlights, spoilers, HTML embeds) into
158/// raw HTML before the body is parsed. Every stretch the document's own parser
159/// calls code — or raw HTML — is copied through untouched, so a fence showing
160/// `==highlight==` stays a fence showing `==highlight==`.
161///
162/// Runs for Markdown and Djot. It is deliberately the *same* syntax in both:
163/// someone who switches a vault's `content_format`
164/// should not find that `==highlight==` stopped working. Djot's native
165/// `{=highlight=}` still works too — twig parses it — it just renders a plain
166/// `<mark>` without Diaryx's colour classes.
167///
168/// HTML bodies are returned untouched. `==` and `||` are literal text there,
169/// and a body that is already HTML has no need of an escape hatch into it.
170pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
171    if format == ContentFormat::Html {
172        return source.to_string();
173    }
174    let markdown = source;
175    let bytes = markdown.as_bytes();
176    let len = bytes.len();
177    let mut out = String::with_capacity(len);
178    let mut i = 0;
179    // Which stretches of the source are opaque, from twig rather than from a
180    // fence-counter of this crate's own. The hand-rolled version knew only
181    // ``` fences and single backticks, so `~~~`, an indented block, a run of two
182    // backticks and a fence inside a list item all leaked their contents to the
183    // scanners below. Spans are computed once, against the *original* text: the
184    // rewrite only ever appends to `out`, so `i` never stops indexing the source
185    // these offsets were taken from.
186    //
187    // A body twig cannot parse degrades to "nothing is code", matching
188    // [`render_body`], which shows such a body as source anyway.
189    let code = prov::code_spans(source, format).unwrap_or_default();
190    let mut next_code = 0;
191
192    while i < len {
193        while next_code < code.len() && code[next_code].end <= i {
194            next_code += 1;
195        }
196        if let Some(span) = code.get(next_code)
197            && span.start <= i
198        {
199            out.push_str(&markdown[i..span.end]);
200            i = span.end;
201            continue;
202        }
203
204        // An escaped opener is text, not syntax. Both characters are emitted
205        // verbatim so the body's own parser does the unescaping — `\!` renders
206        // as `!` in Markdown and Djot alike — which is the difference between
207        // `\![x](y.html)` reading as a literal embed and becoming an island.
208        // `\\` is consumed as a pair so an escaped backslash does not shield the
209        // opener after it.
210        if bytes[i] == b'\\'
211            && let Some(next) = bytes.get(i + 1)
212            && matches!(next, b'\\' | b'!' | b'=' | b'|')
213        {
214            out.push_str(&markdown[i..i + 2]);
215            i += 2;
216            continue;
217        }
218
219        // Try HTML embed: ![alt](path.html) or ![alt](path.htm)
220        if bytes[i] == b'!'
221            && i + 1 < len
222            && bytes[i + 1] == b'['
223            && let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
224        {
225            out.push_str(&raw_inline(&html, format));
226            i += consumed;
227            continue;
228        }
229
230        // Try highlight: ==text== or =={color}text==
231        if i + 1 < len
232            && bytes[i] == b'='
233            && bytes[i + 1] == b'='
234            && let Some((html, consumed)) = try_parse_highlight(&markdown[i..])
235        {
236            out.push_str(&raw_inline(&html, format));
237            i += consumed;
238            continue;
239        }
240
241        // Try spoiler: ||text||
242        if i + 1 < len
243            && bytes[i] == b'|'
244            && bytes[i + 1] == b'|'
245            && let Some((html, consumed)) = try_parse_spoiler(&markdown[i..])
246        {
247            out.push_str(&raw_inline(&html, format));
248            i += consumed;
249            continue;
250        }
251
252        out.push(markdown[i..].chars().next().unwrap());
253        i += markdown[i..].chars().next().unwrap().len_utf8();
254    }
255
256    out
257}
258
259/// Wrap a generated HTML fragment so the body's own parser passes it through
260/// verbatim instead of escaping it.
261///
262/// Markdown needs nothing — twig emits inline raw HTML as-is. Djot does not:
263/// a bare `<mark>` comes out as `&lt;mark&gt;`, and the only way in is an inline
264/// raw span, `` `…`{=html} ``. The fence is one backtick longer than the longest
265/// run inside the fragment, because the highlight/spoiler scanners can swallow a
266/// backtick that the inline-code branch didn't reach first (`==a ` b==`), and a
267/// fence the content also contains would close the span early.
268fn raw_inline(html: &str, format: ContentFormat) -> String {
269    if format != ContentFormat::Djot {
270        return html.to_string();
271    }
272    let longest = html
273        .split(|c| c != '`')
274        .map(|run| run.len())
275        .max()
276        .unwrap_or(0);
277    let fence = "`".repeat(longest + 1);
278    // Djot reads a leading/trailing backtick as part of the fence unless a
279    // space separates them; the space is not part of the raw content.
280    let pad = if html.starts_with('`') || html.ends_with('`') {
281        " "
282    } else {
283        ""
284    };
285    format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
286}
287
288/// Try to parse a highlight starting at `==`. Returns `(html, bytes_consumed)`.
289fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
290    const VALID_COLORS: &[&str] = &[
291        "red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
292    ];
293
294    if !s.starts_with("==") {
295        return None;
296    }
297
298    let after_open = &s[2..];
299    if after_open.is_empty() || after_open.starts_with("==") {
300        return None;
301    }
302
303    let (color, content_start) = if after_open.starts_with('{') {
304        let close_brace = after_open.find('}')?;
305        let color_name = &after_open[1..close_brace];
306        if !VALID_COLORS.contains(&color_name) {
307            return None;
308        }
309        (color_name, close_brace + 1)
310    } else {
311        ("yellow", 0)
312    };
313
314    let content_region = &after_open[content_start..];
315    let close_pos = content_region.find("==")?;
316    if close_pos == 0 {
317        return None;
318    }
319
320    let content = &content_region[..close_pos];
321    if content.contains('\n') {
322        return None;
323    }
324
325    let total_consumed = 2 + content_start + close_pos + 2;
326    let html = format!(
327        r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
328        color = color,
329        content = html_escape(content),
330    );
331
332    Some((html, total_consumed))
333}
334
335/// Try to parse a spoiler starting at `||`. Returns `(html, bytes_consumed)`.
336fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
337    if !s.starts_with("||") {
338        return None;
339    }
340
341    let after_open = &s[2..];
342    if after_open.is_empty() || after_open.starts_with("||") {
343        return None;
344    }
345
346    let close_pos = after_open.find("||")?;
347    if close_pos == 0 {
348        return None;
349    }
350
351    let content = &after_open[..close_pos];
352    if content.contains('|') || content.contains('\n') {
353        return None;
354    }
355
356    let total_consumed = 2 + close_pos + 2;
357    let html = format!(
358        r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
359        content = html_escape(content),
360    );
361
362    Some((html, total_consumed))
363}
364
365/// The height range an island is allowed to occupy, in CSS pixels.
366///
367/// The same clamp the parent-side resize bridge applies to a measurement from
368/// the frame (see `HtmlRenderer::interactivity_script`), applied here to the
369/// authored `{height=…}` so the two cannot disagree about what an island may be:
370/// a one-pixel embed is invisible, and one taller than any screen is a scroll
371/// trap in a page that already scrolls.
372const ISLAND_MIN_HEIGHT: u32 = 200;
373const ISLAND_MAX_HEIGHT: u32 = 4000;
374
375/// Try to parse an HTML embed starting at `![`. Returns `(html, bytes_consumed)`.
376///
377/// Matches `![alt](path.html)` or `![alt](path.htm)`, optionally followed by an
378/// attribute block, and converts it to a sandboxed `<iframe>` tag. This runs
379/// before the body's own parser so the raw HTML is passed through unchanged.
380///
381/// The only attribute is `{height=400}`, which sets the frame's initial
382/// `min-height` — what the reader sees before the resize bridge has measured the
383/// document, and what they keep seeing if it loads no child script. An attribute
384/// block spelling anything else leaves the whole embed unmatched, so it falls
385/// through to ordinary image parsing: an unknown attribute is more likely a
386/// syntax this version has not learned than a mistake worth eating the embed
387/// over, and a visible `![demo](x.html){wdith=400}` is a legible way to say so.
388fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
389    if !s.starts_with("![") {
390        return None;
391    }
392
393    let after_bang = &s[2..];
394    let close_bracket = after_bang.find(']')?;
395    let alt = &after_bang[..close_bracket];
396
397    let after_bracket = &after_bang[close_bracket + 1..];
398    if !after_bracket.starts_with('(') {
399        return None;
400    }
401
402    let after_paren = &after_bracket[1..];
403    let close_paren = after_paren.find(')')?;
404    let path = after_paren[..close_paren].trim();
405
406    // Only match .html / .htm extensions
407    let lower = path.to_lowercase();
408    if !lower.ends_with(".html") && !lower.ends_with(".htm") {
409        return None;
410    }
411
412    let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
413    let mut min_height = ISLAND_MIN_HEIGHT;
414    let after_embed = &s[total_consumed..];
415    if after_embed.starts_with('{') {
416        let close_brace = after_embed.find('}')?;
417        min_height = parse_island_height(&after_embed[1..close_brace])?;
418        total_consumed += close_brace + 1;
419    }
420
421    let html = format!(
422        r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
423        html_escape(path),
424        html_escape(alt),
425        min_height,
426    );
427
428    Some((html, total_consumed))
429}
430
431/// Read an island's attribute block. `None` for anything but `height=<integer>`,
432/// which unmatches the embed rather than silently dropping the attribute.
433fn parse_island_height(attributes: &str) -> Option<u32> {
434    let value = attributes.trim().strip_prefix("height")?.trim_start();
435    let value = value.strip_prefix('=')?.trim();
436    let height: u32 = value.parse().ok()?;
437    Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
438}
439
440/// Escape HTML special characters.
441fn html_escape(s: &str) -> String {
442    s.replace('&', "&amp;")
443        .replace('<', "&lt;")
444        .replace('>', "&gt;")
445        .replace('"', "&quot;")
446        .replace('\'', "&#39;")
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    /// The Markdown case, which is what every existing test asserted before a
454    /// body had a grammar to be in.
455    fn preprocess(source: &str) -> String {
456        preprocess_custom_syntax(source, ContentFormat::Markdown)
457    }
458
459    fn md(source: &str) -> String {
460        render_body(source, ContentFormat::Markdown)
461    }
462
463    /// A directive the author spelled is an element wearing its attributes.
464    #[test]
465    fn a_generic_directive_renders_as_an_element() {
466        let html = md(":::article{class=\"cover tone-blue\"}\n[Lake](lake.html)\n:::\n");
467        assert!(
468            html.contains("<article class=\"cover tone-blue\">"),
469            "{html}"
470        );
471        assert!(html.contains("<a href=\"lake.html\">Lake</a>"), "{html}");
472        assert!(html.contains("</article>"), "{html}");
473
474        let html = md("::cover[Label]{.x}\n\nA :swatch[see]{.red} b\n");
475        assert!(html.contains("<cover class=\"x\">Label</cover>"), "{html}");
476        assert!(
477            html.contains("<swatch class=\"red\">see</swatch>"),
478            "{html}"
479        );
480    }
481
482    /// A bare `:word` is prose: the grammar would read every one as an empty
483    /// element, and prose writes them constantly.
484    #[test]
485    fn a_bare_colon_word_stays_prose() {
486        let html = md("Party :tada: at 12:30pm, a:b, http://x.y/z and `c:d`.\n");
487        assert!(html.contains("Party :tada: at 12:30pm, a:b,"), "{html}");
488        assert!(html.contains("<code>c:d</code>"), "{html}");
489        assert!(!html.contains("<tada"), "{html}");
490        assert!(!html.contains("<b>"), "{html}");
491
492        // Several on one line and across lines, so the back-to-front escape
493        // is exercised past the first.
494        let html = md(":one and :two\n\n:three.\n");
495        assert!(html.contains(":one and :two"), "{html}");
496        assert!(html.contains(":three."), "{html}");
497    }
498
499    /// The template vocabulary's own spelling in a code fence is quoted, not
500    /// written, and so is a directive block.
501    #[test]
502    fn a_directive_in_a_code_fence_is_quoted() {
503        let html = md("```\n:::note{.x}\nhi\n:::\n```\n");
504        assert!(html.contains(":::note{.x}"), "{html}");
505        assert!(!html.contains("<note"), "{html}");
506    }
507
508    #[test]
509    fn highlight_default_color() {
510        let out = preprocess("a ==hi== b");
511        assert_eq!(
512            out,
513            r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
514        );
515    }
516
517    #[test]
518    fn highlight_named_color() {
519        let out = preprocess("=={red}danger==");
520        assert!(out.contains(r#"data-highlight-color="red""#));
521        assert!(out.contains("highlight-red"));
522        assert!(out.contains(">danger<"));
523    }
524
525    #[test]
526    fn highlight_invalid_color_is_left_alone() {
527        let out = preprocess("=={mauve}x==");
528        assert_eq!(out, "=={mauve}x==");
529    }
530
531    #[test]
532    fn spoiler_basic() {
533        let out = preprocess("||secret||");
534        assert_eq!(
535            out,
536            r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
537        );
538    }
539
540    #[test]
541    fn html_embed_becomes_iframe() {
542        let out = preprocess("![demo](island.html)");
543        assert!(out.contains(r#"<iframe src="island.html""#));
544        assert!(out.contains(r#"title="demo""#));
545        assert!(out.contains(r#"class="diaryx-island""#));
546    }
547
548    /// The initial height an island opens at, before — or without — a
549    /// measurement from the document inside it.
550    #[test]
551    fn html_embed_takes_an_authored_height() {
552        let out = preprocess("![demo](island.html){height=520}");
553        assert!(out.contains("min-height:520px"), "got {out}");
554        assert!(
555            !out.contains("{height=520}"),
556            "the block is consumed: {out}"
557        );
558    }
559
560    /// The same range the resize bridge clamps a measurement to, so an island
561    /// cannot open at a size it would never be allowed to reach.
562    #[test]
563    fn an_authored_height_is_clamped_to_the_bridges_range() {
564        assert!(preprocess("![d](i.html){height=10}").contains("min-height:200px"));
565        assert!(preprocess("![d](i.html){height=99999}").contains("min-height:4000px"));
566    }
567
568    /// An attribute this version does not know leaves the embed unmatched, so
569    /// the reader sees the syntax rather than an island silently missing the
570    /// thing it was asked for.
571    #[test]
572    fn an_unknown_island_attribute_leaves_the_embed_alone() {
573        let source = "![demo](island.html){wdith=400}";
574        assert_eq!(preprocess(source), source);
575        assert_eq!(
576            preprocess("![demo](island.html){height=tall}"),
577            "![demo](island.html){height=tall}"
578        );
579    }
580
581    /// `\!` is an escape in every grammar this preprocessor runs for, so an
582    /// escaped embed is text about an embed — a line of documentation, most
583    /// likely — and turning it into an island was the scanner reading past the
584    /// backslash it should have stopped at.
585    #[test]
586    fn an_escaped_embed_is_not_an_island() {
587        let out = preprocess(r"Write \![alt](page.html) to embed one.");
588        assert_eq!(out, r"Write \![alt](page.html) to embed one.");
589        assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
590
591        // The same for the other openers, and an escaped backslash still shields
592        // nothing but itself.
593        assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
594        assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
595        assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
596    }
597
598    #[test]
599    fn inline_code_is_untouched() {
600        let out = preprocess("`==not a highlight==`");
601        assert_eq!(out, "`==not a highlight==`");
602    }
603
604    #[test]
605    fn fenced_code_is_untouched() {
606        let input = "```\n==no==\n||no||\n```";
607        let out = preprocess(input);
608        assert_eq!(out, input);
609    }
610
611    /// The spellings of code a hand-rolled backtick counter never knew: a tilde
612    /// fence, an indented block, a two-backtick inline span, and a fence nested
613    /// in a list item. Each used to have its contents rewritten.
614    #[test]
615    fn every_spelling_of_code_is_untouched() {
616        for input in [
617            "~~~\n==no==\n~~~",
618            "para\n\n    ==no==\n    ![x](i.html)\n\npost",
619            "a ``==no==`` b",
620            "- item\n\n  ```\n  ==no==\n  ```\n",
621        ] {
622            assert_eq!(preprocess(input), input, "input: {input:?}");
623        }
624    }
625
626    /// Djot fences the same way, and the code mask comes from the document's own
627    /// grammar — so this holds for a Djot body without a second scanner.
628    #[test]
629    fn djot_fenced_code_is_untouched() {
630        let input = "```\n==no==\n||no||\n```\n";
631        assert_eq!(
632            preprocess_custom_syntax(input, ContentFormat::Djot),
633            input,
634            "a djot fence is code too"
635        );
636        let out = preprocess_custom_syntax("```\n==no==\n```\n\n==yes==\n", ContentFormat::Djot);
637        assert!(out.contains("```\n==no==\n```"), "fence intact: {out}");
638        assert!(
639            out.contains("highlight-mark"),
640            "prose still rewritten: {out}"
641        );
642    }
643
644    #[test]
645    fn escapes_content() {
646        let out = preprocess("==<b>&\"==");
647        assert!(out.contains("&lt;b&gt;&amp;&quot;"));
648    }
649
650    #[test]
651    fn markdown_renders_basics() {
652        let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
653        assert!(html.contains("<h1>"));
654        assert!(html.contains("<del>struck</del>"));
655    }
656
657    /// The whole comrak feature set this crate used to enable by hand
658    /// (`strikethrough`, `table`, `autolink`, `tasklist`, `footnotes`,
659    /// `unsafe`), asserted against twig so a regression in the engine that
660    /// replaced it cannot land quietly.
661    #[test]
662    fn markdown_still_covers_what_comrak_was_configured_for() {
663        let src = "~~struck~~\n\n\
664                   | a | b |\n|---|---|\n| 1 | 2 |\n\n\
665                   - [ ] todo\n- [x] done\n\n\
666                   A note.[^1]\n\n[^1]: The note.\n\n\
667                   <div class=\"raw\">passed through</div>\n\n\
668                   https://example.test\n\n```rust\nlet x = 1;\n```\n";
669        let html = render_body(src, ContentFormat::Markdown);
670        assert!(html.contains("<del>struck</del>"), "strikethrough");
671        assert!(
672            html.contains("<table>") && html.contains("<th>a</th>"),
673            "tables"
674        );
675        assert!(html.contains("type=\"checkbox\""), "tasklists");
676        assert!(html.contains("checked"), "a checked tasklist item");
677        assert!(html.contains("The note."), "footnote text");
678        assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
679        assert!(
680            html.contains("<a href=\"https://example.test\""),
681            "autolinks"
682        );
683        assert!(html.contains("language-rust"), "fenced code language");
684    }
685
686    /// Stage three, end to end and in all three grammars: the pre-processor
687    /// keeps its hands off fenced code, twig tags it with the language, and the
688    /// highlighter colours it — none of the three knowing about the others.
689    #[cfg(feature = "syntax-highlighting")]
690    #[test]
691    fn fenced_code_is_highlighted_in_every_grammar() {
692        for (format, src) in [
693            (ContentFormat::Markdown, "```rust\nlet x = 1;\n```\n"),
694            (ContentFormat::Djot, "```rust\nlet x = 1;\n```\n"),
695            (
696                ContentFormat::Html,
697                "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
698            ),
699        ] {
700            let html = render_body(src, format);
701            assert!(
702                html.contains(crate::syntax::HIGHLIGHTED_CLASS),
703                "{format:?} left it uncoloured: {html}"
704            );
705            assert!(html.contains("plates-storage"), "{format:?}: {html}");
706        }
707    }
708
709    /// The escaping twig applied has to survive the round trip, or a code block
710    /// starts publishing tags instead of showing them.
711    #[cfg(feature = "syntax-highlighting")]
712    #[test]
713    fn highlighting_does_not_unescape_the_page() {
714        let html = render_body(
715            "```rust\nlet s = \"<b>&amp;</b>\";\n```\n",
716            ContentFormat::Markdown,
717        );
718        assert!(!html.contains("<b>"), "a tag reached the page: {html}");
719        assert!(html.contains("&lt;b&gt;"), "still escaped: {html}");
720    }
721
722    /// A grammar the site supplied itself, reaching the page through the one
723    /// call that takes one.
724    #[cfg(feature = "syntax-highlighting")]
725    #[test]
726    fn a_site_grammar_reaches_a_rendered_body() {
727        let syntaxes = crate::syntax::Syntaxes::with_custom([(
728            "wat.sublime-syntax",
729            "name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n  main:\n    - match: ';;.*$'\n      scope: comment.line.wat\n",
730        )]);
731        let html = render_body_with(
732            "```wat\n;; a note\n```\n",
733            ContentFormat::Markdown,
734            &syntaxes,
735        );
736        assert!(html.contains("plates-comment"), "{html}");
737    }
738
739    #[test]
740    fn markdown_passes_preprocessed_raw_html_through() {
741        let html = render_body("==hi==", ContentFormat::Markdown);
742        assert!(html.contains("<mark"), "got {html}");
743    }
744
745    /// Djot escapes a bare tag, so the same custom syntax has to arrive as an
746    /// inline raw span. This is the assertion that the Djot path is not just
747    /// the Markdown path with a different parser.
748    #[test]
749    fn djot_custom_syntax_survives_as_raw_html() {
750        let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
751        assert!(
752            html.contains("<mark"),
753            "highlight reached the output: {html}"
754        );
755        assert!(html.contains("data-spoiler"), "spoiler too: {html}");
756        assert!(!html.contains("&lt;mark"), "and was not escaped: {html}");
757    }
758
759    #[test]
760    fn djot_renders_its_own_grammar() {
761        let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
762        assert!(html.contains("<em>emph</em>"));
763        assert!(html.contains("<mark>native</mark>"));
764    }
765
766    /// A fragment carrying a backtick would close a one-backtick raw span early.
767    #[test]
768    fn djot_raw_span_outruns_backticks_in_the_content() {
769        let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
770        assert!(out.starts_with("``"), "fence outgrew the content: {out}");
771        assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
772        let html = render_body("==a ` b==", ContentFormat::Djot);
773        assert!(html.contains("<mark"), "still a highlight: {html}");
774    }
775
776    #[test]
777    fn html_bodies_are_left_alone() {
778        // `==` and `||` are literal text in an HTML body, not Diaryx syntax.
779        let src = "<p>a == b || c</p>";
780        assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
781        let html = render_body(src, ContentFormat::Html);
782        assert!(html.contains("a == b || c"), "got {html}");
783        assert!(!html.contains("<mark"));
784    }
785}