docs: markdown documentation section (sidebar nav, per-page toc, search, hljs)
This commit is contained in:
parent
d1ec6962dd
commit
fc129a1f1b
16 changed files with 841 additions and 3 deletions
208
src/docs.rs
Normal file
208
src/docs.rs
Normal 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("<", "<").replace(">", ">").replace("'", "'").replace(""", "\"").replace("&", "&")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
76
src/main.rs
76
src/main.rs
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue