docs: markdown documentation section (sidebar nav, per-page toc, search, hljs)

This commit is contained in:
Jean Chevronnet 2026-08-30 19:20:02 +00:00
parent d1ec6962dd
commit fc129a1f1b
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
16 changed files with 841 additions and 3 deletions

208
src/docs.rs Normal file
View file

@ -0,0 +1,208 @@
//! A small Markdown documentation engine. Pages are authored as Markdown under
//! `content/docs/`, baked into the binary, rendered once with pulldown-cmark, and
//! served with a sidebar nav, a per-page table of contents, and a search index.
use pulldown_cmark::{html, Options, Parser};
use std::sync::OnceLock;
pub struct NavPage {
pub slug: &'static str,
pub title: &'static str,
pub md: &'static str,
}
pub struct NavSection {
pub title: &'static str,
pub pages: &'static [NavPage],
}
macro_rules! p {
($slug:literal, $title:literal) => {
NavPage { slug: $slug, title: $title, md: include_str!(concat!("../content/docs/", $slug, ".md")) }
};
}
pub static NAV: &[NavSection] = &[
NavSection { title: "Getting started", pages: &[
p!("introduction", "Introduction"),
p!("installation", "Installation"),
p!("connecting", "Connecting"),
]},
NavSection { title: "Administration", pages: &[
p!("configuration", "Configuration"),
p!("operators", "Operators"),
p!("linking", "Server links"),
]},
NavSection { title: "Accounts", pages: &[
p!("accounts", "Accounts & SASL"),
p!("services", "Services"),
]},
NavSection { title: "Reference", pages: &[
p!("ircv3", "IRCv3 capabilities"),
]},
];
pub struct Heading {
pub level: u8,
pub id: String,
pub text: String,
}
pub struct Page {
pub slug: &'static str,
pub title: &'static str,
pub html: String,
pub toc: Vec<Heading>,
pub text: String,
}
static REG: OnceLock<Vec<Page>> = OnceLock::new();
fn reg() -> &'static Vec<Page> {
REG.get_or_init(|| {
let mut v = Vec::new();
for s in NAV {
for np in s.pages {
let (html, toc) = render(np.md);
let text = strip_tags(&html);
v.push(Page { slug: np.slug, title: np.title, html, toc, text });
}
}
v
})
}
pub fn find(slug: &str) -> Option<&'static Page> {
reg().iter().find(|p| p.slug == slug)
}
pub fn first_slug() -> &'static str {
NAV[0].pages[0].slug
}
/// (previous, next) page as (slug, title), in reading order.
pub fn neighbours(slug: &str) -> (Option<(&'static str, &'static str)>, Option<(&'static str, &'static str)>) {
let flat: Vec<(&'static str, &'static str)> =
NAV.iter().flat_map(|s| s.pages.iter().map(|p| (p.slug, p.title))).collect();
match flat.iter().position(|(s, _)| *s == slug) {
Some(i) => (if i > 0 { Some(flat[i - 1]) } else { None }, flat.get(i + 1).copied()),
None => (None, None),
}
}
/// A JSON search index: `[{"slug","title","text"}, …]` (built once).
pub fn search_json() -> &'static str {
static J: OnceLock<String> = OnceLock::new();
J.get_or_init(|| {
let mut s = String::from("[");
for (i, p) in reg().iter().enumerate() {
if i > 0 {
s.push(',');
}
let text: String = p.text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(3000).collect();
s.push_str(&format!(
"{{\"slug\":\"{}\",\"title\":\"{}\",\"text\":\"{}\"}}",
p.slug, jesc(p.title), jesc(&text)
));
}
s.push(']');
s
})
}
fn render(md: &str) -> (String, Vec<Heading>) {
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_TASKLISTS;
let mut raw = String::new();
html::push_html(&mut raw, Parser::new_ext(md, opts));
add_ids(&raw)
}
/// Give every `<h2>`/`<h3>` an id (from its text) + an anchor link, and collect a TOC.
fn add_ids(html: &str) -> (String, Vec<Heading>) {
let mut out = String::with_capacity(html.len() + 256);
let mut toc = Vec::new();
let mut rest = html;
loop {
let h2 = rest.find("<h2>");
let h3 = rest.find("<h3>");
let (pos, level, close) = match (h2, h3) {
(Some(a), Some(b)) if a < b => (a, 2u8, "</h2>"),
(Some(_), Some(b)) => (b, 3u8, "</h3>"),
(Some(a), None) => (a, 2u8, "</h2>"),
(None, Some(b)) => (b, 3u8, "</h3>"),
(None, None) => {
out.push_str(rest);
break;
}
};
out.push_str(&rest[..pos]);
let after = &rest[pos + 4..];
let end = match after.find(close) {
Some(e) => e,
None => {
out.push_str(&rest[pos..]);
break;
}
};
let inner = &after[..end];
let text = strip_tags(inner);
let id = slugify(&text);
toc.push(Heading { level, id: id.clone(), text: text.clone() });
out.push_str(&format!(
"<h{lvl} id=\"{id}\">{inner}<a class=\"anchor\" href=\"#{id}\" aria-label=\"permalink\">#</a></h{lvl}>",
lvl = level
));
rest = &after[end + close.len()..];
}
(out, toc)
}
fn slugify(s: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
dash = false;
} else if !out.is_empty() && !dash {
out.push('-');
dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
out.push_str("section");
}
out
}
fn strip_tags(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut intag = false;
for c in html.chars() {
match c {
'<' => intag = true,
'>' => {
intag = false;
out.push(' ');
}
_ if !intag => out.push(c),
_ => {}
}
}
out.replace("&lt;", "<").replace("&gt;", ">").replace("&#39;", "'").replace("&quot;", "\"").replace("&amp;", "&")
}
fn jesc(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
c if (c as u32) < 0x20 => o.push(' '),
_ => o.push(c),
}
}
o
}

