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/links.rs

//! **Where a README's links go, once they are on the console's page.**
//!
//! A README writes its links for the tree it lives in: `docs/guide.pdf`,
//! `.nornir/detail.md`, `src/lib.rs#L10`. On a git host that resolves against
//! the file's own URL; on this console the README is drawn inside
//! `/{ns}/{name}`, a route that is not a directory of anything, so every
//! relative link resolved to a page that does not exist — measured on gunnar.rs
//! 2026-09-11, the repository's link to its own PDF.
//!
//! So every `<a>` in the rendered page has its target rewritten here, after the
//! render and after the cache, into a console URL:
//!
//! | the link names | it becomes |
//! |---|---|
//! | a file a browser shows by itself (a PDF, a picture: [`RawKind`]) | `/api/repos/{ns}/{name}/raw?path=…` — the bytes, see `api::browse::raw` |
//! | any other file, or a directory | `/{ns}/{name}/tree/…` — the console's own view of it |
//! | `http`, `https`, `mailto`, `//host`, `#fragment` | left alone |
//! | any other scheme | `#` |
//!
//! # Why after the cache and not inside the render
//!
//! A render is keyed by the blob's oid, and the same blob can be the README of
//! two refs — `?ref=` is part of where a link goes but not of what the page
//! looks like. Rewriting per request keeps the cache honest and costs one
//! linear pass over a string the response was going to copy anyway.
//!
//! # Pictures too: `<img src>` and SVG `<image href>`
//!
//! A picture is a link the browser follows by itself, and it had the same
//! bug: a raw `<img src="assets/x.png">` in a README (nordisk/knut on
//! gunnar.rs, 2026-10-15) resolved against `/{ns}/{name}` and fetched a
//! console page. A picture's target always becomes the raw route — never the
//! file view, which is a page and not an image — with the revision, like any
//! link. `data:` (what typst writes for a picture the page carries) and
//! `http(s)` are left as written; the console's `img-src 'self' data:` is what
//! decides whether a browser loads another host's picture, and it does not.
//!
//! # Why both `href` and `xlink:href`
//!
//! `typst-svg` writes both on every `<a>`, and docbook's link layer does too:
//! SVG 2 reads `href` and older engines read `xlink:href`. Rewriting one and not
//! the other would leave a browser that prefers the other on the old target.
//!
//! # Why a scanner and not a parser
//!
//! The two inputs are both machine-written: html5ever's serializer (through
//! `ammonia`) and `typst-svg`/docbook. Both write every attribute double-quoted
//! and escaped, so the only grammar this has to know is "a tag starts at `<a `
//! and ends at the first `>` outside quotes". A parser would be a third opinion
//! about two documents that already agree.

use std::borrow::Cow;

use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};

use crate::api::browse::RawKind;

/// Everything not unreserved, in a query value.
const VALUE: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-')
    .remove(b'_')
    .remove(b'.')
    .remove(b'~');

/// A query value that is a path: the separators stay readable.
const PATH_VALUE: &AsciiSet = &VALUE.remove(b'/');

/// Which repository, at which revision, and **from which directory**, the links
/// are resolved against.
#[derive(Debug, Clone, Copy)]
pub struct Base<'a> {
    pub namespace: &'a str,
    pub name: &'a str,
    /// The `?ref=` the README was asked for. Empty is the default branch, and
    /// then the links carry no revision either.
    pub reference: &'a str,
    /// **The directory the rendered document itself lives in**, with no leading
    /// or trailing slash. Empty is the tree root.
    ///
    /// It exists because this module stopped being the README's alone. A README
    /// is at the root, so for years "relative to the document" and "relative to
    /// the tree" were the same sentence and the code only ever wrote the second
    /// one. They come apart the moment any other file is rendered:
    /// `.nornir/gunnar-quickstart.md` writes `![](shot.png)` and means
    /// `.nornir/shot.png`, and resolving that at the root finds nothing —
    /// silently, because a missing image is drawn as its alt text.
    ///
    /// A link that starts with `/` is still root-relative, exactly as it was.
    pub directory: &'a str,
}

