Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/readme/mod.rs

//! **A repository's README, typeset — in two shapes, from one source.**
//!
//! ```text
//!                          ┌─ export_svg_column  ─▶ dressed  ─▶ kind "svg"   (default)
//!   README.md (a blob) ────┤
//!                          └─ export_html        ─▶ ammonia  ─▶ kind "html"  (?as=html)
//! ```
//!
//! The markdown→typst engine is **edda's `nornir-docbook`**, not this crate's.
//! It is the constellation's documentation render primitive and it already had
//! every part of this: pulldown-cmark events into typst markup, a typst-as-lib
//! engine with fonts embedded, out as HTML or SVG. A second translator here
//! would have been a second one to keep correct, and docbook's has scar tissue
//! this one would have had to grow again — its escaping is the way it is
//! because a `**SIGKILL**s` in one file once took down a 297 KB render and
//! named no line.
//!
//! What this module owns is the part that is a *forge's* problem and not a doc
//! engine's: which of a tree's files is the README, which blobs its images are,
//! how big a page a browser may be handed, and what happens when none of it
//! works.
//!
//! # Two grades, and which one is the default — it changed, and here is why
//!
//! **`svg` is the default and is what the console shows.** It is the document
//! actually typeset: a real measure, real tables, the page a person would print.
//!
//! It used to be the toggle, for one reason that no longer holds. `typst-svg`
//! outlines every glyph to a `<path>`, so the page had **no text in it at all** —
//! not selectable, not searchable, invisible to a screen reader beyond the
//! `<title>`. A console cannot lead with a page a blind reader finds empty, so it
//! led with the HTML instead. docbook 0.1.2's `TextLayer` closes that hole: an
//! invisible `<text>` element rides over each shaped run, at the baseline and the
//! advance width the typesetter measured, so the words are selectable, copyable,
//! findable with the browser's search and readable aloud, while the glyphs a
//! reader SEES are still the typeset ones.
//!
//! What it still costs is bytes — roughly **130 bytes of SVG per character of
//! README** — and one click: an `<a>` under the text layer no longer takes them,
//! because the layer has to be on top for a drag to select. Both are why the HTML
//! view is still offered rather than removed.
//!
//! **`html` is asked for with `?as=html`** and is behind the console's "HTML"
//! button. typst's HTML target, sanitised through `ammonia` on the way out — not
//! because typst's elements are suspect, they are ours, but because the **link
//! and image targets inside them came out of somebody's repository**. It is the
//! plainer page, it is a thirtieth of the bytes, and it is where a reader goes to
//! click a link.
//!
//! # The four kinds
//!
//! | kind | when |
//! |---|---|
//! | `svg` | the ordinary answer: it typeset and fits [`MAX_SVG_BYTES`] |
//! | `html` | `?as=html`, and the fallback whenever the typeset page fails |
//! | `text` | the README is not markdown (a bare `README`), or is not text |
//! | `none` | the tree root has no README |
//!
//! **None of them is a 500.** A README is somebody else's file and the console
//! is not entitled to fall over on it.

pub mod cache;
pub mod links;
pub mod pictures;
pub mod queue;

pub use cache::Cache;
pub use pictures::Pictures;

use nornir_docbook::{export_html, export_pdf, export_svg_column, ExportMeta, ImageSet, TextLayer};
use serde::{Deserialize, Serialize};

/// The largest typeset page this console will hand a browser.
///
/// Past this the render is thrown away and the HTML grade is served instead,
/// with the size in `warning`. 4 MiB is about a **32 KB README** — it was 40 KB
/// before docbook 0.1.2's text layer added its ~23%, and the limit was left
/// where it is rather than raised to keep the same README inside it: 32 KB is
/// still far past anything anyone reads on a repository page, and the number
/// that matters is what a phone pays for one `innerHTML`, not how much of a
/// document we would like to fit.
///
/// It also matters more than it did, because this is now the DEFAULT grade: a
/// visitor who asks for nothing is the one who can reach the fallback.
pub const MAX_SVG_BYTES: usize = 4 * 1024 * 1024;

/// The column the typeset page is set to, in points.
///
/// Wide enough for a table of routes at 11 pt, and the SVG is scaled to
/// whatever the reader actually has — the number decides the *measure*, not the
/// size on screen.
pub const COLUMN_PT: f64 = 720.0;

