use std::path::{Path, PathBuf};
use crate::check::Problem;
use crate::document::{Document, NavItem, NavLink};
use crate::{icons, nav};
struct Target {
href: String,
file: Option<PathBuf>,
}
fn target(document: &Document, written: &str) -> Target {
if written.contains("://") || written.starts_with("mailto:") {
return Target {
href: written.to_string(),
file: None,
};
}
let (path, fragment) = written.split_once('#').unwrap_or((written, ""));
let file = normalize(&document.base.join(path));
let mut href = document.url(&file);
if !fragment.is_empty() {
href.push('#');
href.push_str(fragment);
}
Target {
href,
file: Some(file),
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn host(href: &str) -> String {
href.split("://")
.nth(1)
.unwrap_or(href)
.split('/')
.next()
.unwrap_or(href)
.trim_start_matches("www.")
.to_string()
}
fn links(document: &Document) -> impl Iterator<Item = &NavLink> {
document.navbar.iter().flat_map(|item| match item {
NavItem::Link(link) => std::slice::from_ref(link).iter(),
NavItem::Menu { items, .. } => items.iter(),
})
}
pub fn problems(document: &Document) -> Vec<Problem> {
let file = document.url(&document.path())[1..].to_string();
let mut problems = Vec::new();
let mut icon = |name: &Option<String>| {
if let Some(name) = name
&& !icons::exists(name)
{
problems.push(Problem {
file: file.clone(),
line: None,
message: format!("navbar: no icon named {name}"),
target: None,
});
}
};
for item in &document.navbar {
if let NavItem::Menu { icon: name, .. } = item {
icon(name);
}
}
for link in links(document) {
icon(&link.icon);
}
for link in links(document) {
if let Some(path) = target(document, &link.target).file {
let inside = path
.canonicalize()
.is_ok_and(|real| real.is_file() && real.starts_with(&document.served));
if !inside {
problems.push(Problem {
file: file.clone(),
line: None,
message: format!("navbar: {} — no such file", link.target),
target: Some(link.target.clone()),
});
}
}
}
problems
}
pub fn reached(document: &Document) -> (Vec<PathBuf>, Vec<String>) {
let mut files = Vec::new();
let mut web = Vec::new();
for link in links(document) {
match target(document, &link.target).file {
Some(path) => {
if let Ok(real) = path.canonicalize()
&& real.is_file()
&& real.starts_with(&document.served)
{
files.push(real);
}
}
None if !link.target.starts_with("mailto:") => web.push(link.target.clone()),
None => {}
}
}
(files, web)
}
fn active(document: &Document, current: &str) -> Option<String> {
let targets: Vec<String> = links(document)
.map(|link| target(document, &link.target).href)
.collect();
if targets.iter().any(|href| href == current) {
return Some(current.to_string());
}
fn path(
document: &Document,
groups: &[nav::Group],
current: &str,
trail: &mut Vec<String>,
) -> bool {
for entry in groups.iter().flat_map(|group| &group.entries) {
trail.push(document.url(&entry.file));
if (entry.home && trail.last().is_some_and(|url| url == current))
|| path(document, &entry.children, current, trail)
{
return true;
}
trail.pop();
}
false
}
let mut trail = vec![document.entry()];
let found = trail[0] == current || path(document, &nav::tree(document), current, &mut trail);
if !found {
return None;
}
trail.into_iter().rev().find(|url| targets.contains(url))
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn icon(name: &Option<String>) -> String {
name.as_deref().and_then(icons::svg).unwrap_or_default()
}
fn link(document: &Document, link: &NavLink, active: Option<&str>, class: &str) -> String {
let to = target(document, &link.target);
let current = active == Some(to.href.as_str());
let external = to.file.is_none();
let (label, extra) = match &link.label {
Some(label) => (format!("<span>{}</span>", escape(label)), String::new()),
None => {
let name = if external {
host(&to.href)
} else {
link.target.clone()
};
(
String::new(),
format!(" title=\"{0}\" aria-label=\"{0}\"", escape(&name)),
)
}
};
format!(
"<a class=\"{class}{only}{current}\" href=\"{href}\"{extra}{aria}{rel}>{icon}{label}</a>",
only = if link.label.is_none() {
" icon-only"
} else {
""
},
current = if current { " current" } else { "" },
href = escape(&to.href),
aria = if current {
" aria-current=\"page\""
} else {
""
},
rel = if external { " rel=\"noopener\"" } else { "" },
icon = icon(&link.icon),
)
}
pub fn render(document: &Document, current: &str, comments: bool) -> String {
if document.navbar.is_empty() {
return String::new();
}
let active = active(document, current);
let active = active.as_deref();
let mut items = String::new();
for item in &document.navbar {
match item {
NavItem::Link(one) => items.push_str(&link(document, one, active, "navbar-link")),
NavItem::Menu {
label,
icon: name,
items: menu,
} => {
let holds_current = menu
.iter()
.any(|one| active == Some(target(document, &one.target).href.as_str()));
let entries: String = menu
.iter()
.map(|one| link(document, one, active, "navbar-entry"))
.collect();
items.push_str(&format!(
"<div class=\"navbar-menu{current}\"><button type=\"button\" class=\"navbar-link\" aria-haspopup=\"true\" aria-expanded=\"false\">{icon}<span>{label}</span>{chevron}</button><div class=\"navbar-dropdown\" role=\"menu\">{entries}</div></div>",
current = if holds_current { " current" } else { "" },
icon = icon(name),
label = escape(label.as_deref().unwrap_or("")),
chevron = icons::svg("chevron-down").unwrap_or_default(),
));
}
}
}
format!(
"<header id=\"devpage-navbar\"><div class=\"navbar-inner\"><a class=\"navbar-title{root}\" href=\"{home}\">{logo}{title}</a><nav class=\"navbar-links\" aria-label=\"{title}\">{items}</nav><div class=\"navbar-end\">{selector}<button type=\"button\" class=\"navbar-toggle\" aria-expanded=\"false\" aria-label=\"Menu\">{menu}</button></div></div></header>",
root = if current == document.entry() {
" current"
} else {
""
},
home = escape(&document.entry()),
logo = logo(document),
title = escape(&document.title),
selector = controls(document, comments),
menu = icons::svg("menu").unwrap_or_default(),
)
}
pub fn logo(document: &Document) -> String {
match document.logo_url() {
Some(url) => format!("<img class=\"logo\" src=\"{}\" alt=\"\">", escape(&url)),
None => String::new(),
}
}
pub fn logo_problem(document: &Document) -> Option<Problem> {
let written = document.logo.as_ref()?;
if document.logo_url().is_some() {
return None;
}
Some(Problem {
file: document.url(&document.path())[1..].to_string(),
line: None,
message: format!("logo: {written} — no such file"),
target: Some(written.clone()),
})
}
pub fn selector(document: &Document) -> String {
if !document.theme_selector {
return String::new();
}
theme_toggle()
}
pub fn theme_toggle() -> String {
format!(
"<button type=\"button\" class=\"theme-toggle\" title=\"Switch theme\" aria-label=\"Switch theme\">{}</button>",
theme_icons()
)
}
pub fn theme_icons() -> String {
format!(
"<span class=\"to-dark\">{}</span><span class=\"to-light\">{}</span>",
icons::svg("moon").unwrap_or_default(),
icons::svg("sun").unwrap_or_default()
)
}
pub fn design_theme_toggle() -> String {
format!(
"<button type=\"button\" class=\"design-theme\" hidden title=\"Switch this design's theme\" aria-label=\"Switch this design's theme\">{}</button>",
theme_icons()
)
}
pub fn comment_mode() -> String {
"<button type=\"button\" class=\"comment-mode\" title=\"Comment mode\" aria-label=\"Comment mode\" aria-pressed=\"false\"><svg viewBox=\"0 0 20 20\" width=\"17\" height=\"17\" aria-hidden=\"true\"><path d=\"M4 3.5h12A1.5 1.5 0 0 1 17.5 5v8a1.5 1.5 0 0 1-1.5 1.5H9l-3.5 3v-3H4A1.5 1.5 0 0 1 2.5 13V5A1.5 1.5 0 0 1 4 3.5Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg></button>".to_string()
}
pub fn controls(document: &Document, comments: bool) -> String {
let mode = if comments {
comment_mode()
} else {
String::new()
};
format!("{mode}{}", selector(document))
}
pub fn corner(document: &Document, comments: bool) -> String {
format!(
"<div id=\"devpage-corner\">{}</div>",
controls(document, comments)
)
}
pub fn head(document: &Document) -> String {
let icon = match document.logo_url() {
Some(url) => format!("<link rel=\"icon\" href=\"{}\">\n", escape(&url)),
None => String::new(),
};
format!("{icon}{}", theme_script(document.theme_selector))
}
pub fn theme_script(switchable: bool) -> String {
let stored = if switchable {
r#"try{c=localStorage.getItem("devpage-theme")}catch(e){}"#
} else {
""
};
format!(
r#"<script>(function(){{var c;{stored}var d=document.documentElement;d.dataset.theme=c==="light"||c==="dark"?c:(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")}})()</script>"#
)
}
#[cfg(test)]
mod tests {
use super::*;
fn site(dir: &Path, yaml: &str) -> Document {
std::fs::write(dir.join("devpage.yml"), yaml).unwrap();
Document::load(&dir.join("devpage.yml"), dir).unwrap()
}
fn folder(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("devpage-navbar-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("guide")).unwrap();
std::fs::write(
dir.join("README.md"),
"# Home\n\n## Topics\n\n- [Guide](guide/README.md)\n",
)
.unwrap();
std::fs::write(
dir.join("guide/README.md"),
"# Guide\n\n## Topics\n\n- [Deep](deep.md)\n",
)
.unwrap();
std::fs::write(dir.join("guide/deep.md"), "# Deep\n").unwrap();
std::fs::write(
dir.join("talk.yml"),
"kind: slides\ntitle: Talk\nslides: [guide/deep.md]\n",
)
.unwrap();
dir.canonicalize().unwrap()
}
const NAVBAR: &str = "kind: site\ntitle: Garden\nroot: README.md\nnavbar:\n Home (house): README.md\n Guide: guide/README.md\n More (layout-grid):\n Talk (presentation): talk.yml\n Site: https://example.com/garden\n (github): https://github.com/someone/garden\n";
#[test]
fn renders_links_dropdowns_icons_and_icon_only_links_in_order() {
let dir = folder("render");
let document = site(&dir, NAVBAR);
let html = render(&document, "/guide/deep.md", true);
let order: Vec<usize> = ["Home", "Guide", "More", "Talk", "github.com"]
.iter()
.map(|text| {
html.find(text)
.unwrap_or_else(|| panic!("{text} in {html}"))
})
.collect();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{html}");
assert!(html.contains("href=\"/README.md\""), "{html}");
assert!(html.contains("href=\"/talk.yml\""), "{html}");
assert!(html.contains("aria-haspopup=\"true\""), "{html}");
assert!(html.contains("class=\"navbar-link icon-only\" href=\"https://github.com/someone/garden\" title=\"github.com\""), "{html}");
assert_eq!(html.matches("<svg class=\"icon\"").count(), 8, "{html}");
}
#[test]
fn highlights_the_link_holding_the_page_shown() {
let dir = folder("current");
let document = site(&dir, NAVBAR);
let deep = render(&document, "/guide/deep.md", true);
assert!(
deep.contains("class=\"navbar-link current\" href=\"/guide/README.md\""),
"{deep}"
);
assert!(
!deep.contains("class=\"navbar-link current\" href=\"/README.md\""),
"{deep}"
);
let home = render(&document, "/README.md", true);
assert!(
home.contains("class=\"navbar-link current\" href=\"/README.md\""),
"{home}"
);
let talk = render(&document, "/talk.yml", true);
assert!(talk.contains("class=\"navbar-menu current\""), "{talk}");
}
#[test]
fn missing_files_and_unknown_icons_are_problems() {
let dir = folder("problems");
let document = site(
&dir,
"kind: site\ntitle: T\nroot: README.md\nnavbar:\n Gone (no-such-icon): gone.md\n Out: ../../outside.md\n Web: https://example.com\n",
);
let found: Vec<String> = problems(&document).iter().map(|p| p.to_string()).collect();
assert_eq!(
found,
[
"devpage.yml: navbar: no icon named no-such-icon",
"devpage.yml: navbar: gone.md — no such file",
"devpage.yml: navbar: ../../outside.md — no such file",
],
"{found:?}"
);
}
#[test]
fn no_navbar_renders_nothing_and_the_selector_can_be_turned_off() {
let dir = folder("off");
let plain = site(&dir, "kind: site\ntitle: T\nroot: README.md\n");
assert_eq!(render(&plain, "/README.md", true), "");
assert!(corner(&plain, true).contains("class=\"theme-toggle\""));
assert!(head(&plain).contains("localStorage"));
let off = site(
&dir,
"kind: site\ntitle: T\nroot: README.md\ntheme-selector: false\n",
);
assert!(corner(&plain, true).contains("class=\"comment-mode\""));
assert!(corner(&off, true).contains("class=\"comment-mode\""));
assert!(!corner(&off, true).contains("theme-toggle"));
assert!(!corner(&plain, false).contains("comment-mode"));
assert!(corner(&plain, false).contains("theme-toggle"));
assert!(
!head(&off).contains("localStorage"),
"the system decides when there is no selector"
);
}
#[test]
fn a_logo_sits_before_the_title_and_is_the_tab_icon() {
let dir = folder("logo");
std::fs::write(
dir.join("logo.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
.unwrap();
let with = site(&dir, &format!("{NAVBAR}logo: logo.svg\n"));
let bar = render(&with, "/README.md", true);
let img = bar
.find("<img class=\"logo\" src=\"/logo.svg\" alt=\"\">")
.expect(&bar);
assert!(
img < bar.find("Garden").unwrap(),
"the logo comes first: {bar}"
);
assert!(head(&with).contains("<link rel=\"icon\" href=\"/logo.svg\">"));
assert_eq!(logo_problem(&with), None);
std::fs::write(
dir.join("deck.yml"),
"kind: slides\ntitle: D\nslides: [guide/deep.md]\nlogo: logo.svg\n",
)
.unwrap();
let deck = Document::load(&dir.join("deck.yml"), &dir).unwrap();
assert!(head(&deck).contains("rel=\"icon\" href=\"/logo.svg\""));
let without = site(&dir, NAVBAR);
assert!(!render(&without, "/README.md", true).contains("<img"));
assert!(!head(&without).contains("rel=\"icon\""));
}
#[test]
fn a_missing_logo_is_a_problem_and_the_page_shows_without_it() {
let dir = folder("logo-missing");
let document = site(&dir, &format!("{NAVBAR}logo: gone.svg\n"));
let problem = logo_problem(&document).expect("a problem");
assert_eq!(problem.message, "logo: gone.svg — no such file");
assert_eq!(problem.file, "devpage.yml");
assert!(!render(&document, "/README.md", true).contains("<img"));
assert!(!head(&document).contains("rel=\"icon\""));
let outside = site(&dir, &format!("{NAVBAR}logo: ../../etc/hosts\n"));
assert!(logo_problem(&outside).is_some());
}
}