use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use hyperchad::app::{App, AppBuilder, renderer::DefaultRenderer};
use hyperchad::markdown::markdown_to_container;
use hyperchad::router::Router;
use hyperchad::template::{Container, Containers, container};
use hyperchad::transformer::Element;
#[cfg(feature = "assets")]
use hyperchad::renderer::assets::StaticAssetRoute;
use crate::link_map::LinkMap;
use crate::markdown::MarkdownStyle;
use crate::registry::{DocPage, DocsSection, NavSection, PageKind, nav_sections};
use crate::theme::Theme;
pub static VIEWPORT: LazyLock<String> =
LazyLock::new(|| "width=device-width, initial-scale=1".to_string());
#[derive(Clone)]
pub struct MarkdownScan {
root: PathBuf,
route_prefix: String,
section: Option<&'static str>,
}
impl MarkdownScan {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
route_prefix: "/docs".to_string(),
section: None,
}
}
#[allow(clippy::missing_const_for_fn)]
#[must_use]
pub fn route_prefix(mut self, route_prefix: impl Into<String>) -> Self {
self.route_prefix = route_prefix.into();
self
}
#[must_use]
pub const fn section(mut self, section: &'static str) -> Self {
self.section = Some(section);
self
}
}
#[derive(Clone)]
pub struct HeaderLink {
label: &'static str,
href: &'static str,
external: bool,
}
impl HeaderLink {
#[must_use]
pub const fn new(label: &'static str, href: &'static str) -> Self {
Self {
label,
href,
external: false,
}
}
#[must_use]
pub const fn external(label: &'static str, href: &'static str) -> Self {
Self {
label,
href,
external: true,
}
}
#[must_use]
pub const fn label(&self) -> &'static str {
self.label
}
#[must_use]
pub const fn href(&self) -> &'static str {
self.href
}
#[must_use]
pub const fn is_external(&self) -> bool {
self.external
}
}
pub struct ShellContext<'a> {
pub site: &'a DocsSite,
pub current_path: &'a str,
pub title: Option<&'static str>,
pub body: &'a Containers,
pub sections: Vec<NavSection>,
}
impl ShellContext<'_> {
#[must_use]
pub fn sidebar(&self) -> Containers {
render_sidebar(&self.sections, self.current_path, &self.site.theme)
}
}
pub type PageShell = for<'a> fn(ShellContext<'a>) -> Containers;
pub struct DocsSiteBuilder {
name: &'static str,
title: String,
description: String,
theme: Theme,
pages: Vec<DocPage>,
sections: Vec<DocsSection>,
home: Option<fn() -> Containers>,
shell: Option<PageShell>,
brand: Option<(&'static str, &'static str)>,
header_links: Vec<HeaderLink>,
global_font: Option<&'static str>,
scans: Vec<MarkdownScan>,
}
impl DocsSiteBuilder {
#[must_use]
pub fn new(name: &'static str) -> Self {
Self {
name,
title: String::new(),
description: String::new(),
theme: Theme::default(),
pages: Vec::new(),
sections: Vec::new(),
home: None,
shell: None,
brand: None,
header_links: Vec::new(),
global_font: None,
scans: Vec::new(),
}
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub const fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
#[must_use]
pub const fn home(mut self, home: fn() -> Containers) -> Self {
self.home = Some(home);
self
}
#[must_use]
pub const fn shell(mut self, shell: PageShell) -> Self {
self.shell = Some(shell);
self
}
#[must_use]
pub const fn brand(mut self, label: &'static str, href: &'static str) -> Self {
self.brand = Some((label, href));
self
}
#[must_use]
pub fn header_link(mut self, link: HeaderLink) -> Self {
self.header_links.push(link);
self
}
#[must_use]
pub fn header_links(mut self, links: impl IntoIterator<Item = HeaderLink>) -> Self {
self.header_links.extend(links);
self
}
#[must_use]
pub const fn global_font(mut self, font: &'static str) -> Self {
self.global_font = Some(font);
self
}
#[must_use]
pub fn sections(mut self, sections: &'static [DocsSection]) -> Self {
self.sections.extend_from_slice(sections);
self
}
#[must_use]
pub fn pages(mut self, pages: &'static [DocPage]) -> Self {
self.pages.extend_from_slice(pages);
self
}
#[must_use]
pub fn page(mut self, page: DocPage) -> Self {
self.pages.push(page);
self
}
#[must_use]
pub fn generated_page(
mut self,
route: &'static str,
title: &'static str,
section: Option<&'static str>,
nav_label: Option<&'static str>,
generate: fn() -> String,
) -> Self {
self.pages.push(DocPage {
source: None,
route,
title: Some(title),
section,
nav_label,
kind: PageKind::GeneratedMarkdown { generate },
});
self
}
#[must_use]
pub fn scan_markdown(mut self, scan: MarkdownScan) -> Self {
self.scans.push(scan);
self
}
#[must_use]
pub fn build(mut self) -> DocsSite {
for scan in self.scans.clone() {
self.scan_pages(&scan);
}
let title = if self.title.is_empty() {
format!("{} docs", self.name)
} else {
self.title
};
let description = if self.description.is_empty() {
format!("Documentation for {}", self.name)
} else {
self.description
};
let link_map = LinkMap::from_pages(&self.pages);
DocsSite {
name: self.name,
title,
description,
theme: self.theme,
pages: self.pages,
sections: self.sections,
home: self.home,
shell: self.shell,
brand: self.brand,
header_links: self.header_links,
global_font: self.global_font,
link_map,
}
}
fn scan_pages(&mut self, scan: &MarkdownScan) {
let root = scan.root.clone();
if !root.exists() {
return;
}
let mut files = Vec::new();
collect_markdown_files(&root, &mut files).expect("failed to scan markdown docs");
files.sort();
for file in files {
let Ok(source) = file
.strip_prefix(".")
.unwrap_or(&file)
.to_str()
.map(str::to_string)
.ok_or(())
else {
continue;
};
if self
.pages
.iter()
.any(|page| page.source == Some(source.as_str()))
{
continue;
}
let Ok(contents) = std::fs::read_to_string(&file) else {
continue;
};
let route = route_for_markdown(&file, &root, &scan.route_prefix);
let title = title_from_markdown(&contents).unwrap_or_else(|| title_from_path(&file));
self.pages.push(DocPage {
source: Some(leak_string(source)),
route: leak_string(route),
title: Some(leak_string(title.clone())),
section: scan.section,
nav_label: Some(leak_string(title)),
kind: PageKind::Markdown {
contents: leak_string(contents),
},
});
}
}
}
#[derive(Clone)]
pub struct DocsSite {
name: &'static str,
title: String,
description: String,
theme: Theme,
pages: Vec<DocPage>,
sections: Vec<DocsSection>,
home: Option<fn() -> Containers>,
shell: Option<PageShell>,
brand: Option<(&'static str, &'static str)>,
header_links: Vec<HeaderLink>,
global_font: Option<&'static str>,
link_map: LinkMap,
}
impl DocsSite {
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub const fn theme(&self) -> &Theme {
&self.theme
}
#[must_use]
pub const fn font_family(&self) -> &'static str {
if let Some(font) = self.global_font {
font
} else {
self.theme.mono_font
}
}
}
impl DocsSite {
#[must_use]
pub fn builder(name: &'static str) -> DocsSiteBuilder {
DocsSiteBuilder::new(name)
}
#[must_use]
pub fn init(self) -> AppBuilder {
let title = self.title.clone();
let description = self.description.clone();
let background = self.theme.background;
AppBuilder::new()
.with_router(self.router())
.with_background(background)
.with_title(title)
.with_description(description)
.with_size(1100.0, 700.0)
}
#[must_use]
pub fn router(self) -> Router {
let site: &'static Self = Box::leak(Box::new(self));
let mut router = Router::new()
.with_static_route(&["/", "/home"], move |_| async move { site.render_home() });
for page in &site.pages {
let page_ref = page;
router = router.with_static_route(&[page.route], move |_| async move {
site.render_page(page_ref)
});
}
router.with_static_route(
&["/not-found"],
move |_| async move { site.render_not_found() },
)
}
#[cfg(feature = "assets")]
#[must_use]
pub fn assets(&self) -> Vec<StaticAssetRoute> {
vec![
#[cfg(feature = "vanilla-js")]
crate::assets::vanilla_js_route(),
]
}
}
impl DocsSite {
fn render_home(&self) -> Containers {
let body = self.home.map_or_else(
|| {
container! {
div direction=column gap=16 {
h1 { (self.title.clone()) }
span color=#c9d1d9 { (self.description.clone()) }
}
}
},
|home| home(),
);
self.wrap_page("/", &body)
}
fn render_not_found(&self) -> Containers {
let body = container! {
div direction=column gap=16 {
h1 { "Not found" }
span { "The requested documentation page does not exist." }
}
};
self.wrap_page("/not-found", &body)
}
#[must_use]
pub fn render_page(&self, page: &DocPage) -> Containers {
match &page.kind {
PageKind::Markdown { contents } => self.render_markdown_page(page, contents),
PageKind::GeneratedMarkdown { generate } => {
let markdown = generate();
self.render_markdown_page(page, leak_string(markdown))
}
PageKind::Custom { render } => render(page),
}
}
fn render_markdown_page(&self, page: &DocPage, markdown: &'static str) -> Containers {
let content = self.render_markdown_content(page, markdown);
let title = markdown_shell_title(&content, page.title);
let body = Self::render_markdown_body(&vec![content]);
self.wrap_page_with_title(page.route, title, &body)
}
#[must_use]
pub fn render_markdown_content(&self, page: &DocPage, markdown: &str) -> Container {
let mut content = markdown_to_container(markdown);
if let Some(source) = page.source {
self.link_map.rewrite_relative_links(&mut content, source);
}
let style = MarkdownStyle::new(&self.theme, self.font_family());
style.apply(&mut content);
style.apply_body(&mut content);
content
}
fn render_markdown_body(content: &Containers) -> Containers {
container! {
div {
(content)
}
}
}
#[must_use]
pub fn wrap_page(&self, current_path: &str, body: &Containers) -> Containers {
self.wrap_page_with_title(current_path, None, body)
}
fn wrap_page_with_title(
&self,
current_path: &str,
title: Option<&'static str>,
body: &Containers,
) -> Containers {
let sections = nav_sections(&self.sections, &self.pages);
if let Some(shell) = self.shell {
return shell(ShellContext {
site: self,
current_path,
title,
body,
sections,
});
}
let border = (self.theme.border, 1);
let brand = self.brand.unwrap_or((self.name, "/"));
let header_links = self.header_links.clone();
let font_family = self.global_font.unwrap_or(self.theme.mono_font);
container! {
div direction=column min-height="100vh" background=(self.theme.background) color=(self.theme.text_primary) font-family=(font_family) {
header direction=row align-items=center justify-content=space-between padding-x=24 padding-y=16 border-bottom=(border) {
anchor href=(brand.1) color=(self.theme.accent) text-decoration="none" font-size=20 font-weight=700 { (brand.0) }
div direction=row align-items=center justify-content=end gap=24 {
@for link in header_links {
@if link.external {
anchor href=(link.href) target="_blank" color=(self.theme.text_secondary) text-decoration="none" font-size=14 { (link.label) }
} @else {
anchor href=(link.href) color=(self.theme.text_secondary) text-decoration="none" font-size=14 { (link.label) }
}
}
}
}
div direction=row flex=1 {
aside direction=column width=260 min-width=260 background=(self.theme.surface) border-right=(border) padding-y=24 overflow-y=auto {
(render_sidebar(§ions, current_path, &self.theme))
}
main flex=1 padding=32 overflow-x=auto {
div max-width=900 direction=column gap=24 {
@if let Some(title) = title {
h1 { (title) }
}
(body)
}
}
}
}
}
}
}
fn render_sidebar(sections: &[NavSection], current_path: &str, theme: &Theme) -> Containers {
container! {
div direction=column gap=24 padding-x=16 {
@for section in sections {
div direction=column gap=8 {
div color=(theme.text_muted) font-size=12 font-weight=700 {
(section.title)
}
@for item in §ion.items {
anchor
href=(item.href)
color=(if item.href == current_path { theme.accent } else { theme.text_secondary })
text-decoration="none"
padding-y=4
{
(item.label)
}
}
}
}
}
}
}
pub fn build_app(builder: AppBuilder) -> Result<App<DefaultRenderer>, hyperchad::app::Error> {
use hyperchad::renderer::Renderer as _;
let mut app = builder.build_default()?;
app.renderer.add_responsive_trigger(
"mobile".into(),
hyperchad::renderer::transformer::ResponsiveTrigger::MaxWidth(
hyperchad::renderer::transformer::Number::Integer(600),
),
);
app.renderer.add_responsive_trigger(
"tablet".into(),
hyperchad::renderer::transformer::ResponsiveTrigger::MaxWidth(
hyperchad::renderer::transformer::Number::Integer(900),
),
);
Ok(app)
}
fn collect_markdown_files(root: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_markdown_files(&path, files)?;
} else if path.extension().is_some_and(|extension| extension == "md") {
files.push(path);
}
}
Ok(())
}
fn route_for_markdown(path: &Path, root: &Path, route_prefix: &str) -> String {
let relative = path.strip_prefix(root).unwrap_or(path);
let mut parts: Vec<String> = relative
.components()
.filter_map(|component| component.as_os_str().to_str().map(ToString::to_string))
.collect();
if let Some(last) = parts.last_mut() {
*last = last.trim_end_matches(".md").to_string();
if last == "README" {
parts.pop();
}
}
let suffix = parts.join("/");
if suffix.is_empty() {
route_prefix.to_string()
} else {
format!("{}/{suffix}", route_prefix.trim_end_matches('/'))
}
}
fn markdown_shell_title(content: &Container, title: Option<&'static str>) -> Option<&'static str> {
let title = title?;
let first_heading = content.children.iter().find_map(heading_text);
if first_heading.as_deref().map(str::trim) == Some(title.trim()) {
None
} else {
Some(title)
}
}
fn heading_text(container: &Container) -> Option<String> {
if matches!(container.element, Element::Heading { .. }) {
let mut text = String::new();
collect_text(container, &mut text);
return Some(text);
}
None
}
fn collect_text(container: &Container, text: &mut String) {
if let Element::Text { value } = &container.element {
text.push_str(value);
}
for child in &container.children {
collect_text(child, text);
}
}
fn title_from_markdown(markdown: &str) -> Option<String> {
markdown
.lines()
.find_map(|line| line.strip_prefix("# ").map(str::to_string))
}
fn title_from_path(path: &Path) -> String {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("Docs")
.replace(['-', '_'], " ")
}
fn leak_string(value: String) -> &'static str {
Box::leak(value.into_boxed_str())
}