/// The README the console looks for, in order, at the tree root.
///
/// The order is the answer to a repository holding two: the first match wins
/// and nothing scans further, so which one is drawn is a fact and not a
/// coincidence of tree ordering.
pub const CANDIDATES: &[&str] = &["README.md", "readme.md", "README.markdown", "README"];

/// True when this name goes through the markdown pipeline rather than being
/// served as plain text.
///
/// A bare `README` is NOT markdown. GitHub treats it as text and so does this:
/// a file that was never written as markdown renders badly as markdown, and
/// "badly" here means headings appearing where somebody drew a line of `===`.
pub fn is_markdown(path: &str) -> bool {
    let lower = path.to_ascii_lowercase();
    lower.ends_with(".md") || lower.ends_with(".markdown")
}

/// Which shape the caller asked for.
///
/// A query parameter and not two routes, because it is one document and one
/// cache entry family — and because the console's toggle should change a word
/// in a URL rather than navigate somewhere else.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Shape {
    /// The typeset page, as one tall SVG with docbook's selectable text layer
    /// over it. **The default** — see the module docs for what changed.
    #[default]
    Svg,
    /// typst's HTML target: plainer, far smaller, and the one whose links take a
    /// click. Also the fallback whenever the typeset page cannot be served.
    Html,
}

impl Shape {
    /// The word that names this shape in a cache key and in an answer.
    pub fn name(self) -> &'static str {
        match self {
            Shape::Html => "html",
            Shape::Svg => "svg",
        }
    }
}

/// One README, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Readme {
    /// `html` | `svg` | `text` | `none`. See the module docs.
    pub kind: &'static str,
    /// The sanitised HTML, the SVG, or the plain text. Empty for `none`.
    pub body: String,
    /// Where it was found. Empty for `none`.
    pub path: String,
    /// The blob's object id — which is also the cache key, and what a client
    /// can use to know two renders are the same document.
    pub oid: String,
    /// Why this is not what was asked for. `None` on the happy path, so a
    /// client can tell "there is no README" from "the typeset page was too
    /// large" without parsing prose.
    pub warning: Option<String>,
}

impl Readme {
    /// The repository has no README at the tree root.
    pub fn none() -> Readme {
        Readme {
            kind: "none",
            body: String::new(),
            path: String::new(),
            oid: String::new(),
            warning: None,
        }
    }
}

/// **Everything that goes through the console's `inner_html`, cleaned first.**
///
/// `ammonia`'s default allow-list keeps the document elements — headings,
/// paragraphs, lists, tables, code, links, images — and drops `script`,
/// `style`, `iframe`, `object`, every `on*` attribute and every URL scheme that
/// is not http/https/mailto. It also drops `<html>`, `<head>` and `<body>`
/// while keeping what they wrapped, which is what turns docbook's full document
/// into the fragment a panel wants.
///
/// Applied to typst's HTML output even though those elements are **ours**: the
/// `href` and `src` values inside them are not. A README can write
/// `[click](javascript:…)` and typst will faithfully typeset a link to it.
///
/// # `title` is on the CONTENT list, and finding out why cost a test
///
/// ammonia's default removes the `<title>` **element** and keeps its text,
/// because that is right for a fragment somebody pasted. Here it is wrong:
/// docbook hands back a whole document whose `<title>` is the label this crate
/// put on it, so the default turned every README into one that began
/// "ns/name README.md" as a stray line of prose. `clean_content_tags` is the
/// distinction — remove the element *and* what it wrapped — and `head` is on
/// the list for the same reason.
///
/// # `rel` says `external`, and that is for the console's router
///
/// [`links::rewrite`] points a README's links at console URLs, and the router
/// claims every same-origin `<a>` click unless the anchor opts out. A link to
/// `/api/…/raw` is a file, not a route: claimed, it would move the URL bar and
/// draw nothing — the same bug the Log in button had. `external` hands every
/// README link back to the browser, which loads a console route as well as it
/// loads a PDF.
pub fn sanitise(html: &str) -> String {
    let mut builder = ammonia::Builder::default();
    builder.clean_content_tags(std::collections::HashSet::from([
        "script", "style", "title", "head",
    ]));
    builder.link_rel(Some("noopener noreferrer external"));
    builder.clean(html).to_string()
}

