//! The HTML for every page, written with `maud`. //! //! maud templates are Rust code: `html! { p { (value) } }` produces //! `

value

`, and `(value)` is always HTML-escaped. The only places we //! insert pre-built HTML are wrapped in `PreEscaped(...)`, and those strings //! come from our Markdown renderer or syntax highlighter, which escape for us. use maud::{html, Markup, PreEscaped, DOCTYPE}; use crate::git::{CommitDiff, CommitInfo, EntryKind, LineKind, RefInfo, TreeEntry}; use crate::util::{encode_path, encode_query}; /// Which tab is highlighted in the repo menu. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Tab { Summary, Refs, Log, Tree, Commit, } /// Information shown at the top of every per-repo page. pub struct RepoHeader<'a> { pub site_title: &'a str, pub name: &'a str, pub description: &'a str, pub tab: Tab, /// The `?h=` ref currently being browsed, if the user picked one. pub rev: Option<&'a str>, } impl RepoHeader<'_> { /// "?h=branch" (or "") to keep the selected branch when following links. fn h_query(&self) -> String { match self.rev { Some(r) => format!("?h={}", encode_query(r)), None => String::new(), } } } /// The outer HTML shell shared by all pages. fn layout(title: &str, site_title: &str, body: Markup) -> Markup { html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; meta name="color-scheme" content="light dark"; title { (title) } link rel="stylesheet" href="/static/style.css"; link rel="stylesheet" href="/static/highlight.css"; } body { header.site { a.site-title href="/" { (site_title) } } main { (body) } footer { "generated by forge v" (env!("CARGO_PKG_VERSION")) } } } } } fn repo_layout(h: &RepoHeader, title: &str, body: Markup) -> Markup { let base = format!("/{}", h.name); let q = h.h_query(); let tab = |t: Tab| if h.tab == t { "active" } else { "" }; let inner = html! { div.repo-head { h1 { a href={ (base) "/" } { (h.name) } } @if !h.description.is_empty() { p.desc { (h.description) } } } nav.tabs { a class=(tab(Tab::Summary)) href={ (base) "/" } { "summary" } a class=(tab(Tab::Refs)) href={ (base) "/refs" } { "refs" } a class=(tab(Tab::Log)) href={ (base) "/log" (q) } { "log" } a class=(tab(Tab::Tree)) href={ (base) "/tree" (q) } { "tree" } @if h.tab == Tab::Commit { a.active href="#" { "commit" } } @if let Some(r) = h.rev { span.branch { "ref: " code { (r) } } } } (body) }; layout(&format!("{title} - {}", h.name), h.site_title, inner) } // ------------------------------------------------------------------ index -- pub struct IndexRow { pub name: String, pub description: String, pub idle: Option, } pub fn index(site_title: &str, rows: &[IndexRow]) -> Markup { let body = html! { table.list { thead { tr { th { "Name" } th { "Description" } th { "Idle" } } } tbody { @for r in rows { tr { td { a href={ "/" (r.name) "/" } { (r.name) } } td { (r.description) } td.age { (r.idle.as_deref().unwrap_or("empty")) } } } @if rows.is_empty() { tr { td colspan="3" { "No repositories configured." } } } } } }; layout(site_title, site_title, body) } // ---------------------------------------------------------------- summary -- fn commit_rows(repo: &str, commits: &[CommitInfo]) -> Markup { html! { table.list { thead { tr { th { "Age" } th { "Commit message" } th { "Author" } th { "Id" } } } tbody { @for c in commits { tr { td.age title=(c.date()) { (c.age()) } td { a href={ "/" (repo) "/commit/" (c.id) } { (c.summary) } } td { (c.author_name) } td { code { a href={ "/" (repo) "/commit/" (c.id) } { (c.short_id) } } } } } } } } } fn ref_rows(repo: &str, heading: &str, refs: &[RefInfo]) -> Markup { html! { table.list { thead { tr { th { (heading) } th { "Commit message" } th { "Author" } th { "Age" } } } tbody { @for r in refs { tr { td { a href={ "/" (repo) "/log?h=" (encode_query(&r.name)) } { (r.name) } } td { a href={ "/" (repo) "/commit/" (r.commit.id) } { (r.commit.summary) } } td { (r.commit.author_name) } td.age title=(r.commit.date()) { (r.commit.age()) } } } @if refs.is_empty() { tr { td colspan="4" { "none" } } } } } } } pub struct Summary<'a> { pub clone_url: String, pub branches: &'a [RefInfo], pub tags: &'a [RefInfo], pub commits: &'a [CommitInfo], /// (file name, already-rendered safe HTML) pub readme: Option<(String, String)>, } pub fn summary(h: &RepoHeader, s: &Summary) -> Markup { let body = html! { div.clone { "clone: " code { "git clone " (s.clone_url) } } (ref_rows(h.name, "Branch", s.branches)) @if !s.tags.is_empty() { (ref_rows(h.name, "Tag", s.tags)) } h2 { "Recent commits" } (commit_rows(h.name, s.commits)) @if let Some((name, readme_html)) = &s.readme { section.readme { h2 { (name) } div.markdown { (PreEscaped(readme_html)) } } } }; repo_layout(h, "summary", body) } pub fn refs(h: &RepoHeader, branches: &[RefInfo], tags: &[RefInfo]) -> Markup { let body = html! { (ref_rows(h.name, "Branch", branches)) (ref_rows(h.name, "Tag", tags)) }; repo_layout(h, "refs", body) } // -------------------------------------------------------------------- log -- pub fn log(h: &RepoHeader, commits: &[CommitInfo], offset: usize, page_size: usize, more: bool) -> Markup { let base = format!("/{}/log", h.name); let h_param = h.rev.map(|r| format!("h={}&", encode_query(r))).unwrap_or_default(); let body = html! { (commit_rows(h.name, commits)) nav.pager { @if offset > 0 { a href={ (base) "?" (h_param) "ofs=" (offset.saturating_sub(page_size)) } { "« newer" } } @if more { a href={ (base) "?" (h_param) "ofs=" (offset + page_size) } { "older »" } } } }; repo_layout(h, "log", body) } // ----------------------------------------------------------------- commit -- pub fn commit(h: &RepoHeader, c: &CommitInfo, diff: &CommitDiff) -> Markup { let repo = h.name; let adds: usize = diff.files.iter().map(|f| f.additions).sum(); let dels: usize = diff.files.iter().map(|f| f.deletions).sum(); let body = html! { table.commit-info { tr { th { "author" } td { (c.author_name) " <" (c.author_email) ">" } td.age { (c.date()) } } tr { th { "commit" } td colspan="2" { code { (c.id) } } } @for p in &c.parents { tr { th { "parent" } td colspan="2" { code { a href={ "/" (repo) "/commit/" (p) } { (p) } } } } } tr { th { "tree" } td colspan="2" { a href={ "/" (repo) "/tree?h=" (c.id) } { "browse files at this commit" } } } } pre.commit-msg { (c.message.trim_end()) } table.diffstat { @for f in &diff.files { tr { td.status { (f.status) } td { a href={ "#" (anchor(&f.new_path)) } { (display_path(f.status, &f.old_path, &f.new_path)) } } td.num { span.add-n { "+" (f.additions) } " " span.del-n { "-" (f.deletions) } } } } } p.diffsum { (plural(diff.files.len(), "file")) " changed, " (plural(adds, "insertion")) ", " (plural(dels, "deletion")) } @for f in &diff.files { div.file id=(anchor(&f.new_path)) { div.file-head { @if f.status == 'D' { (f.old_path) } @else { a href={ "/" (repo) "/blob/" (encode_path(&f.new_path)) "?h=" (c.id) } { (display_path(f.status, &f.old_path, &f.new_path)) } } } @if f.binary { p.note { "Binary file changed." } } @else { pre.diff { @for l in &f.lines { @match l.kind { LineKind::HunkHeader => span.hunk { (l.text) }, LineKind::Add => span.add { "+" (l.text) }, LineKind::Remove => span.del { "-" (l.text) }, LineKind::Context => span.ctx { " " (l.text) }, } } } } } } @if diff.truncated { p.note { "Diff truncated: this commit is too large to show in full." } } }; repo_layout(h, &format!("commit {}", c.short_id), body) } /// "1 file" / "3 files" fn plural(n: usize, word: &str) -> String { if n == 1 { format!("1 {word}") } else { format!("{n} {word}s") } } fn display_path(status: char, old: &str, new: &str) -> String { if status == 'R' && old != new { format!("{old} → {new}") } else { new.to_string() } } /// HTML id for a file section: "diff-" + the path with odd characters replaced. fn anchor(path: &str) -> String { let safe: String = path.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); format!("diff-{safe}") } // ------------------------------------------------------------ tree / blob -- /// "repo / src / main.rs" navigation links. fn breadcrumbs(h: &RepoHeader, path: &str) -> Markup { let q = h.h_query(); // Build ("src", "src"), ("main.rs", "src/main.rs") pairs up front. let mut crumbs: Vec<(&str, String)> = Vec::new(); let mut so_far = String::new(); for part in path.split('/').filter(|p| !p.is_empty()) { if !so_far.is_empty() { so_far.push('/'); } so_far.push_str(part); crumbs.push((part, so_far.clone())); } html! { div.path { a href={ "/" (h.name) "/tree" (q) } { (h.name) } @for (name, full) in &crumbs { " / " a href={ "/" (h.name) "/tree/" (encode_path(full)) (q) } { (name) } } } } } fn mode_string(kind: &EntryKind, mode: i32) -> &'static str { match kind { EntryKind::Dir => "d---------", EntryKind::Symlink => "l---------", EntryKind::Submodule => "m---------", EntryKind::File if mode & 0o111 != 0 => "-rwxr-xr-x", EntryKind::File => "-rw-r--r--", } } pub fn tree(h: &RepoHeader, path: &str, entries: &[TreeEntry]) -> Markup { let q = h.h_query(); let body = html! { (breadcrumbs(h, path)) table.list.tree { thead { tr { th { "Mode" } th { "Name" } th.num { "Size" } th {} } } tbody { @for e in entries { tr { td.mode { code { (mode_string(&e.kind, e.mode)) } } @match e.kind { EntryKind::Dir => td { a.dir href={ "/" (h.name) "/tree/" (encode_path(&e.path)) (q) } { (e.name) "/" } }, EntryKind::Submodule => td { (e.name) " (submodule)" }, _ => td { a href={ "/" (h.name) "/blob/" (encode_path(&e.path)) (q) } { (e.name) } }, } td.num { @if let Some(s) = e.size { (s) } } td { @if e.kind == EntryKind::File { a.small href={ "/" (h.name) "/raw/" (encode_path(&e.path)) (q) } { "raw" } } } } } } } }; repo_layout(h, path, body) } pub enum BlobBody { /// Highlighted, already-escaped HTML and the number of lines. Text { html: String, lines: usize, }, Binary, TooLarge, } pub fn blob(h: &RepoHeader, path: &str, size: usize, content: &BlobBody) -> Markup { let q = h.h_query(); let body = html! { (breadcrumbs(h, path)) div.blob-meta { (size) " bytes · " a href={ "/" (h.name) "/raw/" (encode_path(path)) (q) } { "raw" } } @match content { BlobBody::Binary => p.note { "Binary file, not shown. Use the raw link to download it." }, BlobBody::TooLarge => p.note { "File too large to display. Use the raw link to download it." }, BlobBody::Text { html: code, lines } => { table.blob { tr { td.linenos { pre { @for n in 1..=*lines { a id={ "n" (n) } href={ "#n" (n) } { (n) } "\n" } } } td.code { pre { (PreEscaped(code)) } } } } } } }; repo_layout(h, path, body) } // ------------------------------------------------------------------ error -- pub fn error(site_title: &str, status: u16, message: &str) -> Markup { let body = html! { h1 { (status) } p { (message) } p { a href="/" { "back to index" } } }; layout(&format!("{status}"), site_title, body) }