impl<'a> Base<'a> {
    /// The base for a document at the tree root, which is where every README
    /// is.
    pub fn at_root(namespace: &'a str, name: &'a str, reference: &'a str) -> Base<'a> {
        Base {
            namespace,
            name,
            reference,
            directory: "",
        }
    }
}

/// Rewrite the target of every `<a>`, `<img>` and SVG `<image>` in a rendered
/// page. See the module docs.
pub fn rewrite(page: &str, base: &Base) -> String {
    let mut out = String::with_capacity(page.len() + page.len() / 64);
    let mut rest = page;
    while let Some((at, tag)) = find_tag(rest) {
        out.push_str(&rest[..at]);
        let tag_end = tag_end(&rest[at..]).map_or(rest.len(), |end| at + end);
        rewrite_tag(&rest[at..tag_end], tag, base, &mut out);
        rest = &rest[tag_end..];
    }
    out.push_str(rest);
    out
}

/// Which kind of tag carries a target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tag {
    /// `<a href>` / `<a xlink:href>`: a link.
    Anchor,
    /// `<img src>`: a picture in HTML.
    Img,
    /// `<image href>` / `<image xlink:href>`: a picture in SVG.
    Image,
}

impl Tag {
    /// The attribute whose value is the target (`href` covers `xlink:href`).
    fn attribute(self) -> &'static str {
        match self {
            Tag::Anchor | Tag::Image => "href",
            Tag::Img => "src",
        }
    }
}