/// The metadata docbook's templates read.
///
/// `title` is the only field that reaches the page here: `column.typ` puts it in
/// the SVG's `<title>` and `aria-label`, and `web.typ` in `<title>`. The
/// repository and file, because "README" alone in a browser tab full of them
/// says nothing. `version` and `generated` belong to a book's cover, which
/// neither of these templates draws.
pub fn meta(label: &str) -> ExportMeta {
    ExportMeta {
        title: label.to_owned(),
        version: String::new(),
        generated: String::new(),
        cover_image: String::new(),
    }
}

/// Render one README into the shape that was asked for.
///
/// **Synchronous and CPU-bound** — [`crate::api::readme`] calls it on
/// `tokio::task::spawn_blocking`, because a typeset that takes tens of
/// milliseconds on a runtime worker is time every other request on that worker
/// waits.
///
/// # Errors
///
/// The typesetter's first diagnostic, or the size the page came to when it was
/// over [`MAX_SVG_BYTES`]. Either way the caller falls back rather than failing
/// the request.
pub fn render(
    markdown: &str,
    label: &str,
    pictures: &Pictures,
    shape: Shape,
) -> Result<String, String> {
    let meta = meta(label);
    let images = &pictures.set;
    match shape {
        Shape::Html => {
            let html = export_html(markdown, &meta, images).map_err(|e| e.to_string())?;
            // Before the sanitiser, which drops every `data:` URL: see
            // `pictures`.
            // A small SVG picture is carried as a data URL past it (an
            // `<img>` never runs an SVG's script): see `pictures`.
            let (html, carried) = pictures::hold_svg_data(&pictures.point(html, shape));
            Ok(pictures::restore_svg_data(sanitise(&html), &carried))
        }
        Shape::Svg => {
            // `TextLayer::On` is what makes this grade servable as the default:
            // without it the page is a picture of writing, and this console
            // would be leading with something a screen reader reads as empty.
            let svg = export_svg_column(markdown, &meta, images, COLUMN_PT, TextLayer::On)
                .map_err(|e| e.to_string())?;
            let svg = pictures.point(svg, shape);
            if svg.len() > MAX_SVG_BYTES {
                return Err(format!(
                    "the typeset page came to {} bytes, over the {MAX_SVG_BYTES}-byte limit \
                     this console will send a browser",
                    svg.len()
                ));
            }
            Ok(svg)
        }
    }
}

/// The largest PDF this console will typeset and hand over.
///
/// A PDF is not put through `inner_html` — the browser's own viewer opens it —
/// so the reason for the ceiling is not a phone's DOM but the same one
/// [`MAX_BLOB_BYTES`](crate::git::MAX_BLOB_BYTES) has: one request must not be
/// able to ask this process for an unbounded allocation. 16 MiB is far past any
/// document in a repository and comfortably inside what the cache will hold.
pub const MAX_PDF_BYTES: usize = 16 * 1024 * 1024;

/// **The same markdown, typeset to a PDF.**
///
/// docbook's `report.typ`: a cover with the title, an outline, running headers
/// and page numbers — a *document*, which is what somebody asking for a PDF
/// wants, rather than the web column [`render`] draws.
///
/// Both `None`s are load-bearing. The first is docbook's remote-image cache
/// directory, and passing one would turn a repository's `![](https://…)` into
/// an outbound HTTP request made by this process on a stranger's say-so — the
/// server-side request forgery `crate::api::readme` refuses at length. The
/// second is a typst filesystem root, and there is none: this console has no
/// checkout, only blobs over gRPC.
///
/// The PDF carries the same pictures the typeset page does: docbook 0.1.4's
/// `export_pdf` takes the [`ImageSet`] the column export always took. Until it
/// existed this path drew every image as its alt text — measured on gunnar.rs
/// 2026-09-12 as "View PDF" on any README with a badge — and the fix went into
/// docbook rather than into a forge, because that is where the paged pipeline is.
///
/// **Synchronous and CPU-bound**, like [`render`], and goes through
/// [`queue`] for the same reason.
///
/// # Errors
///
/// The typesetter's first diagnostic, or the size the PDF came to when it was
/// over [`MAX_PDF_BYTES`].
pub fn render_pdf(markdown: &str, label: &str, images: &ImageSet) -> Result<Vec<u8>, String> {
    let pdf = export_pdf(markdown, &meta(label), images).map_err(|e| e.to_string())?;
    if pdf.len() > MAX_PDF_BYTES {
        return Err(format!(
            "the typeset PDF came to {} bytes, over the {MAX_PDF_BYTES}-byte limit this console \
             will send a browser",
            pdf.len()
        ));
    }
    Ok(pdf)
}