View file

@ -1,13 +1,15 @@
//! The echoIRCd project website: an Axum server rendering compile-time Askama
//! templates, with a small live "network status" pulled from the running ircd.
//! templates, a Markdown documentation section, and a small live "network status"
//! pulled from the running ircd.
mod docs;
mod stats;
use askama::Template;
use axum::{
extract::State,
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
response::{Html, IntoResponse, Redirect, Response},
routing::get,
Router,
};
@ -49,6 +51,28 @@ struct ConnectTemplate {
active: &'static str,
}
struct SidePage {
slug: &'static str,
title: &'static str,
active: bool,
}
struct SideSection {
title: &'static str,
pages: Vec<SidePage>,
}
#[derive(Template)]
#[template(path = "docs.html")]
struct DocsTemplate {
active: &'static str,
page_title: &'static str,
body: &'static str,
toc: &'static [docs::Heading],
sections: Vec<SideSection>,
prev: Option<(&'static str, &'static str)>,
next: Option<(&'static str, &'static str)>,
}
fn page<T: Template>(t: T) -> Response {
match t.render() {
Ok(body) => Html(body).into_response(),
@ -69,12 +93,54 @@ async fn connect() -> Response {
page(ConnectTemplate { active: "connect" })
}
async fn docs_index() -> Response {
Redirect::permanent(&format!("/docs/{}", docs::first_slug())).into_response()
}
async fn docs_page(Path(slug): Path<String>) -> Response {
let Some(p) = docs::find(&slug) else {
return not_found().await;
};
let sections = docs::NAV
.iter()
.map(|s| SideSection {
title: s.title,
pages: s
.pages
.iter()
.map(|np| SidePage { slug: np.slug, title: np.title, active: np.slug == p.slug })
.collect(),
})
.collect();
let (prev, next) = docs::neighbours(&slug);
page(DocsTemplate {
active: "docs",
page_title: p.title,
body: &p.html,
toc: &p.toc,
sections,
prev,
next,
})
}
async fn docs_search() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
docs::search_json(),
)
}
async fn style() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
include_str!("../static/style.css"),
)
}
async fn docs_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
include_str!("../static/docs.css"),
)
}
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml")],
@ -108,7 +174,11 @@ async fn main() {
.route("/", get(index))
.route("/features", get(features))
.route("/connect", get(connect))
.route("/docs", get(docs_index))
.route("/docs/search.json", get(docs_search))
.route("/docs/:slug", get(docs_page))
.route("/static/style.css", get(style))
.route("/static/docs.css", get(docs_css))
.route("/favicon.svg", get(favicon))
.route("/health", get(health))
.fallback(not_found)