/// The byte offset of the next `<a`, `<img` or `<image` that opens such a tag
/// — the name followed by whitespace, `/` or `>`, so `<abbr>`, `<animate>` and
/// `<imagex>` are not one.
fn find_tag(text: &str) -> Option<(usize, Tag)> {
    let bytes = text.as_bytes();
    let mut from = 0;
    while let Some(found) = text[from..].find('<') {
        let at = from + found;
        let after = &bytes[at + 1..];
        for (name, tag) in [("image", Tag::Image), ("img", Tag::Img), ("a", Tag::Anchor)] {
            if after.starts_with(name.as_bytes())
                && matches!(
                    after.get(name.len()),
                    Some(b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/')
                )
            {
                // `<a/>` is not an anchor with a target; the others may be.
                if tag == Tag::Anchor && after.get(1) == Some(&b'/') {
                    break;
                }
                return Some((at, tag));
            }
        }
        from = at + 1;
    }
    None
}

/// The offset just past the `>` that closes the tag starting at `tag[0]`,
/// ignoring any `>` inside a quoted value — html5ever does not escape one.
fn tag_end(tag: &str) -> Option<usize> {
    let mut quote = None;
    for (i, c) in tag.char_indices() {
        match (quote, c) {
            (None, '"' | '\'') => quote = Some(c),
            (Some(q), c) if c == q => quote = None,
            (None, '>') => return Some(i + 1),
            _ => {}
        }
    }
    None
}

/// Copy one tag to `out`, with its target values resolved: `href` and
/// `xlink:href` on an `<a>` or an `<image>`, `src` on an `<img>`.
fn rewrite_tag(text: &str, tag: Tag, base: &Base, out: &mut String) {
    let mut rest = text;
    loop {
        let Some((value_at, value_end)) = next_target(rest, tag.attribute()) else {
            out.push_str(rest);
            return;
        };
        out.push_str(&rest[..value_at]);
        let raw = &rest[value_at..value_end];
        let target = match tag {
            Tag::Anchor => resolve(&unescape(raw), base),
            Tag::Img | Tag::Image => resolve_picture(&unescape(raw), base),
        };
        match target {
            Some(target) => out.push_str(&escape(&target)),
            None => out.push_str(raw),
        }
        // Past the closing quote too: the next search's "inside a value?"
        // count starts from here, and a stray closing quote would flip it.
        out.push('"');
        rest = &rest[value_end + 1..];
    }
}

/// The next `{attribute}="…"` attribute in a tag — for `href`, `xlink:href`
/// too: the span of its value inside the quotes.
fn next_target(tag: &str, attribute: &str) -> Option<(usize, usize)> {
    let opening = format!("{attribute}=\"");
    let mut from = 0;
    while let Some(found) = tag[from..].find(&opening) {
        let at = from + found;
        let name_at = if attribute == "href" && tag[..at].ends_with("xlink:") {
            at - 6
        } else {
            at
        };
        // An attribute name starts after whitespace. `data-href="…"` is not
        // `href`, and neither is a `href="` that is inside another value.
        let starts_attribute = tag[..name_at]
            .chars()
            .next_back()
            .is_some_and(char::is_whitespace);
        let value_at = at + opening.len();
        let value_end = tag[value_at..].find('"').map(|end| value_at + end)?;
        if starts_attribute && !inside_quotes(&tag[..name_at]) {
            return Some((value_at, value_end));
        }
        from = value_end;
    }
    None
}

/// True when `prefix` ends inside an open double-quoted value.
fn inside_quotes(prefix: &str) -> bool {
    prefix.matches('"').count() % 2 == 1
}

/// Where one link should go on this console, or `None` to leave it as written.
pub fn resolve(href: &str, base: &Base) -> Option<String> {
    let href = href.trim();
    if href.is_empty() || href.starts_with('#') || href.starts_with("//") {
        return None;
    }
    if let Some((scheme, _)) = href.split_once(':') {
        if !scheme.contains(['/', '?', '#']) {
            // An allow-list, for the reason docbook's `followable` gives: a
            // browser reads more spellings of `javascript:` than a deny-list
            // would remember.
            return match scheme.to_ascii_lowercase().as_str() {
                "http" | "https" | "mailto" => None,
                _ => Some("#".to_owned()),
            };
        }
    }

    let (href, fragment) = match href.split_once('#') {
        Some((before, after)) => (before, Some(after)),
        None => (href, None),
    };
    let path = tree_path(href, base);

    let ns = utf8_percent_encode(base.namespace, VALUE);
    let name = utf8_percent_encode(base.name, VALUE);
    let mut url = if is_raw_kind(&path) {
        format!(
            "/api/repos/{ns}/{name}/raw?path={}",
            utf8_percent_encode(&path, PATH_VALUE)
        )
    } else if path.is_empty() {
        format!("/{ns}/{name}/tree")
    } else {
        // GUN-GUEST (owner 2026-10-30): the console's one address scheme, the path in the
        // URL's path (`tree/` takes a directory or a file alike).
        format!("/{ns}/{name}/tree/{}", utf8_percent_encode(&path, PATH_VALUE))
    };
    if !base.reference.is_empty() {
        url.push(if url.contains('?') { '&' } else { '?' });
        url.push_str("rev=");
        url.extend(utf8_percent_encode(base.reference, VALUE));
    }
    if let Some(fragment) = fragment.filter(|f| !f.is_empty()) {
        url.push('#');
        url.extend(utf8_percent_encode(fragment, VALUE));
    }
    Some(url)
}

/// A link's target as a path in the tree, query and fragment already gone.
fn tree_path(href: &str, base: &Base) -> String {
    // A query on a path into a tree means nothing to the tree; GitHub's
    // `?raw=true` is the usual one, and the raw route is what it asks for.
    let href = href.split_once('?').map_or(href, |(before, _)| before);
    let decoded = percent_decode_str(href).decode_utf8_lossy();
    // **Relative to the DOCUMENT, root-relative only when it says so.** A
    // leading `/` is the author writing a path from the tree root and is left
    // alone; everything else is joined to the directory the document is in
    // before the dot segments are applied, so `../docs/a.md` from `.nornir/`
    // lands on `docs/a.md` and never outside the tree.
    if decoded.starts_with('/') || base.directory.is_empty() {
        normalise(&decoded)
    } else {
        normalise(&format!("{}/{decoded}", base.directory))
    }
}

/// Where one picture's bytes are on this console, or `None` to leave its
/// source as written. Always the raw route: the file view is a page, and a
/// browser asked for a picture draws nothing from a page.
pub fn resolve_picture(src: &str, base: &Base) -> Option<String> {
    let src = src.trim();
    if src.is_empty() || src.starts_with('#') || src.starts_with("//") {
        return None;
    }
    if let Some((scheme, _)) = src.split_once(':') {
        if !scheme.contains(['/', '?', '#']) {
            // `data:` is what typst writes for a picture the page carries (the
            // sanitiser has already taken any a README wrote into the HTML
            // grade). `http(s)` is another host's, and the console's
            // `img-src` decides it.
            return match scheme.to_ascii_lowercase().as_str() {
                "http" | "https" | "data" => None,
                _ => Some("#".to_owned()),
            };
        }
    }
    let path = tree_path(src.split_once('#').map_or(src, |(before, _)| before), base);
    if path.is_empty() {
        return None;
    }
    let mut url = format!(
        "/api/repos/{}/{}/raw?path={}",
        utf8_percent_encode(base.namespace, VALUE),
        utf8_percent_encode(base.name, VALUE),
        utf8_percent_encode(&path, PATH_VALUE)
    );
    if !base.reference.is_empty() {
        url.push_str("&rev=");
        url.extend(utf8_percent_encode(base.reference, VALUE));
    }
    Some(url)
}

/// A link's path as a path in the tree: `./` dropped, `..` applied, and a
/// leading `/` meaning the tree root. A `..` past the root stays at the root:
/// the link cannot leave the repository, and the tree's own listing is the most
/// useful place for it to land.
///
/// Public because the IMAGE fetch resolves destinations the same way
/// ([`crate::api::render`]), and two answers to "where does this relative path
/// point" would mean a picture drawn from one blob and linked to another.
pub fn normalise(path: &str) -> String {
    let mut segments: Vec<&str> = Vec::new();
    for segment in path.split('/') {
        match segment {
            "" | "." => {}
            ".." => {
                segments.pop();
            }
            _ => segments.push(segment),
        }
    }
    segments.join("/")
}

/// True for a file the browser shows by itself, which `api::browse::raw`
/// serves inline. The list is `RawKind`'s, so what a link promises and what the
/// route does cannot drift apart. Anything else goes to the console's file
/// view, where source is highlighted and a binary is named as one.
fn is_raw_kind(path: &str) -> bool {
    // A Markdown or text file is served as text too (GUN-LANDING), but its link goes to the
    // file view, where Markdown is read as the console draws it.
    !matches!(RawKind::of(path), RawKind::Download | RawKind::Text)
}

/// Undo the escaping both writers apply to an attribute value.
fn unescape(value: &str) -> Cow<'_, str> {
    if !value.contains('&') {
        return Cow::Borrowed(value);
    }
    let mut out = String::with_capacity(value.len());
    let mut rest = value;
    while let Some(at) = rest.find('&') {
        out.push_str(&rest[..at]);
        rest = &rest[at..];
        let entity = rest.find(';').map(|end| &rest[1..end]);
        let decoded = entity.and_then(|e| match e {
            "amp" => Some('&'),
            "lt" => Some('<'),
            "gt" => Some('>'),
            "quot" => Some('"'),
            "apos" => Some('\''),
            "nbsp" => Some('\u{a0}'),
            _ => e
                .strip_prefix("#x")
                .or_else(|| e.strip_prefix("#X"))
                .and_then(|hex| u32::from_str_radix(hex, 16).ok())
                .or_else(|| e.strip_prefix('#').and_then(|dec| dec.parse().ok()))
                .and_then(char::from_u32),
        });
        match (entity, decoded) {
            (Some(entity), Some(c)) => {
                out.push(c);
                rest = &rest[entity.len() + 2..];
            }
            _ => {
                out.push('&');
                rest = &rest[1..];
            }
        }
    }
    out.push_str(rest);
    Cow::Owned(out)
}