/// **Why a typeset did not arrive — and whose fault it is.**
///
/// The two answers are not alike and the console must not confuse them. A
/// document the typesetter refuses is a fact about that document, it will be
/// refused again in an hour, and the reader is served the plain grade with the
/// reason attached. A console that is busy is a fact about the *box* at this
/// second: the document is fine, there is nothing to fall back to, and the
/// honest answer is a 503 or a 504 and a suggestion to come back.
///
/// Answering [`Trouble::Busy`] with the plain-HTML fallback would be a console
/// that responds to load by doing MORE work per request, which is the shape of
/// every cascading failure.
#[derive(Debug)]
pub enum Trouble {
    /// The typesetter refused this document, or its page was over the limit.
    Document(String),
    /// The console would not typeset anything right now.
    Busy(queue::Refused),
}

impl std::fmt::Display for Trouble {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Trouble::Document(why) => f.write_str(why),
            Trouble::Busy(refused) => write!(f, "{refused}"),
        }
    }
}

/// The directory one path lives in: everything before the last `/`, and empty
/// for a file at the tree root.
///
/// One definition, used for both halves of "relative to this document" — the
/// links ([`links::Base::directory`]) and the images — because the two
/// disagreeing means a picture fetched from one blob and linked to another.
pub fn directory_of(path: &str) -> &str {
    path.rsplit_once('/').map_or("", |(directory, _)| directory)
}

/// **The last grade: markdown to HTML with no typst in the way.**
///
/// Reached when typst refuses the document, or when the typeset page was too
/// large. It is the same sanitiser and a much simpler front end, so a README
/// that defeats the typesetter still reaches the reader.
pub fn plain_html(markdown: &str) -> String {
    let mut options = pulldown_cmark::Options::empty();
    options.insert(pulldown_cmark::Options::ENABLE_TABLES);
    options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
    options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
    let mut html = String::with_capacity(markdown.len() * 2);
    pulldown_cmark::html::push_html(
        &mut html,
        pulldown_cmark::Parser::new_ext(markdown, options),
    );
    sanitise(&html)
}

#[cfg(test)]
mod tests {
    use super::*;

    const SOURCE: &str = "# The Heading\n\nSome prose with `code_here` and a \
                          [link](https://example.test).\n\n\
                          | A | B |\n| - | - |\n| 1 | 2 |\n\n```rust\nfn f() {}\n```\n";

    #[test]
    fn the_candidates_are_tried_in_a_fixed_order() {
        assert_eq!(CANDIDATES[0], "README.md");
        assert_eq!(CANDIDATES.len(), 4);
    }

    #[test]
    fn a_bare_readme_is_not_markdown() {
        assert!(is_markdown("README.md"));
        assert!(is_markdown("readme.md"));
        assert!(is_markdown("README.markdown"));
        assert!(!is_markdown("README"));
    }

    /// **The default is the typeset page**, and it stayed nameable as `svg` in
    /// the cache key and in the answer — the two grades did not swap names when
    /// they swapped places, or every cached page in every deployment would have
    /// been served under the other one's.
    #[test]
    fn the_default_shape_is_the_typeset_one() {
        assert_eq!(Shape::default(), Shape::Svg);
        assert_eq!(Shape::Svg.name(), "svg");
        assert_eq!(Shape::Html.name(), "html");
    }

