//! 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";
// e.g. "log - rust-kernel - RustForge"
title { (title) @if title != site_title { " - " (site_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 RustForge 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)
}