Skip to main content

plates_render/
body.rs

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