    /// **The HTML grade keeps the words as words**, which is the whole reason
    /// it is the default. Asserted as plain text in the body, because
    /// "selectable" means a browser can find the characters.
    #[test]
    fn the_html_grade_carries_the_heading_text_and_no_script() {
        let html =
            render(SOURCE, "ns/name README.md", &Pictures::new(), Shape::Html).expect("it renders");

        assert!(
            html.contains("The Heading"),
            "the heading must be text: {html}"
        );
        assert!(html.contains("Some prose"), "{html}");
        assert!(
            html.contains("code_here"),
            "an identifier keeps its underscore: {html}"
        );
        assert!(!html.contains("<script"), "{html}");
        assert!(!html.contains("javascript:"), "{html}");
        // Sanitised down to a fragment: the document wrapper is gone and what
        // it wrapped is not.
        assert!(!html.contains("<html"), "{html}");
        assert!(!html.contains("<head"), "{html}");
        // …and the document TITLE is gone with its element, rather than left
        // behind as a stray first line of prose.
        assert!(
            !html.contains("ns/name README.md"),
            "the document title leaked into the fragment: {html}"
        );
    }

    /// **And the SVG grade carries the words too, now.**
    ///
    /// This assertion used to read the other way — "the glyphs are paths; if the
    /// heading text appears the exporter changed" — and it was right about the
    /// exporter and was the reason this grade could not be the default. docbook
    /// 0.1.2 changed exactly that, so the assertion is inverted rather than
    /// deleted: the heading is in the page, as text, in the text layer.
    #[test]
    fn the_svg_grade_is_a_typeset_page_whose_words_are_still_words() {
        let svg =
            render(SOURCE, "ns/name README.md", &Pictures::new(), Shape::Svg).expect("it renders");

        assert!(svg.starts_with("<svg "), "{}", &svg[..60.min(svg.len())]);
        assert!(
            svg.contains("viewBox="),
            "without a viewBox it cannot scale"
        );
        assert!(svg.contains("width=\"100%\""), "it must fit a phone");
        assert!(
            !svg.contains("<script"),
            "an inline SVG must carry no script"
        );
        assert!(
            !svg.contains("javascript:"),
            "{}",
            &svg[..200.min(svg.len())]
        );
        assert!(svg.contains("<title>ns/name README.md</title>"), "no title");
        // The layer, and the heading in it.
        assert!(svg.contains("<text "), "no text layer in the typeset page");
        assert!(svg.contains("class=\"text-layer\""), "no text layer");
        assert!(
            svg.contains("The Heading"),
            "the heading must be selectable text, not only paths"
        );
        // It is a document a screen reader can read, and the drawing under it is
        // hidden so the same words are not offered twice.
        assert!(
            svg.contains("role=\"document\""),
            "{}",
            &svg[..200.min(svg.len())]
        );
        assert!(
            svg.contains("aria-hidden=\"true\""),
            "the glyphs stay hidden"
        );
        // The `<style>` that makes the layer selectable is INSIDE the SVG, and
        // the console's CSP must keep `style-src 'unsafe-inline'` for it —
        // `security::tests` is where that is nailed down.
        assert!(
            svg.contains("user-select:text"),
            "the layer must be selectable"
        );
    }

    /// **Every colour the typeset page paints is one the console's stylesheet
    /// maps onto the theme** (owner, 2026-10-06: the rendered README must
    /// follow light, dark and surprise). docbook's template paints in fixed
    /// colours, typst writes each as a `fill="#…"` / `stroke="#…"` attribute,
    /// and `console.css` repaints each by that exact attribute. A colour a
    /// docbook or typst upgrade starts painting that the stylesheet does not
    /// map would stay white-paper black in a dark panel; this names it.
    #[test]
    fn every_colour_the_typeset_page_paints_is_mapped_onto_the_theme() {
        let source = "# Heading\n\nProse with `inline` and a [link](https://example.test).\n\n\
                      > quoted\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n\
                      ```rust\n// a comment\nfn f() -> &'static str { let n = 1; \"text\" }\n```\n\n---\n\nend\n";
        let svg = render(source, "ns/name README.md", &Pictures::new(), Shape::Svg).expect("it renders");
        let css = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../gunnar-ui-web/styles/console.css"));
        let mut painted: Vec<String> = Vec::new();
        for attr in ["fill", "stroke"] {
            let open = format!("{attr}=\"#");
            for (i, _) in svg.match_indices(&open) {
                let hex: String = svg[i + open.len()..].chars().take_while(|c| c.is_ascii_hexdigit()).collect();
                let one = format!("{attr}=\"#{hex}\"");
                if !painted.contains(&one) {
                    painted.push(one);
                }
            }
        }
        // The page itself, the words, the link, the code box and the rules are
        // all in this source; the stylesheet must carry each.
        for must in ["fill=\"#ffffff\"", "fill=\"#16191d\"", "fill=\"#0a58ca\"", "fill=\"#f5f6f8\"", "fill=\"#f2f2f4\"", "stroke=\"#cfd4da\""] {
            assert!(painted.iter().any(|p| p == must), "docbook no longer paints {must}: {painted:?}");
        }
        let unmapped: Vec<&String> = painted.iter().filter(|p| !css.contains(&format!(".readme-page svg [{p}]"))).collect();
        assert!(unmapped.is_empty(), "the typeset page paints {unmapped:?}, which console.css does not map onto the theme");
    }

