crates/nornir-docgen/src/site.rs
//! **Static site** — `.nornir/site/*.md` rendered to one `<page>.html` each.
//!
//! The doc book's other shape. A book is A4 sheets; a site page is the gunnar.rs
//! front page: a fixed picture owning the left half, and the typeset text owning
//! the right. The text is the same typst pipeline the book uses, through
//! `column.typ` rather than `report.typ` — one tall page at a web width, on a
//! transparent ground, with the selectable text layer and clickable links
//! [`nornir_docbook::export_svg_column`] already puts over the glyphs. The HTML
//! around it carries only what an SVG cannot: title, description, canonical and
//! OG tags, the picture, and the page list.
//!
//! ```text
//! .nornir/site/
//! index.md → index.html (the first page; its H1 is the site title)
//! <name>.md → <name>.html
//! <name>.<ll>.md → <name>.<ll>.html (the same page in language `ll`)
//! lang → the languages in switcher order (`da sv fi is`); the first is the
//! plain `<name>.md` pages' own; absent = `en`
//! flag-<ll>.<ext> → that language's flag in the switcher (else its code)
//! *.ttf/otf/ttc/otc → extra font faces for the typesetter (e.g. CJK); not published
//! art.<ext> → the left-half picture (webp/png/jpg/svg), copied as-is
//! icon.<ext> → the favicon, copied as-is
//! anything else → copied as-is (and offered to the markdown as an image)
//! ```
//!
//! With more than one language every page carries a switcher in its top right
//! corner: one link per language, to the same page in that language (or to that
//! language's front page when the page is not translated). The page list only
//! names pages of the page's own language.
//!
//! The canonical base URL comes from a `CNAME` file at the repo root (GitHub/
//! Codeberg Pages), so a site published from `docs/` needs no config of its own.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use nornir_docbook::{ExportMeta, ImageSet, TextLayer, export_svg_column_in};
/// Column measure on a desktop, in points. The right half of a 1440-wide screen
/// is ~630 CSS px, so 11 pt body text lands at ~16 px.
pub const WIDE_PT: f64 = 440.0;
/// Column measure on a phone. A 390-wide phone column is ~355 CSS px, so 11 pt
/// body text lands at ~14 px. Below [`NARROW_BREAK_PX`] this one is shown.
pub const NARROW_PT: f64 = 280.0;
/// Viewport width (CSS px) at and below which the layout is one column.
pub const NARROW_BREAK_PX: u32 = 720;
/// Ground used when the picture is missing or cannot be decoded.
const FALLBACK_GROUND: [u8; 3] = [0xf4, 0xf4, 0xf4];
/// One output file, relative to the site's output directory.
#[derive(Debug, Clone)]
pub struct SiteFile {
pub path: PathBuf,
pub bytes: Vec<u8>,
}
struct Page {
/// The file stem, language suffix included (`index.sv`); the output is `<stem>.html`.
stem: String,
/// The stem without the language suffix (`index`) — what translations share.
base: String,
lang: String,
title: String,
description: String,
markdown: String,
}
/// Build every file of the site from `<repo_root>/.nornir/site/`.
pub fn build_site(repo_root: &Path) -> Result<Vec<SiteFile>> {
let dir = repo_root.join(".nornir/site");
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.with_context(|| format!("read {} — create `.nornir/site/index.md` first", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_file())
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!name.starts_with('.') && !name.ends_with('~')
})
.collect();
entries.sort();
// `lang`: the languages in switcher order; the first is the plain pages' own.
let lang_order: Vec<String> = std::fs::read_to_string(dir.join("lang"))
.unwrap_or_default()
.split_whitespace()
.filter(|l| lang_code(l))
.map(str::to_string)
.collect();
let default_lang = lang_order.first().cloned().unwrap_or_else(|| "en".to_string());
let mut pages = Vec::new();
let mut assets: Vec<(String, Vec<u8>)> = Vec::new();
for path in &entries {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
if name == "lang" {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("md") {
let markdown = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let (base, lang) = match stem.rsplit_once('.') {
Some((b, l)) if lang_code(l) => (b.to_string(), l.to_string()),
_ => (stem.clone(), default_lang.clone()),
};
pages.push(Page {
title: first_heading(&markdown).unwrap_or_else(|| stem.clone()),
description: first_paragraph(&markdown),
stem,
base,
lang,
markdown,
});
} else {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
assets.push((name, bytes));
}
}
// `index` first, the rest in name order.
pages.sort_by_key(|p| (p.stem != "index", p.stem.clone()));
if pages.first().map(|p| p.stem.as_str()) != Some("index") {
return Err(anyhow!("no `.nornir/site/index.md` — the site needs a front page"));
}
let art = asset_named(&assets, "art");
let icon = asset_named(&assets, "icon");
let ground = art
.and_then(|a| right_edge_mean(&assets.iter().find(|(n, _)| n == a)?.1))
.unwrap_or(FALLBACK_GROUND);
let base = std::fs::read_to_string(repo_root.join("CNAME"))
.ok()
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.map(|c| format!("https://{c}/"));
let mut images = ImageSet::new();
for (name, bytes) in &assets {
if is_font(name) {
if images.insert_font(bytes.clone()) == 0 {
return Err(anyhow!("{name}: not a font the typesetter can read"));
}
continue;
}
// Not an image typst can place → `insert` keeps nothing; that is fine.
images.insert(name, bytes.clone());
}
// The default language first, then `lang`'s order, then the rest in code order.
let mut langs: Vec<String> = pages.iter().map(|p| p.lang.clone()).collect();
langs.sort_by_key(|l| (*l != default_lang, lang_order.iter().position(|o| o == l).unwrap_or(usize::MAX), l.clone()));
langs.dedup();
let mut out = Vec::new();
for page in &pages {
let same_lang = || pages.iter().filter(|p| p.lang == page.lang);
let site_title = same_lang()
.find(|p| p.base == "index")
.map(|p| p.title.clone())
.unwrap_or_else(|| pages[0].title.clone());
let nav: Vec<(String, String)> = same_lang().map(|p| (href(&p.stem), p.title.clone())).collect();
// One switcher entry per language: the same page in it, else its front page.
let switch: Vec<LangLink> = if langs.len() > 1 {
langs
.iter()
.filter_map(|l| {
let target = pages
.iter()
.find(|p| p.lang == *l && p.base == page.base)
.or_else(|| pages.iter().find(|p| p.lang == *l && p.base == "index"))?;
Some(LangLink {
lang: l.clone(),
href: href(&target.stem),
flag: asset_named(&assets, &format!("flag-{l}")).map(str::to_string),
current: *l == page.lang,
})
})
.collect()
} else {
Vec::new()
};
let site_title = site_title.as_str();
let meta = ExportMeta { title: page.title.clone(), ..ExportMeta::default() };
let wide = export_svg_column_in(&page.markdown, &meta, &images, WIDE_PT, TextLayer::On, Some("none"), Some(&page.lang))
.with_context(|| format!("typeset {}.md (wide)", page.stem))?;
let narrow = export_svg_column_in(&page.markdown, &meta, &images, NARROW_PT, TextLayer::On, Some("none"), Some(&page.lang))
.with_context(|| format!("typeset {}.md (narrow)", page.stem))?;
let html = page_html(&PageShell {
site_title,
page,
base: base.as_deref(),
art,
icon,
ground,
nav: if nav.len() > 1 { &nav } else { &[] },
switch: &switch,
wide: &prefix_ids(&wide, "w-"),
narrow: &prefix_ids(&narrow, "n-"),
});
out.push(SiteFile { path: PathBuf::from(format!("{}.html", page.stem)), bytes: html.into_bytes() });
}
// A font only sets the text; the SVG carries the glyphs as paths, so the
// font itself is never published.
for (name, bytes) in assets.into_iter().filter(|(n, _)| !is_font(n)) {
out.push(SiteFile { path: PathBuf::from(name), bytes });
}
Ok(out)
}
fn href(stem: &str) -> String {
if stem == "index" { "./".to_string() } else { format!("{stem}.html") }
}
/// A font file by its extension (`.ttf .otf .ttc .otc`).
fn is_font(name: &str) -> bool {
matches!(
Path::new(name).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(),
Some("ttf" | "otf" | "ttc" | "otc")
)
}
/// A language code as a file suffix: two or three lower-case ASCII letters.
fn lang_code(s: &str) -> bool {
(2..=3).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_lowercase())
}
/// One entry of the language switcher.
struct LangLink {
lang: String,
href: String,
flag: Option<String>,
current: bool,
}
/// The asset whose file stem is `stem` (`art.webp` for `"art"`).
fn asset_named<'a>(assets: &'a [(String, Vec<u8>)], stem: &str) -> Option<&'a str> {
assets
.iter()
.map(|(n, _)| n.as_str())
.find(|n| Path::new(n).file_stem().and_then(|s| s.to_str()) == Some(stem))
}
/// Mean colour of the picture's rightmost 4 px columns — the edge that meets the
/// text half. One colour cannot match a column that moves down the edge, so the
/// shell also fades the last 90 px of the picture into this ground; the mean
/// only makes that fade the shortest possible. `None` for an SVG or anything
/// the decoder does not know.
fn right_edge_mean(bytes: &[u8]) -> Option<[u8; 3]> {
let img = image::load_from_memory(bytes).ok()?.to_rgb8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return None;
}
let mut sum = [0u64; 3];
let mut n = 0u64;
for x in w.saturating_sub(4)..w {
for y in 0..h {
let p = img.get_pixel(x, y).0;
for c in 0..3 {
sum[c] += p[c] as u64;
}
n += 1;
}
}
Some([(sum[0] / n) as u8, (sum[1] / n) as u8, (sum[2] / n) as u8])
}
/// Two inline SVGs from the same typesetter share glyph ids, and `<use>` resolves
/// against the whole HTML document — so each copy gets its own id namespace.
fn prefix_ids(svg: &str, prefix: &str) -> String {
svg.replace("id=\"", &format!("id=\"{prefix}"))
.replace("href=\"#", &format!("href=\"#{prefix}"))
.replace("url(#", &format!("url(#{prefix}"))
}
fn first_heading(md: &str) -> Option<String> {
md.lines()
.find_map(|l| l.strip_prefix("# "))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
/// First paragraph that is not a heading, as plain text, cut at 160 chars —
/// the `<meta name="description">`.
fn first_paragraph(md: &str) -> String {
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
let mut out = String::new();
let mut inside = false;
for ev in Parser::new(md) {
match ev {
Event::Start(Tag::Paragraph) => inside = true,
Event::End(TagEnd::Paragraph) if !out.trim().is_empty() => break,
Event::End(TagEnd::Paragraph) => inside = false,
Event::Text(t) | Event::Code(t) if inside => out.push_str(&t),
Event::SoftBreak | Event::HardBreak if inside => out.push(' '),
_ => {}
}
}
let out = out.split_whitespace().collect::<Vec<_>>().join(" ");
match out.char_indices().nth(160) {
Some((i, _)) => format!("{}…", &out[..i]),
None => out,
}
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
struct PageShell<'a> {
site_title: &'a str,
page: &'a Page,
base: Option<&'a str>,
art: Option<&'a str>,
icon: Option<&'a str>,
ground: [u8; 3],
nav: &'a [(String, String)],
switch: &'a [LangLink],
wide: &'a str,
narrow: &'a str,
}
fn page_html(s: &PageShell) -> String {
let [r, g, b] = s.ground;
let title = if s.page.base == "index" {
esc(s.site_title)
} else {
format!("{} — {}", esc(&s.page.title), esc(s.site_title))
};
let desc = esc(&s.page.description);
let mut head = String::new();
if let Some(base) = s.base {
let url = if s.page.stem == "index" { base.to_string() } else { format!("{base}{}.html", s.page.stem) };
head.push_str(&format!(
"<link rel=\"canonical\" href=\"{u}\">\n<meta property=\"og:type\" content=\"website\">\n\
<meta property=\"og:url\" content=\"{u}\">\n<meta property=\"og:title\" content=\"{title}\">\n\
<meta property=\"og:description\" content=\"{desc}\">\n",
u = esc(&url)
));
if let Some(art) = s.art {
head.push_str(&format!("<meta property=\"og:image\" content=\"{}{}\">\n", esc(base), esc(art)));
}
for l in s.switch {
let u = if l.href == "./" { base.to_string() } else { format!("{base}{}", l.href) };
head.push_str(&format!("<link rel=\"alternate\" hreflang=\"{}\" href=\"{}\">\n", esc(&l.lang), esc(&u)));
}
}
if let Some(icon) = s.icon {
head.push_str(&format!("<link rel=\"icon\" href=\"{}\">\n", esc(icon)));
}
let art_css = match s.art {
Some(a) => format!("url(\"{}\")", esc(a)),
None => "none".to_string(),
};
let art_div = if s.art.is_some() { "<div class=\"art\" aria-hidden=\"true\"></div>\n" } else { "" };
let nav = if s.nav.is_empty() {
String::new()
} else {
let items: String = s
.nav
.iter()
.map(|(h, t)| {
let cur = if *h == href(&s.page.stem) { " aria-current=\"page\"" } else { "" };
format!("<a href=\"{}\"{cur}>{}</a>", esc(h), esc(t))
})
.collect::<Vec<_>>()
.join("\n");
format!("<nav>\n{items}\n</nav>\n")
};
let switch = if s.switch.is_empty() {
String::new()
} else {
let items: String = s
.switch
.iter()
.map(|l| {
let cur = if l.current { " aria-current=\"true\"" } else { "" };
let label = match &l.flag {
Some(f) => format!("<img src=\"{}\" alt=\"{}\">", esc(f), esc(&l.lang.to_uppercase())),
None => esc(&l.lang.to_uppercase()),
};
format!("<a href=\"{}\" hreflang=\"{}\" lang=\"{}\"{cur}>{label}</a>", esc(&l.href), esc(&l.lang), esc(&l.lang))
})
.collect::<Vec<_>>()
.join("<span aria-hidden=\"true\">|</span>");
format!("<div class=\"lang\">{items}</div>\n")
};
let lang = esc(&s.page.lang);
let brk = NARROW_BREAK_PX;
format!(
r#"<!doctype html>
<html lang="{lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="generator" content="dwarves docs book --format site">
<title>{title}</title>
<meta name="description" content="{desc}">
{head}<style>
:root {{ --bg: rgb({r} {g} {b}); --bg-rgb: {r} {g} {b}; --art: {art_css}; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; display: flex; min-height: 100dvh; background: var(--bg);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }}
.art {{ flex: 0 0 50%; position: sticky; top: 0; height: 100dvh; background-color: var(--bg);
background-image: linear-gradient(to right, rgb(var(--bg-rgb) / 0) calc(100% - 90px), rgb(var(--bg-rgb) / 1)), var(--art);
background-size: auto, cover; background-position: center; background-repeat: no-repeat; }}
.side {{ flex: 1 1 50%; min-width: 0; padding: clamp(.5rem, 2vw, 1.75rem); }}
.side svg {{ display: block; width: 100%; height: auto; }}
nav {{ display: flex; flex-wrap: wrap; gap: .4rem 1.1rem; padding: 0 24px .5rem; }}
nav a {{ color: #16191d; text-decoration: none; }}
nav a[aria-current] {{ text-decoration: underline; text-underline-offset: 4px; }}
.col-n {{ display: none; }}
.lang {{ position: fixed; top: .6rem; right: .8rem; z-index: 10; display: flex; align-items: center; gap: .4rem;
padding: .3rem .5rem; border-radius: 6px; background: rgb(var(--bg-rgb) / .85); color: #16191d; }}
.lang a {{ display: block; color: inherit; text-decoration: none; line-height: 0; opacity: .6; }}
.lang a:hover, .lang a[aria-current] {{ opacity: 1; }}
.lang img {{ display: block; width: auto; height: 20px; border-radius: 2px; box-shadow: 0 0 0 1px rgb(0 0 0 / .18); }}
.lang a[aria-current] img {{ outline: 2px solid #16191d; outline-offset: 2px; }}
@media (max-width: {brk}px) {{
body {{ display: block; }}
.art {{ position: relative; height: 45vh;
background-image: linear-gradient(to bottom, rgb(var(--bg-rgb) / 0) calc(100% - 60px), rgb(var(--bg-rgb) / 1)), var(--art); }}
.side {{ padding: .25rem; }}
.col-w {{ display: none; }}
.col-n {{ display: block; }}
}}
</style>
</head>
<body>
{switch}{art_div}<div class="side">
{nav}<main>
<div class="col-w">{wide}</div>
<div class="col-n">{narrow}</div>
</main>
</div>
</body>
</html>
"#,
wide = s.wide,
narrow = s.narrow,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_is_the_first_real_paragraph() {
let md = "# Title\n\nFirst *para*\nline two.\n\nSecond.\n";
assert_eq!(first_paragraph(md), "First para line two.");
assert_eq!(first_heading(md).as_deref(), Some("Title"));
}
#[test]
fn ids_and_their_references_move_together() {
let svg = r##"<g id="a"/><use href="#a"/><path clip-path="url(#a)"/>"##;
assert_eq!(prefix_ids(svg, "n-"), r##"<g id="n-a"/><use href="#n-a"/><path clip-path="url(#n-a)"/>"##);
}
#[test]
fn builds_a_two_page_site_with_assets() {
let tmp = std::env::temp_dir().join(format!("docgen-site-{}", std::process::id()));
let site = tmp.join(".nornir/site");
std::fs::create_dir_all(&site).unwrap();
std::fs::write(site.join("index.md"), "# Acme\n\nWe make things.\n").unwrap();
std::fs::write(site.join("about.md"), "# About\n\nMore.\n").unwrap();
std::fs::write(site.join("art.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
std::fs::write(tmp.join("CNAME"), "www.acme.test\n").unwrap();
let files = build_site(&tmp).unwrap();
let names: Vec<_> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
assert_eq!(names, ["index.html", "about.html", "art.svg"]);
let index = String::from_utf8(files[0].bytes.clone()).unwrap();
assert!(index.contains("<title>Acme</title>"));
assert!(index.contains("content=\"We make things.\""));
assert!(index.contains("href=\"https://www.acme.test/\""));
assert!(index.contains("href=\"about.html\""));
assert!(index.contains("url(\"art.svg\")"));
assert!(index.contains("<html lang=\"en\">"));
assert!(!index.contains("class=\"lang\""), "one language, no switcher");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_font_file_is_for_the_typesetter_and_a_broken_one_is_refused() {
assert!(is_font("NotoSerifCJK-Regular.ttc") && is_font("x.OTF") && !is_font("art.webp"));
let tmp = std::env::temp_dir().join(format!("docgen-site-font-{}", std::process::id()));
let site = tmp.join(".nornir/site");
std::fs::create_dir_all(&site).unwrap();
std::fs::write(site.join("index.md"), "# Acme\n\nText.\n").unwrap();
std::fs::write(site.join("broken.ttf"), b"not a font").unwrap();
let err = build_site(&tmp).unwrap_err().to_string();
assert!(err.contains("broken.ttf"), "{err}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_translated_front_page_gets_a_switcher_with_flags() {
let tmp = std::env::temp_dir().join(format!("docgen-site-lang-{}", std::process::id()));
let site = tmp.join(".nornir/site");
std::fs::create_dir_all(&site).unwrap();
std::fs::write(site.join("lang"), "da sv fi\n").unwrap();
std::fs::write(site.join("index.fi.md"), "# Acme\n\nTeemme asioita.\n").unwrap();
std::fs::write(site.join("index.md"), "# Acme\n\nVi laver ting.\n").unwrap();
std::fs::write(site.join("index.sv.md"), "# Acme\n\nVi gör saker.\n").unwrap();
std::fs::write(site.join("flag-da.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
std::fs::write(site.join("flag-sv.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
std::fs::write(tmp.join("CNAME"), "www.acme.test\n").unwrap();
let files = build_site(&tmp).unwrap();
let names: Vec<_> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
assert_eq!(names, ["index.html", "index.fi.html", "index.sv.html", "flag-da.svg", "flag-sv.svg"]);
let da = String::from_utf8(files[0].bytes.clone()).unwrap();
let sv = String::from_utf8(files[2].bytes.clone()).unwrap();
let (d, s, fi) = (da.find("hreflang=\"da\" lang").unwrap(), da.find("hreflang=\"sv\" lang").unwrap(), da.find("hreflang=\"fi\" lang").unwrap());
assert!(d < s && s < fi, "the switcher follows the lang file: da | sv | fi");
assert!(da.contains(">FI</a>"), "no flag-fi asset: the code stands in");
assert!(da.contains("<html lang=\"da\">") && sv.contains("<html lang=\"sv\">"));
for page in [&da, &sv] {
assert!(page.contains("<a href=\"./\" hreflang=\"da\""), "{page}");
assert!(page.contains("<a href=\"index.sv.html\" hreflang=\"sv\""));
assert!(page.contains("<img src=\"flag-da.svg\" alt=\"DA\">") && page.contains("<img src=\"flag-sv.svg\" alt=\"SV\">"));
assert!(page.contains("hreflang=\"sv\" href=\"https://www.acme.test/index.sv.html\""));
assert!(!page.contains("<nav>"), "one page per language, no page list");
}
assert!(da.contains("hreflang=\"da\" lang=\"da\" aria-current"));
assert!(sv.contains("hreflang=\"sv\" lang=\"sv\" aria-current"));
assert!(sv.contains("<title>Acme</title>") && sv.contains("content=\"Vi gör saker.\""));
assert!(!names.contains(&"lang".to_string()), "the lang file is config, not an asset");
let _ = std::fs::remove_dir_all(&tmp);
}
}