/// Escape a value for a double-quoted attribute, in either document.
fn escape(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for c in value.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '"' => out.push_str("&quot;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    out
}

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

    const BASE: Base<'static> = Base {
        namespace: "gunnar",
        name: "gunnar",
        reference: "",
        directory: "",
    };

    fn at(href: &str) -> Option<String> {
        resolve(href, &BASE)
    }

    /// The same repository, rendering a document that lives in `.nornir/`.
    const IN_NORNIR: Base<'static> = Base {
        namespace: "gunnar",
        name: "gunnar",
        reference: "",
        directory: ".nornir",
    };

    fn from_nornir(href: &str) -> Option<String> {
        resolve(href, &IN_NORNIR)
    }

    /// **The link that was measured broken**: the gunnar README's PDF, which
    /// must open as the PDF rather than as a console page about it.
    #[test]
    fn a_pdf_beside_the_readme_opens_as_the_file() {
        assert_eq!(
            at("docs/gunnar-fast-setup.pdf").as_deref(),
            Some("/api/repos/gunnar/gunnar/raw?path=docs/gunnar-fast-setup.pdf")
        );
    }

    #[test]
    fn any_other_file_opens_in_the_console_s_file_view() {
        assert_eq!(
            at(".nornir/gunnar-quickstart.md").as_deref(),
            Some("/gunnar/gunnar/tree/.nornir/gunnar-quickstart.md")
        );
        assert_eq!(
            at("src/lib.rs#L10").as_deref(),
            Some("/gunnar/gunnar/tree/src/lib.rs#L10"),
            "a line anchor survives, and the file view has them"
        );
    }

    #[test]
    fn dot_segments_and_a_leading_slash_resolve_inside_the_tree() {
        let want = Some("/gunnar/gunnar/tree/docs/a.md".to_owned());
        assert_eq!(at("./docs/a.md"), want);
        assert_eq!(at("/docs/a.md"), want);
        assert_eq!(at("docs/x/../a.md"), want);
        // Past the root is the root, not somebody else's path.
        assert_eq!(at("../../docs/a.md"), want);
        assert_eq!(at("./").as_deref(), Some("/gunnar/gunnar/tree"));
    }

    /// ★ **A document that is not the README resolves against its own
    /// directory.**
    ///
    /// Red-first: with one base for the whole tree,
    /// `.nornir/gunnar-quickstart.md`'s link to `shot.png` resolved to
    /// `shot.png` at the root — a file view of something that is not there,
    /// reached by a click that used to work on the git host the document was
    /// written for.
    #[test]
    fn a_document_in_a_subdirectory_resolves_beside_itself() {
        assert_eq!(
            from_nornir("shot.png").as_deref(),
            Some("/api/repos/gunnar/gunnar/raw?path=.nornir/shot.png")
        );
        assert_eq!(
            from_nornir("gunnar-quickstart.md").as_deref(),
            Some("/gunnar/gunnar/tree/.nornir/gunnar-quickstart.md")
        );
        assert_eq!(
            from_nornir("./deeper/a.md").as_deref(),
            Some("/gunnar/gunnar/tree/.nornir/deeper/a.md")
        );
        // `..` climbs out of the document's directory and no further than the
        // root, which is the same clamp a root-relative link gets.
        assert_eq!(
            from_nornir("../docs/a.md").as_deref(),
            Some("/gunnar/gunnar/tree/docs/a.md")
        );
        assert_eq!(
            from_nornir("../../../../etc/passwd").as_deref(),
            Some("/gunnar/gunnar/tree/etc/passwd"),
            "a climb past the root lands in the root, never outside the repository"
        );
        // A leading slash still means the tree root, whatever directory the
        // document is in: that is what the author wrote.
        assert_eq!(
            from_nornir("/docs/a.md").as_deref(),
            Some("/gunnar/gunnar/tree/docs/a.md")
        );
        // …and the README's own base is unchanged by all of this.
        assert_eq!(
            at("shot.png").as_deref(),
            Some("/api/repos/gunnar/gunnar/raw?path=shot.png")
        );
    }

    #[test]
    fn external_links_and_fragments_are_left_as_written() {
        for keep in [
            "https://gunnar.rs",
            "http://example.test/a.pdf",
            "mailto:a@example.test",
            "//cdn.example.test/x",
            "#usage",
            "",
        ] {
            assert_eq!(at(keep), None, "{keep}");
        }
    }

    #[test]
    fn a_script_scheme_goes_nowhere() {
        for bad in [
            "javascript:alert(1)",
            " JavaScript:x",
            "data:text/html,x",
            "vbscript:x",
        ] {
            assert_eq!(at(bad).as_deref(), Some("#"), "{bad:?}");
        }
    }

    #[test]
    fn a_query_is_dropped_and_raw_true_still_lands_on_the_bytes() {
        assert_eq!(
            at("docs/logo.png?raw=true").as_deref(),
            Some("/api/repos/gunnar/gunnar/raw?path=docs/logo.png")
        );
    }

    #[test]
    fn names_are_encoded_once_and_decoded_links_are_not_double_encoded() {
        let base = Base::at_root("ns x", "a&b", "feature/one");
        assert_eq!(
            resolve("docs/my%20guide.pdf", &base).as_deref(),
            Some("/api/repos/ns%20x/a%26b/raw?path=docs/my%20guide.pdf&rev=feature%2Fone")
        );
        assert_eq!(
            resolve("docs/", &base).as_deref(),
            Some("/ns%20x/a%26b/tree/docs?rev=feature%2Fone")
        );
    }

    #[test]
    fn the_raw_kinds_are_the_raw_route_s() {
        assert!(is_raw_kind("docs/a.PDF"));
        assert!(is_raw_kind("logo.webp"));
        assert!(!is_raw_kind("docs/a.pdf.md"));
        assert!(
            !is_raw_kind("page.html"),
            "a page that would run script is not"
        );
        assert!(
            !is_raw_kind("drawing.svg"),
            "and neither is a drawing that would"
        );
        assert!(!is_raw_kind("Makefile"));
    }

    /// The SVG shape, as docbook writes it: typst's own `<a>` under the text
    /// and the lifted one above it, both with both attributes.
    #[test]
    fn both_attributes_on_both_svg_anchors_are_rewritten() {
        let page = "<svg><g><a transform=\"matrix(1 0 0 1 2 3)\" href=\"docs/g.pdf\" \
                    xlink:href=\"docs/g.pdf\"><rect/></a><use xlink:href=\"#g1\"/></g>\
                    <g class=\"link-layer\"><a href=\"docs/g.pdf\" xlink:href=\"docs/g.pdf\">\
                    <rect/></a></g></svg>";

        let out = rewrite(page, &BASE);

        let want = "/api/repos/gunnar/gunnar/raw?path=docs/g.pdf";
        assert_eq!(out.matches(&format!("href=\"{want}\"")).count(), 4, "{out}");
        assert!(!out.contains("\"docs/g.pdf\""), "{out}");
        // A glyph reference is not a link and is not touched.
        assert!(out.contains("<use xlink:href=\"#g1\"/>"), "{out}");
    }

    /// The HTML shape, as ammonia writes it — with a `>` inside a value, which
    /// html5ever does not escape, and an entity that must round-trip.
    #[test]
    fn an_html_anchor_keeps_its_other_attributes_and_its_text() {
        let page = "<p>See <a href=\"docs/a.md?x=1&amp;y=2\" title=\"a > b\" \
                    rel=\"noopener noreferrer external\">the <abbr>doc</abbr></a>.</p>";

        let out = rewrite(page, &BASE);

        assert_eq!(
            out,
            "<p>See <a href=\"/gunnar/gunnar/tree/docs/a.md\" title=\"a > b\" \
             rel=\"noopener noreferrer external\">the <abbr>doc</abbr></a>.</p>"
        );
    }

    #[test]
    fn a_lookalike_attribute_is_not_a_target() {
        let page = "<a data-href=\"docs/a.pdf\" title=\"x href=&quot;y\" href=\"https://e.test\">";

        assert_eq!(rewrite(page, &BASE), page);
    }

    #[test]
    fn an_entity_in_a_link_round_trips() {
        assert_eq!(
            unescape("a&amp;b&#x41;&#66;&quot;&bogus;&"),
            "a&bAB\"&bogus;&"
        );
        assert_eq!(escape("a&\"<>"), "a&amp;&quot;&lt;&gt;");
    }

    /// ★ **nordisk/knut on gunnar.rs (2026-10-15)**: a README's raw `<img>`
    /// kept `src="assets/…png"`, which the browser resolved against
    /// `/repo/nordisk/knut` — a console page, not the picture.
    #[test]
    fn an_html_img_points_at_the_raw_route() {
        let page = "<p align=\"center\"><img src=\"assets/generated_knut_iceberg.png\" \
                    alt=\"knut\" width=\"560\"></p>";
        let base = Base::at_root("nordisk", "knut", "");

        assert_eq!(
            rewrite(page, &base),
            "<p align=\"center\"><img src=\"/api/repos/nordisk/knut/raw?path=assets/\
             generated_knut_iceberg.png\" alt=\"knut\" width=\"560\"></p>"
        );
    }

    /// The typeset grade's picture is an SVG `<image>`, both attributes; a
    /// stand-in's tree path from the root, at a revision, from a document in
    /// a subdirectory.
    #[test]
    fn an_svg_image_points_at_the_raw_route_with_the_revision() {
        let base = Base {
            namespace: "gunnar",
            name: "gunnar",
            reference: "feature/one",
            directory: ".nornir",
        };
        let page = "<svg><image href=\"/assets/a b.png\" xlink:href=\"shot.png\" width=\"10\"/>\
                    <use xlink:href=\"#g1\"/></svg>";

        assert_eq!(
            rewrite(page, &base),
            "<svg><image href=\"/api/repos/gunnar/gunnar/raw?path=assets/a%20b.png&amp;rev=feature%2Fone\" \
             xlink:href=\"/api/repos/gunnar/gunnar/raw?path=.nornir/shot.png&amp;rev=feature%2Fone\" \
             width=\"10\"/><use xlink:href=\"#g1\"/></svg>"
        );
    }

    /// A picture the page carries stays carried, another host's stays as
    /// written (the console's `img-src` refuses it), and a script scheme
    /// goes nowhere. A picture never lands on the file view, whatever it is.
    #[test]
    fn a_picture_s_source_is_left_alone_unless_it_is_in_the_tree() {
        for keep in [
            "data:image/png;base64,AAAA",
            "https://img.shields.io/badge/x.svg",
            "//cdn.example.test/x.png",
            "",
        ] {
            assert_eq!(resolve_picture(keep, &BASE), None, "{keep}");
        }
        assert_eq!(resolve_picture("javascript:x", &BASE).as_deref(), Some("#"));
        assert_eq!(
            resolve_picture("docs/badge.svg?raw=true", &BASE).as_deref(),
            Some("/api/repos/gunnar/gunnar/raw?path=docs/badge.svg")
        );
        let page = "<img alt=\"x\" src=\"data:image/png;base64,AAAA\"><imagex href=\"a.png\">";
        assert_eq!(rewrite(page, &BASE), page);
    }

    #[test]
    fn a_page_with_no_anchor_is_unchanged() {
        let page = "<svg><animate/><abbr>x</abbr><g>text</g></svg>";
        assert_eq!(rewrite(page, &BASE), page);
    }
}