    /// **The PDF carries the picture the typeset page carries.** RED before
    /// docbook 0.1.4: `render_pdf` had no image argument and every badge in a
    /// README came out as its alt text in "View PDF" — measured on gunnar.rs
    /// 2026-09-12. Counted rather than detected, because docbook's report
    /// template places a cover picture of its own: with the badge there are
    /// strictly more image XObjects than without (a PNG with alpha is two — the
    /// raster and its soft mask).
    #[test]
    fn the_pdf_grade_embeds_the_pictures_it_was_handed() {
        const PNG_1PX: &[u8] = &[
            0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
            0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78,
            0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
            0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
        ];
        let md = "# Badge\n\n![the badge](docs/badge.png)\n\nProse.\n";
        let mut images = ImageSet::new();
        assert!(images.insert("docs/badge.png", PNG_1PX.to_vec()));
        let count = |pdf: &[u8]| pdf.windows(6).filter(|w| *w == b"/Image").count();

        let with = render_pdf(md, "ns/name README.md", &images).expect("renders");
        let without = render_pdf(md, "ns/name README.md", &ImageSet::new()).expect("renders");

        assert!(
            count(&with) > count(&without),
            "the PDF must embed the badge it was handed: {} image XObjects with, {} without",
            count(&with),
            count(&without)
        );
    }

    /// ★ **The PDF grade is a PDF**, from the same markdown and the same
    /// engine.
    ///
    /// Red-first: there was no third grade at all, and the console's answer to
    /// "give me this document as a PDF" was the raw route serving the markdown
    /// as an attachment. The assertion is on the file's own magic rather than on
    /// a length, because "some bytes came back" is what a stub would also
    /// satisfy.
    #[test]
    fn the_pdf_grade_is_a_document_a_viewer_will_open() {
        let pdf = render_pdf(SOURCE, "ns/name .nornir/quickstart.md", &ImageSet::new())
            .expect("it renders");

        assert!(
            pdf.starts_with(b"%PDF-"),
            "not a PDF: {:?}",
            &pdf[..8.min(pdf.len())]
        );
        assert!(
            pdf.ends_with(b"%%EOF\n") || pdf.ends_with(b"%%EOF"),
            "truncated"
        );
        // A page of prose, a table and a code block is thousands of bytes, not
        // an empty document.
        assert!(pdf.len() > 1_000, "{} bytes", pdf.len());
    }

    #[test]
    fn a_document_s_directory_is_where_its_neighbours_are() {
        assert_eq!(directory_of("README.md"), "");
        assert_eq!(directory_of(".nornir/gunnar-quickstart.md"), ".nornir");
        assert_eq!(directory_of("docs/a/b.md"), "docs/a");
    }

    /// A typst-special character in a README is drawn, not executed — docbook's
    /// string-literal escaping, seen from this side of the wall.
    #[test]
    fn a_readme_that_reads_like_typst_is_drawn_rather_than_run() {
        let hostile = "# Costs $5 @ #1\n\nCall MAX_BLOB_BYTES.\n\n\
                       #import \"@preview/cmarker:0.1.0\": *\n";

        let html =
            render(hostile, "ns/n README.md", &Pictures::new(), Shape::Html).expect("it renders");

        assert!(
            html.contains("MAX_BLOB_BYTES"),
            "the underscores survived: {html}"
        );
        assert!(html.contains("Costs $5 @ #1"), "{html}");
        assert!(
            html.contains("@preview/cmarker"),
            "the import is drawn as text: {html}"
        );
    }

