Skip to main content

claude_scriptorium/
render.rs

1//! Turning a parsed folio into a self-contained HTML document.
2
3use std::{path::Path, sync::LazyLock};
4
5use base64::{Engine, engine::general_purpose::STANDARD};
6use comrak::{
7    Options, markdown_to_html_with_plugins, options::Plugins, plugins::syntect::SyntectAdapter,
8};
9use jiff::{Timestamp, Zoned, tz::TimeZone};
10use maud::{DOCTYPE, Markup, PreEscaped, html};
11use serde_json::Value;
12
13use crate::transcript::{Block, Folio, ImageSource, Known, Panel, PanelKind, ToolResultContent};
14
15/// One embedded web font: the family and posture the stylesheet asks for, the
16/// weight range the variable file covers, and the woff2 bytes themselves.
17struct Face {
18    family: &'static str,
19    style: &'static str,
20    weight: &'static str,
21    woff2: &'static [u8],
22}
23
24/// The fonts are inlined so a folio stays self-contained (see the rendering
25/// invariants in CLAUDE.md). `just fonts` vendors these from upstream.
26///
27/// Junicode 2 (serif body, roman + italic) and Fira Code (monospace) are
28/// variable, so one file per posture spans every weight; UnifrakturCook is the
29/// single-weight blackletter used for headings and dropped versals.
30const FACES: [Face; 4] = [
31    Face {
32        family: "Junicode",
33        style: "normal",
34        weight: "300 700",
35        woff2: include_bytes!("fonts/JunicodeVF-Roman.woff2"),
36    },
37    Face {
38        family: "Junicode",
39        style: "italic",
40        weight: "300 700",
41        woff2: include_bytes!("fonts/JunicodeVF-Italic.woff2"),
42    },
43    Face {
44        family: "UnifrakturCook",
45        style: "normal",
46        weight: "400",
47        woff2: include_bytes!("fonts/UnifrakturCook.woff2"),
48    },
49    Face {
50        family: "Fira Code",
51        style: "normal",
52        weight: "300 700",
53        woff2: include_bytes!("fonts/FiraCode-VF.woff2"),
54    },
55];
56
57/// Copyright notices for the embedded fonts, emitted into every folio. The SIL
58/// OFL requires each copy of the font to carry its copyright and license; the
59/// woff2 metadata does for most faces, but this notice covers every artifact
60/// uniformly. Full license texts are vendored under src/fonts/licenses.
61const FONT_NOTICES: [(&str, &str); 3] = [
62    ("Junicode", "Copyright 2025 Peter S. Baker"),
63    ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
64    (
65        "UnifrakturCook",
66        "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
67    ),
68];
69
70/// The font attribution as an HTML comment for the top of every folio.
71fn font_notice() -> String {
72    let mut notice = String::from(
73        "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
74    );
75    for (family, copyright) in FONT_NOTICES {
76        notice.push_str(&format!("\n     {family}: {copyright}"));
77    }
78    notice.push_str("\n-->");
79    notice
80}
81
82/// The `@font-face` block, built once: base64 is deterministic and the bytes
83/// are compile-time constants, so encoding on every render would be wasted work
84/// on the `serve` hot path.
85static FONT_FACES: LazyLock<String> = LazyLock::new(|| {
86    FACES
87        .iter()
88        .map(|face| {
89            let data = STANDARD.encode(face.woff2);
90            format!(
91                "@font-face{{font-family:\"{}\";font-style:{};font-weight:{};font-display:swap;\
92                 src:url(data:font/woff2;base64,{}) format(\"woff2\")}}",
93                face.family, face.style, face.weight, data,
94            )
95        })
96        .collect()
97});
98
99/// The generation metadata recorded in every folio's plaque.
100pub struct Colophon {
101    pub generated: Timestamp,
102    pub tool: &'static str,
103    pub version: &'static str,
104}
105
106/// Renders folios, carrying the decisions a render depends on: how markdown
107/// becomes HTML, how code gets highlighted, and which zone timestamps read in.
108pub struct Scribe<'a> {
109    options: Options<'a>,
110    plugins: Plugins<'a>,
111    timezone: TimeZone,
112}
113
114impl<'a> Scribe<'a> {
115    pub fn new(highlighter: &'a SyntectAdapter, timezone: TimeZone) -> Self {
116        let mut options = Options::default();
117        options.extension.strikethrough = true;
118        options.extension.table = true;
119        options.extension.tasklist = true;
120        options.extension.autolink = true;
121        options.extension.footnotes = true;
122        options.render.github_pre_lang = true;
123
124        let mut plugins = Plugins::default();
125        plugins.render.codefence_syntax_highlighter = Some(highlighter);
126
127        Self {
128            options,
129            plugins,
130            timezone,
131        }
132    }
133
134    fn markdown(&self, source: &str) -> Markup {
135        PreEscaped(markdown_to_html_with_plugins(
136            source,
137            &self.options,
138            &self.plugins,
139        ))
140    }
141
142    fn zoned(&self, timestamp: Timestamp) -> Zoned {
143        timestamp.to_zoned(self.timezone.clone())
144    }
145
146    pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> Markup {
147        let title = format!("folio {}", folio.session_id());
148        let panels = folio.panels();
149        let left_border = margin_strip(border_seed(folio.session_id(), "left"));
150        let right_border = margin_strip(border_seed(folio.session_id(), "right"));
151        html! {
152            (DOCTYPE)
153            (PreEscaped(font_notice()))
154            html lang="en" {
155                head {
156                    meta charset="utf-8";
157                    meta name="viewport" content="width=device-width, initial-scale=1";
158                    title { (title) }
159                    style {
160                        (PreEscaped(FONT_FACES.as_str()))
161                        (PreEscaped(include_str!("illumination.css")))
162                    }
163                    // The folio's own behaviour (theme, and more to come). It
164                    // sits in <head> so the stored theme applies before the
165                    // body paints, avoiding a flash of the wrong scheme.
166                    script { (PreEscaped(include_str!("illumination.js"))) }
167                }
168                body {
169                    // Illuminated borders down each outer margin: a per-session
170                    // strip of vine sections with drolleries seated among them,
171                    // tiled by the stylesheet. Purely decorative, so hidden from
172                    // assistive tech.
173                    div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
174                    div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
175                    // The folio's plaque: title, facts, and colophon, tucked
176                    // into a disclosure in the top corner so the reading column
177                    // is pure transcript. Pure-CSS open/close, no script.
178                    details .plaque {
179                        summary .plaque__seal aria-label="folio details" title="folio details" { "❦" }
180                        div .plaque__panel {
181                            h1 .plaque__title { (title) }
182                            dl .plaque__facts {
183                                dt { "source" } dd { code { (folio.source.display().to_string()) } }
184                                dt { "turns" } dd { (panels.len()) }
185                                @if let Some(first) = panels.first() {
186                                    dt { "opened" } dd { (self.stamp(first.timestamp)) }
187                                }
188                            }
189                            p .plaque__colophon {
190                                "Written by " (colophon.tool) " " (colophon.version)
191                                " on " (self.stamp(colophon.generated)) "."
192                            }
193                            p .plaque__colophon {
194                                "Set in Junicode, Fira Code, and UnifrakturCook, under the "
195                                a href="https://openfontlicense.org" { "SIL Open Font License" } "."
196                            }
197                        }
198                    }
199                    // A fixed search widget: highlights matches and steps
200                    // through them, wired by the app script.
201                    div .search role="search" {
202                        div .search__bar {
203                            input .search__input type="search" placeholder="search folio" aria-label="search folio";
204                            span .search__count aria-live="polite" {}
205                            button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
206                            button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
207                        }
208                        // Restrict the search to chosen kinds of message, so a
209                        // query need not wade through tool output or reasoning.
210                        div .search__scopes role="group" aria-label="search in" {
211                            button .search__scope.search__scope--user type="button" data-scope="user" aria-pressed="true" { "user" }
212                            button .search__scope.search__scope--assistant type="button" data-scope="assistant" aria-pressed="true" { "assistant" }
213                            button .search__scope type="button" data-scope="tool" aria-pressed="true" { "tool" }
214                            button .search__scope type="button" data-scope="thinking" aria-pressed="true" { "thinking" }
215                        }
216                    }
217                    // A fixed dock: jump between messages and fold every tool
218                    // call open or shut. Wired by the app script. The nav grid
219                    // is three columns of up/down arrows: the middle steps
220                    // between all messages, flanked by a user (blue) and an
221                    // assistant (orange) column that seek only that speaker.
222                    nav .dock aria-label="folio navigation" {
223                        div .dock__nav {
224                            button .dock__btn .dock__btn--user type="button" data-nav="prev" data-role="user" aria-label="previous user message" title="previous user message" { "▲" }
225                            button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
226                            button .dock__btn .dock__btn--assistant type="button" data-nav="prev" data-role="assistant" aria-label="previous assistant message" title="previous assistant message" { "▲" }
227                            button .dock__btn .dock__btn--user type="button" data-nav="next" data-role="user" aria-label="next user message" title="next user message" { "▼" }
228                            button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
229                            button .dock__btn .dock__btn--assistant type="button" data-nav="next" data-role="assistant" aria-label="next assistant message" title="next assistant message" { "▼" }
230                        }
231                        div .dock__fold {
232                            button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
233                            button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
234                        }
235                    }
236                    // Presentation controls, opposite the navigation dock:
237                    // light / dark / system, wired by the app script and
238                    // defaulting to the reader's system preference.
239                    div .controls {
240                        div .theme-toggle role="group" aria-label="colour theme" {
241                            button type="button" data-theme-choice="light" { "light" }
242                            button type="button" data-theme-choice="system" aria-pressed="true" { "system" }
243                            button type="button" data-theme-choice="dark" { "dark" }
244                        }
245                    }
246                    main .folio {
247                        @for panel in &panels {
248                            (self.panel(panel))
249                        }
250                    }
251                }
252            }
253        }
254    }
255
256    fn stamp(&self, timestamp: Timestamp) -> String {
257        self.zoned(timestamp)
258            .strftime("%Y-%m-%d %H:%M:%S %Z")
259            .to_string()
260    }
261
262    fn panel(&self, panel: &Panel) -> Markup {
263        let kind = panel.kind();
264        // A speaker's leading paragraph opens with a rubricated versal (a
265        // dropped blackletter initial the stylesheet draws): tag the block that
266        // carries it so only that one is decorated, and not any prose that
267        // resumes after a tool call. Tool and thinking panels carry no versal.
268        let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
269            .then(|| panel.blocks.iter().position(Block::is_visible_text))
270            .flatten();
271        html! {
272            article class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
273                data-turn=(panel.turn_number)
274                data-sidechain[panel.is_sidechain] data-meta[panel.is_meta] {
275                header .turn__meta {
276                    span .turn__role { (kind.label()) }
277                    @if let Some(model) = &panel.model {
278                        span .turn__model { (model) }
279                    }
280                    time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
281                    span .turn__index { "#" (panel.turn_number) }
282                }
283                @for (index, block) in panel.blocks.iter().enumerate() {
284                    (self.block(block, Some(index) == opening))
285                }
286            }
287        }
288    }
289
290    fn block(&self, block: &Block, versal: bool) -> Markup {
291        let known = match block {
292            Block::Known(known) => known,
293            Block::Unknown(value) => return unknown(value),
294        };
295        match known {
296            Known::Text { text } => {
297                html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
298            }
299            // Redacted thinking arrives as an empty string with only a
300            // signature: the reasoning happened but wasn't recorded. Mark it
301            // rather than dropping it to nothing (which leaves a bare turn).
302            Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
303                p .block.block--redacted { "reasoning redacted" }
304            },
305            Known::Thinking { thinking } => html! {
306                section .block.block--thinking {
307                    (self.markdown(thinking))
308                }
309            },
310            Known::ToolUse { name, input } => html! {
311                details .marginalia.marginalia--use {
312                    summary {
313                        span .marginalia__tool { (name) }
314                        @if let Some(gist) = gist(input) {
315                            span .marginalia__gist { (gist) }
316                        }
317                    }
318                    (self.tool_body(name, input))
319                }
320            },
321            Known::ToolResult { content, is_error } => html! {
322                details .marginalia.marginalia--result data-error[*is_error] {
323                    summary { @if *is_error { "error" } @else { "result" } }
324                    @match content {
325                        ToolResultContent::Text(text) => pre .marginalia__body { code { (text) } },
326                        ToolResultContent::Blocks(blocks) => @for block in blocks { (self.block(block, false)) },
327                    }
328                }
329            },
330            Known::Image { source } => image(source),
331        }
332    }
333
334    /// The body of a tool call. A handful of common tools get a bespoke view;
335    /// everything else (and any call whose input doesn't match the shape this
336    /// view expects) falls back to pretty-printed JSON, the same treatment an
337    /// unrecognized block gets.
338    fn tool_body(&self, name: &str, input: &Value) -> Markup {
339        let special = match name {
340            "Bash" => self.bash_body(input),
341            "Write" => self.write_body(input),
342            "Edit" => self.edit_body(input),
343            "TodoWrite" => self.todo_body(input),
344            _ => None,
345        };
346        special.unwrap_or_else(|| json(input))
347    }
348
349    fn bash_body(&self, input: &Value) -> Option<Markup> {
350        let command = input.get("command")?.as_str()?;
351        let description = input.get("description").and_then(Value::as_str);
352        Some(html! {
353            div .tool.tool--bash {
354                @if let Some(description) = description {
355                    p .tool__caption { (description) }
356                }
357                (self.code_block("bash", command))
358            }
359        })
360    }
361
362    fn write_body(&self, input: &Value) -> Option<Markup> {
363        let path = input.get("file_path")?.as_str()?;
364        let content = input.get("content")?.as_str()?;
365        Some(html! {
366            div .tool.tool--write {
367                (self.code_block(lang_for_path(path), content))
368            }
369        })
370    }
371
372    fn edit_body(&self, input: &Value) -> Option<Markup> {
373        let old = input.get("old_string")?.as_str()?;
374        let new = input.get("new_string")?.as_str()?;
375        let replace_all = input
376            .get("replace_all")
377            .and_then(Value::as_bool)
378            .unwrap_or(false);
379        Some(html! {
380            div .tool.tool--edit {
381                @if replace_all {
382                    p .tool__caption { "replace all occurrences" }
383                }
384                (self.code_block("diff", &unified_diff(old, new)))
385            }
386        })
387    }
388
389    fn todo_body(&self, input: &Value) -> Option<Markup> {
390        let todos = input.get("todos")?.as_array()?;
391        Some(html! {
392            ul .tool.tool--todos {
393                @for todo in todos {
394                    @let content = todo.get("content").and_then(Value::as_str).unwrap_or("");
395                    @let status = todo.get("status").and_then(Value::as_str).unwrap_or("pending");
396                    li .tool__todo data-status=(status) { (content) }
397                }
398            }
399        })
400    }
401
402    /// A fenced code block run through the markdown path so it picks up syntax
403    /// highlighting. The fence is grown past the longest backtick run in the
404    /// source, so a body that itself contains backticks can't break out of it.
405    fn code_block(&self, lang: &str, code: &str) -> Markup {
406        let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
407        self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
408    }
409}
410
411/// One border cell's coordinate box: the composed strip is one cell wide, and
412/// each vine or drollery is authored to sit in this 90x210 space.
413const CELL_WIDTH: u32 = 90;
414const CELL_HEIGHT: u32 = 210;
415
416/// Cells the border is stitched from before the strip repeats down a long
417/// folio. Long enough that a full bestiary's worth of drolleries appears before
418/// the strip recurs, at the cost of a larger data URI.
419const STRIP_CELLS: usize = 48;
420
421/// The vine cell, the border's default section.
422const VINE_CELL: &str = include_str!("drolleries/vine.svg");
423
424/// A vine stub that eases the border into a drollery: baked above each creature
425/// and mirrored below, its stroke fading to transparent (via the `vinefade`
426/// gradient) as it nears the beast, so the vine dissolves in and coalesces back
427/// rather than stopping dead at a gap.
428const TRAIL: &str = include_str!("drolleries/trail.svg");
429
430/// The fade the trail's stroke draws with: opaque vine gold at the seam,
431/// transparent by the time it reaches the creature. `userSpaceOnUse` resolves it
432/// in each trail's own (possibly mirrored) coordinate space, so one definition
433/// serves every drollery and its mirror.
434const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
435     x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
436     <stop offset=\"0\" stop-color=\"#c1912f\"/>\
437     <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
438
439/// The bestiary a border seats between vines, each paired with the `(dx, dy)`
440/// nudge that centres it in its cell: `dx` on the vine's centreline (x=45), and
441/// `dy` in the gap between the fading trail above and its mirror below (the
442/// creatures are drawn low in the 90x210 box, so most lift toward the gap's
443/// centre at y=105). Several carry a tail or ear that pulls the bounding box off
444/// centre, so the nudges are measured, not zero. Each is line-and-pigment art
445/// authored in that cell (see CLAUDE.md); a background-image SVG can't reach the
446/// palette variables, so the colours are baked to read on either parchment.
447const DROLLERIES: [(&str, i32, i32); 10] = [
448    (include_str!("drolleries/snail.svg"), 0, -18),
449    (include_str!("drolleries/budgie.svg"), -7, -11),
450    (include_str!("drolleries/cockatiel.svg"), -8, 2),
451    (include_str!("drolleries/cardinal.svg"), -6, -3),
452    (include_str!("drolleries/fish.svg"), -8, 0),
453    (include_str!("drolleries/butterfly.svg"), 0, -23),
454    (include_str!("drolleries/frog.svg"), 0, -32),
455    (include_str!("drolleries/cat.svg"), -5, -23),
456    (include_str!("drolleries/hare.svg"), 0, -14),
457    (include_str!("drolleries/stag.svg"), -2, -21),
458];
459
460/// A small deterministic PRNG (SplitMix64) so a border's section layout is
461/// stable for a seed but varies cell to cell.
462struct Rng(u64);
463
464impl Rng {
465    fn next(&mut self) -> u64 {
466        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
467        let mut z = self.0;
468        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
469        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
470        z ^ (z >> 31)
471    }
472}
473
474/// A stable seed for one border of a folio: the session id folded with a
475/// per-side salt (FNV-1a), so the two borders differ but each is reproducible.
476/// FNV-1a keeps the mapping self-contained rather than leaning on stdlib hash
477/// output, whose value isn't guaranteed across Rust releases.
478fn border_seed(session_id: &str, salt: &str) -> u64 {
479    session_id
480        .bytes()
481        .chain(salt.bytes())
482        .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
483            (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
484        })
485}
486
487/// A section of a border: a plain vine cell, or a drollery with the offset that
488/// centres it.
489#[derive(Clone, Copy)]
490enum BorderCell {
491    Vine,
492    Drollery { svg: &'static str, dx: i32, dy: i32 },
493}
494
495/// The whole bestiary in a fresh random order (Fisher-Yates). Drawing drolleries
496/// from a bag that refills when drained means every creature appears before any
497/// repeats, so a border cycles through all of them rather than a fixed few.
498fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
499    let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
500    for i in (1..bag.len()).rev() {
501        bag.swap(i, rng.next() as usize % (i + 1));
502    }
503    bag
504}
505
506/// One border as a sequence of cells: mostly vine, with drolleries seated at
507/// intervals. The seam cells stay vine so the strip tiles cleanly, and no two
508/// drolleries sit adjacent so each creature reads on its own.
509fn border_cells(seed: u64) -> Vec<BorderCell> {
510    let mut rng = Rng(seed);
511    let mut bag = shuffled_bag(&mut rng);
512    let mut cells = Vec::with_capacity(STRIP_CELLS);
513    let mut previous_was_drollery = false;
514    for index in 0..STRIP_CELLS {
515        let seam = index == 0 || index == STRIP_CELLS - 1;
516        let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
517        if drollery {
518            if bag.is_empty() {
519                bag = shuffled_bag(&mut rng);
520            }
521            let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
522            cells.push(BorderCell::Drollery { svg, dx, dy });
523        } else {
524            cells.push(BorderCell::Vine);
525        }
526        previous_was_drollery = drollery;
527    }
528    cells
529}
530
531/// A tiling border strip for one side of a folio as a base64 SVG data URI. The
532/// renderer sets it as a `background-image`; the stylesheet repeats it to fill
533/// the leaf, however tall (see the border invariants in CLAUDE.md).
534fn margin_strip(seed: u64) -> String {
535    let height = STRIP_CELLS as u32 * CELL_HEIGHT;
536    let mut inner = String::new();
537    for (index, cell) in border_cells(seed).iter().enumerate() {
538        let y = index as u32 * CELL_HEIGHT;
539        // Frame a drollery with the fading trail above and its vertical mirror
540        // below, nudging the creature to centre it between them; vine cells
541        // stand alone.
542        let content = match cell {
543            BorderCell::Vine => VINE_CELL.to_string(),
544            BorderCell::Drollery { svg, dx, dy } => format!(
545                "{TRAIL}<g transform=\"translate({dx},{dy})\">{svg}</g>\
546                 <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
547            ),
548        };
549        inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
550    }
551    let svg = format!(
552        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
553         height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
554    );
555    format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
556}
557
558/// A one-line summary of a tool call, drawn from whichever field carries the
559/// subject of the call.
560fn gist(input: &Value) -> Option<&str> {
561    ["command", "file_path", "pattern", "path", "url", "prompt"]
562        .iter()
563        .find_map(|field| input.get(field)?.as_str())
564}
565
566/// The language token for a path, taken from its extension. syntect resolves it
567/// by extension or name, so the bare extension is enough; an empty token (no
568/// extension) highlights as plain text.
569fn lang_for_path(path: &str) -> &str {
570    Path::new(path)
571        .extension()
572        .and_then(|extension| extension.to_str())
573        .unwrap_or("")
574}
575
576/// An `Edit`'s before/after rendered as a diff body: the old lines removed, the
577/// new lines added. The `diff` lexer colours each line by its leading marker,
578/// reusing the inserted/deleted scope palette the stylesheet already defines.
579fn unified_diff(old: &str, new: &str) -> String {
580    let mut diff = String::new();
581    for line in old.lines() {
582        diff.push('-');
583        diff.push_str(line);
584        diff.push('\n');
585    }
586    for line in new.lines() {
587        diff.push('+');
588        diff.push_str(line);
589        diff.push('\n');
590    }
591    diff
592}
593
594fn longest_backtick_run(source: &str) -> usize {
595    let mut longest = 0;
596    let mut run = 0;
597    for byte in source.bytes() {
598        if byte == b'`' {
599            run += 1;
600            longest = longest.max(run);
601        } else {
602            run = 0;
603        }
604    }
605    longest
606}
607
608fn json(value: &Value) -> Markup {
609    let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
610    html! { pre .marginalia__body { code { (pretty) } } }
611}
612
613fn unknown(value: &Value) -> Markup {
614    let label = value
615        .get("type")
616        .and_then(Value::as_str)
617        .unwrap_or("unrecognized");
618    html! {
619        details .block.block--unknown {
620            summary { (label) }
621            (json(value))
622        }
623    }
624}
625
626fn image(source: &ImageSource) -> Markup {
627    let src = format!("data:{};base64,{}", source.media_type, source.data);
628    html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn border_strip_is_stable_per_seed() {
637        let seed = border_seed("3f9c-a17b-session", "left");
638        assert_eq!(margin_strip(seed), margin_strip(seed));
639    }
640
641    #[test]
642    fn the_two_borders_of_a_folio_differ() {
643        let session = "3f9c-a17b-session";
644        assert_ne!(
645            margin_strip(border_seed(session, "left")),
646            margin_strip(border_seed(session, "right"))
647        );
648    }
649
650    fn is_drollery(cell: &BorderCell) -> bool {
651        matches!(cell, BorderCell::Drollery { .. })
652    }
653
654    #[test]
655    fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
656        // Seams stay vine so the strip tiles cleanly, drolleries never sit
657        // adjacent, and vine dominates. Exercise many seeds for confidence.
658        let mut total_drolleries = 0;
659        for salt in [
660            "one", "two", "three", "four", "five", "six", "seven", "eight",
661        ] {
662            let cells = border_cells(border_seed("a-session", salt));
663            assert_eq!(cells.len(), STRIP_CELLS);
664            assert!(matches!(cells[0], BorderCell::Vine));
665            assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
666            let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
667            assert!(
668                drolleries < STRIP_CELLS / 2,
669                "too many drolleries: {drolleries}"
670            );
671            let adjacent = cells
672                .windows(2)
673                .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
674            assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
675            total_drolleries += drolleries;
676        }
677        // A border of pure vine would defeat the point; the generator must seat
678        // creatures.
679        assert!(total_drolleries > 0, "generator produced no drolleries");
680    }
681
682    #[test]
683    fn a_border_cycles_through_the_whole_bestiary() {
684        // The shuffled bag draws every creature before repeating any, so a
685        // border with a full bag's worth of drolleries shows all of them, and a
686        // shorter one still never repeats before the bag drains.
687        let cells = border_cells(border_seed("variety-session", "left"));
688        let used: std::collections::HashSet<&str> = cells
689            .iter()
690            .filter_map(|cell| match cell {
691                BorderCell::Drollery { svg, .. } => Some(*svg),
692                BorderCell::Vine => None,
693            })
694            .collect();
695        let count = cells.iter().filter(|cell| is_drollery(cell)).count();
696        let expected = count.min(DROLLERIES.len());
697        assert_eq!(
698            used.len(),
699            expected,
700            "creatures repeated before the bag drained"
701        );
702    }
703}