    #[test]
    fn a_page_over_the_size_limit_is_refused_rather_than_sent() {
        // 60 KB of prose is roughly 6 MB of glyph paths, past MAX_SVG_BYTES.
        let huge = "lorem ipsum dolor sit amet consectetur adipiscing elit\n\n".repeat(1_100);

        let refused = render(&huge, "ns/name README.md", &Pictures::new(), Shape::Svg)
            .expect_err("a page this size must be refused");

        assert!(refused.contains("over the"), "{refused}");
    }

    /// **The sanitiser is the point**, and it is applied to both grades.
    #[test]
    fn the_sanitiser_drops_script_style_and_handlers() {
        let hostile = "<script>fetch('/api/keys')</script>\
                       <style>body{display:none}</style>\
                       <iframe src=\"https://evil.test\"></iframe>\
                       <img src=x onerror=\"alert(1)\">\
                       <a href=\"javascript:alert(1)\">x</a>\
                       <p><strong>kept</strong></p>";

        let html = sanitise(hostile);

        assert!(!html.contains("<script"), "{html}");
        assert!(!html.contains("<style"), "{html}");
        assert!(!html.contains("<iframe"), "{html}");
        assert!(!html.contains("onerror"), "{html}");
        assert!(!html.contains("javascript:"), "{html}");
        assert!(html.contains("<strong>kept</strong>"), "{html}");
    }

    #[test]
    fn the_last_grade_still_reaches_the_reader() {
        let html = plain_html("# Title\n\n<script>x</script>\n\n**kept**\n");

        assert!(html.contains("<h1>Title</h1>"), "{html}");
        assert!(html.contains("<strong>kept</strong>"), "{html}");
        assert!(!html.contains("<script"), "{html}");
    }

    /// **Warm is a read transaction and nothing else**, which is the claim the
    /// cache makes. Measured rather than asserted as a wall-clock bound,
    /// because a loaded CI box is not this laptop — the assertion is the one
    /// that would still be true there.
    #[tokio::test]
    async fn a_warm_read_is_a_read_transaction_and_nothing_else() {
        async fn never() -> Result<String, ()> {
            panic!("a warm read must not render")
        }

        let dir = tempfile::tempdir().expect("a temp dir");
        let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
        let cache = Cache::new(Some(store));
        let key = Cache::key("ns", "n", "oid", "README.md", Shape::Html);
        let big = "x".repeat(347 * 1024);

        cache
            .get_or_render(&key, || async { Ok::<_, ()>(big.clone()) })
            .await
            .expect("the cold render");

        let start = std::time::Instant::now();
        for _ in 0..20 {
            let held = cache.get_or_render(&key, never).await.expect("a warm read");
            assert_eq!(held.len(), big.len());
        }
        let each = start.elapsed() / 20;

        assert_eq!(cache.compiles(), 1);
        assert!(
            each < std::time::Duration::from_millis(10),
            "a warm read took {each:?}, which is not a read transaction"
        );
    }

    /// **The two shapes are two entries**, or the toggle would serve one of
    /// them under the other's name.
    #[tokio::test]
    async fn the_shape_is_part_of_the_cache_key() {
        let dir = tempfile::tempdir().expect("a temp dir");
        let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
        let cache = Cache::new(Some(store));

        let html = cache
            .get_or_render(
                &Cache::key("ns", "n", "oid", "README.md", Shape::Html),
                || async { Ok::<_, ()>("the html".to_owned()) },
            )
            .await
            .expect("html");
        let svg = cache
            .get_or_render(
                &Cache::key("ns", "n", "oid", "README.md", Shape::Svg),
                || async { Ok::<_, ()>("the svg".to_owned()) },
            )
            .await
            .expect("svg");

        assert_eq!((html.as_str(), svg.as_str()), ("the html", "the svg"));
        assert_eq!(cache.compiles(), 2);
    }
}