From fc129a1f1b0979911632b01ddb9a99859d800b51 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 30 Aug 2026 19:20:02 +0000 Subject: [PATCH] docs: markdown documentation section (sidebar nav, per-page toc, search, hljs) --- Cargo.lock | 35 ++++++ Cargo.toml | 1 + content/docs/accounts.md | 50 ++++++++ content/docs/configuration.md | 62 ++++++++++ content/docs/connecting.md | 36 ++++++ content/docs/installation.md | 50 ++++++++ content/docs/introduction.md | 25 ++++ content/docs/ircv3.md | 28 +++++ content/docs/linking.md | 45 ++++++++ content/docs/operators.md | 55 +++++++++ content/docs/services.md | 30 +++++ src/docs.rs | 208 ++++++++++++++++++++++++++++++++++ src/main.rs | 76 ++++++++++++- static/docs.css | 71 ++++++++++++ templates/base.html | 3 + templates/docs.html | 69 +++++++++++ 16 files changed, 841 insertions(+), 3 deletions(-) create mode 100644 content/docs/accounts.md create mode 100644 content/docs/configuration.md create mode 100644 content/docs/connecting.md create mode 100644 content/docs/installation.md create mode 100644 content/docs/introduction.md create mode 100644 content/docs/ircv3.md create mode 100644 content/docs/linking.md create mode 100644 content/docs/operators.md create mode 100644 content/docs/services.md create mode 100644 src/docs.rs create mode 100644 static/docs.css create mode 100644 templates/docs.html diff --git a/Cargo.lock b/Cargo.lock index f52d4dc..8f58f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,7 @@ version = "0.1.0" dependencies = [ "askama", "axum", + "pulldown-cmark", "tokio", "tower-http", "tracing", @@ -285,6 +286,15 @@ dependencies = [ "slab", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "http" version = "1.5.0" @@ -523,6 +533,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.47" @@ -873,6 +902,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 2130357..3b3bd18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" axum = "0.7" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "io-util", "time"] } askama = "0.12" +pulldown-cmark = "0.12" tower-http = { version = "0.6", features = ["trace", "compression-gzip"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/content/docs/accounts.md b/content/docs/accounts.md new file mode 100644 index 0000000..bd88f73 --- /dev/null +++ b/content/docs/accounts.md @@ -0,0 +1,50 @@ +# Accounts & SASL + +Registering a nickname creates an account you can protect, use to found channels, and log in with +via SASL. + +## Register + +Message **NickServ** to register your current nick, then identify: + +```text +/msg NickServ REGISTER +/msg NickServ IDENTIFY +``` + +## SASL mechanisms + +SASL logs you in *during* connection, before you join anything. echoIRCd advertises: + +| Mechanism | How it works | +| --- | --- | +| `PLAIN` | account + password | +| `EXTERNAL` | your TLS client-certificate fingerprint | +| `SCRAM-SHA-256` | salted challenge / response — no password on the wire | +| `ECDSA-NIST256P-CHALLENGE` | sign a challenge with a NIST P-256 key | + +## SASL EXTERNAL (client certificate) + +Add your certificate fingerprint to your account, then select **EXTERNAL** in your client: + +```text +/msg NickServ CERT ADD +``` + +## Key-based login (ECDSA) + +Generate a NIST P-256 key and register its public half. At login the server sends a random +challenge, your client signs it, and the signature is verified against the stored key — nothing +secret crosses the wire. + +```sh +ecdsatool keygen ~/.ecdsa.pem +ecdsatool pubkey ~/.ecdsa.pem +``` + +```text +/msg NickServ SET PUBKEY +``` + +Then point your client's SASL settings at the key file and choose the +`ECDSA-NIST256P-CHALLENGE` mechanism. See `/msg NickServ HELP SET PUBKEY` for more. diff --git a/content/docs/configuration.md b/content/docs/configuration.md new file mode 100644 index 0000000..a9ae6e1 --- /dev/null +++ b/content/docs/configuration.md @@ -0,0 +1,62 @@ +# Configuration + +echoIRCd is configured from a single file (`echoircd.conf` by default). The format is a set of +**blocks** made of `key value;` fields. + +> Inline `#` comments are **not** stripped from a value — keep active lines to bare values and put +> comments on their own line. + +## Format + +```ini +network "echoiRCd"; +sid "0AA"; + +listen { ip "[::]"; port 6697; tls yes; } # dual-stack v4 + v6 +``` + +## Listeners + +Each `listen` block opens one port. `tls yes` makes it a direct-TLS port, `wss yes` a +WebSocket-over-TLS port, and `type server` a server-to-server port. + +```ini +listen { ip "[::]"; port 6667; } # plaintext clients +listen { ip "[::]"; port 6697; tls yes; } # direct-TLS clients +listen { ip "[::]"; port 7799; wss yes; } # WebSocket (wss) +listen { ip "[::]"; port 7700; type server; } # server links +``` + +## TLS + +Point the `tls` block at a certificate and private key. Both the OpenSSL and rustls backends are +available, and certificates are re-read on rehash. Per-host certificates can be supplied with +repeatable `sni` entries. + +```ini +tls { + cert "/etc/echoircd/tls/cert.pem"; + key "/etc/echoircd/tls/key.pem"; + backend openssl; # or: rustls + sni "irc.example.net ./tls/example.crt ./tls/example.key"; +} +``` + +## Cloaking + +Set a secret `cloak` key to enable host masking: + +```ini +cloak { key "a-long-random-secret"; } +``` + +## Applying changes + +Validate, then rehash the running server — no restart required: + +```sh +echoircd checkconfig echoircd.conf +echoircd rehash echoircd.conf # sends SIGHUP +``` + +See the shipped `echoircd.conf.example` for every available option. diff --git a/content/docs/connecting.md b/content/docs/connecting.md new file mode 100644 index 0000000..28e7a8e --- /dev/null +++ b/content/docs/connecting.md @@ -0,0 +1,36 @@ +# Connecting + +Point any IRC client at the network, or use the browser. + +## Network details + +| Setting | Value | +| --- | --- | +| Server | `irc.echoircd.org` | +| TLS (recommended) | `6697` | +| Plaintext | `6667` | +| Network | echoiRCd | + +## One-line connect + +Most clients accept a single server string; a leading `+` marks the port as TLS. + +```text +/server irc.echoircd.org +6697 +/join #echoircd +``` + +## Popular clients + +| Client | Command | +| --- | --- | +| HexChat | add `irc.echoircd.org/+6697`, tick “Use SSL” | +| WeeChat | `/server add echo irc.echoircd.org/6697 -tls` | +| irssi | `/connect -tls irc.echoircd.org 6697` | +| mIRC | `/server irc.echoircd.org +6697` | + +## In the browser + +A web client is available at . + +Once connected, [register your nick](/docs/accounts) to keep it and to log in with SASL. diff --git a/content/docs/installation.md b/content/docs/installation.md new file mode 100644 index 0000000..0e51733 --- /dev/null +++ b/content/docs/installation.md @@ -0,0 +1,50 @@ +# Installation + +echoIRCd builds with a recent stable Rust toolchain and has no C dependencies of its own beyond an +SSL library when using the OpenSSL TLS backend. + +## Requirements + +- Rust **1.85** or newer (`rustup` recommended). +- For the OpenSSL backend: `libssl-dev` / `openssl` and `pkg-config`. The `rustls` backend needs + neither. + +## Build from source + +```sh +git clone https://git.devtronic.pro/echo/echoIRCd +cd echoIRCd +cargo build --release +``` + +The server binary is written to `target/release/echoircd`. + +## Configure and run + +Copy the shipped example configuration, edit it, and start the server: + +```sh +cp echoircd.conf.example echoircd.conf +$EDITOR echoircd.conf +./target/release/echoircd echoircd.conf +``` + +Validate a configuration without starting the server: + +```sh +./target/release/echoircd checkconfig echoircd.conf +``` + +## TLS certificates + +Point the `tls` block at a certificate and key (for example, from Let's Encrypt). See +[Configuration](/docs/configuration#tls) for the details. + +## Running as a service + +Run `echoircd` under your init system of choice. A `SIGHUP` triggers a live configuration +rehash — including a certificate reload — with no restart and no disconnects: + +```sh +./target/release/echoircd rehash echoircd.conf +``` diff --git a/content/docs/introduction.md b/content/docs/introduction.md new file mode 100644 index 0000000..508af32 --- /dev/null +++ b/content/docs/introduction.md @@ -0,0 +1,25 @@ +# Introduction + +**echoIRCd** is a modern IRC daemon and a full services suite, written from scratch in Rust. It is +original code — not a fork of another ircd — and the entire tree compiles with +`#![forbid(unsafe_code)]`. + +## What you get + +- **echoIRCd** — the server: a single-threaded core with an epoll reactor pool, the full channel + and user mode set, host cloaking, connection classes, and an operator privilege model. +- **echo services** — NickServ, ChanServ, OperServ, MemoServ and more, linked over a standard + server-to-server protocol and backed by an event-sourced store. + +## Highlights + +- Full **IRCv3**, including `chathistory`, `labeled-response`, `multiline` and more. +- Modern **TLS 1.3** (OpenSSL and rustls backends) with post-quantum `X25519MLKEM768`. +- **SASL**: `PLAIN`, `EXTERNAL`, `SCRAM-SHA-256`, and `ECDSA-NIST256P-CHALLENGE`. +- A native **anti-abuse** engine built into the core. + +> New here? Start with [Installation](/docs/installation), then [Connecting](/docs/connecting). + +## The public network + +A live network runs at **irc.echoircd.org** — see [Connecting](/docs/connecting) to join. diff --git a/content/docs/ircv3.md b/content/docs/ircv3.md new file mode 100644 index 0000000..3d2de81 --- /dev/null +++ b/content/docs/ircv3.md @@ -0,0 +1,28 @@ +# IRCv3 capabilities + +echoIRCd implements a broad set of IRCv3 capabilities. Clients negotiate them with `CAP LS` and +`CAP REQ`. + +## Advertised capabilities + +```text +server-time message-tags account-tag account-notify +extended-join chghost multi-prefix away-notify +invite-notify setname echo-message userhost-in-names +batch labeled-response standard-replies extended-monitor +draft/chathistory draft/event-playback draft/message-redaction +draft/multiline draft/metadata-2 draft/read-marker draft/webpush +draft/account-registration sts +``` + +## Notable features + +- **CHATHISTORY** — replay recent conversation, with server-side storage. +- **labeled-response** + **batch** — correlate replies to the command that caused them. +- **multiline** — send a message that spans lines as a single logical message. +- **STS** — advertise a strict-transport-security policy so clients upgrade to TLS. +- **draft/webpush** — RFC 8291 / 8292 Web Push notifications for supporting clients. + +## SASL + +SASL is a capability too — see [Accounts & SASL](/docs/accounts) for the supported mechanisms. diff --git a/content/docs/linking.md b/content/docs/linking.md new file mode 100644 index 0000000..602f251 --- /dev/null +++ b/content/docs/linking.md @@ -0,0 +1,45 @@ +# Server links + +echoIRCd links to other servers — and to echo services — over a standard server-to-server +protocol, wire-compatible with the InspIRCd 1206 protocol. + +## Server identity + +Every server needs a unique **SID** (three characters, the first a digit) and a name. + +```ini +network "echoiRCd"; +sid "0AA"; +``` + +## A link block + +Each `link` block describes a peer: its name, address, port, and the passwords used to +authenticate the connection in each direction. + +```ini +listen { ip "[::]"; port 7700; type server; } + +link { + name "hub.example.net"; + ip "203.0.113.10"; + port 7700; + sendpassword "outbound-secret"; + recvpassword "inbound-secret"; +} +``` + +> Treat link passwords as secrets, and restrict the server port to trusted peers. + +## Services + +echo services connect as a special linked server. In the ircd, mark the services server name as a +**U-lined** server (and as the SASL server) so its privileged commands and SASL relay are +accepted: + +```ini +uline { server "services.echoircd.org"; } +sasl_server "services.echoircd.org"; +``` + +See [Services](/docs/services) for the services side of the link. diff --git a/content/docs/operators.md b/content/docs/operators.md new file mode 100644 index 0000000..28e69ef --- /dev/null +++ b/content/docs/operators.md @@ -0,0 +1,55 @@ +# Operators + +Server operators are defined by `oper` blocks and typed by `opertype` blocks. An oper's power is +the sum of three allow-lists: the **commands** they may run, the named **privileges** they hold, +and the user/channel **modes** they may set. + +## Oper types + +An `opertype` groups a set of powers so many opers can share one role. + +```ini +opertype { + name "netadmin"; + commands "*"; # every oper command + privs "*"; # every named privilege + usermodes "*"; + chanmodes "*"; +} + +opertype { + name "helper"; + commands "KILL SANICK"; + privs "users/auspex channels/auspex"; + usermodes "-*"; # no privileged usermodes + chanmodes "b"; +} +``` + +Lists are space-separated tokens. `*` grants everything; a `-` prefix denies a specific token — +for example `* -KILL` means "everything except `KILL`". + +## Oper accounts + +An `oper` block ties a login to an `opertype`. Hash the password with the `mkpasswd` helper. + +```ini +oper { + name "alice"; + password "$argon2id$..."; # from: echoircd mkpasswd + type "netadmin"; + host "*@192.0.2.0/24"; +} +``` + +Then, as a client: + +```text +/oper alice hunter2 +``` + +## Privileges + +Named privileges gate individual abilities — for example `users/auspex` (see hidden user +details), `channels/auspex`, `servers/rehash`, or `users/mass-message`. Assign them per +`opertype` through `privs`, and the daemon enforces them everywhere the ability is used. diff --git a/content/docs/services.md b/content/docs/services.md new file mode 100644 index 0000000..97e39d3 --- /dev/null +++ b/content/docs/services.md @@ -0,0 +1,30 @@ +# Services + +echo services is a suite of pseudo-clients that runs as its own program and links to the network +as a services server. State is **event-sourced** — every change is an appended event, replayed to +rebuild the database. + +## The services + +| Service | Purpose | +| --- | --- | +| **NickServ** | account registration, grouped nicks, certificates, public keys, vhosts, profiles | +| **ChanServ** | channel founders and access, auto-op, akick, topic and mode locks | +| **OperServ** | network administration, akills, session control | +| **MemoServ** | offline messages between accounts | + +## Running + +echo services is configured from its own file and connects to the ircd over the server port: + +```sh +echo config.toml +``` + +On the ircd side, the services server must be U-lined and named as the SASL server — see +[Server links](/docs/linking#services). + +## Accounts & SASL + +Registration and login are covered in [Accounts & SASL](/docs/accounts). SASL is relayed from the +daemon to services mechanism-agnostically, so new mechanisms work without daemon changes. diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..047a306 --- /dev/null +++ b/src/docs.rs @@ -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, + pub text: String, +} + +static REG: OnceLock> = OnceLock::new(); +fn reg() -> &'static Vec { + 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 = 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::>().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) { + 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 `

`/`

` an id (from its text) + an anchor link, and collect a TOC. +fn add_ids(html: &str) -> (String, Vec) { + let mut out = String::with_capacity(html.len() + 256); + let mut toc = Vec::new(); + let mut rest = html; + loop { + let h2 = rest.find("

"); + let h3 = rest.find("

"); + let (pos, level, close) = match (h2, h3) { + (Some(a), Some(b)) if a < b => (a, 2u8, "

"), + (Some(_), Some(b)) => (b, 3u8, "

"), + (Some(a), None) => (a, 2u8, ""), + (None, Some(b)) => (b, 3u8, ""), + (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!( + "{inner}#", + 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 +} diff --git a/src/main.rs b/src/main.rs index 515d536..216514d 100644 --- a/src/main.rs +++ b/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, +} + +#[derive(Template)] +#[template(path = "docs.html")] +struct DocsTemplate { + active: &'static str, + page_title: &'static str, + body: &'static str, + toc: &'static [docs::Heading], + sections: Vec, + prev: Option<(&'static str, &'static str)>, + next: Option<(&'static str, &'static str)>, +} + fn page(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) -> 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) diff --git a/static/docs.css b/static/docs.css new file mode 100644 index 0000000..d1b7c92 --- /dev/null +++ b/static/docs.css @@ -0,0 +1,71 @@ +/* Documentation layout: sidebar · content · table-of-contents. Loads on top of + style.css, so the :root custom properties defined there are available here. */ +.docs-shell{ + max-width:1260px;margin:0 auto;padding:0 1.25rem 3rem; + display:grid;grid-template-columns:236px minmax(0,1fr) 210px;gap:2.2rem;align-items:start; +} + +/* sidebar */ +.docs-side{position:sticky;top:74px;align-self:start;padding-top:1.6rem;max-height:calc(100vh - 84px);overflow-y:auto} +.docs-search{position:relative;margin-bottom:1.2rem} +.docs-search input{width:100%;padding:.5rem .7rem;border:1px solid var(--line2);border-radius:8px;font:inherit;font-size:.9rem;background:#fff;color:var(--ink)} +.docs-search input:focus{outline:none;border-color:var(--teal);box-shadow:0 0 0 3px rgba(18,165,148,.12)} +.results{position:absolute;top:calc(100% + 6px);left:0;right:-40px;background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:0 18px 40px -18px rgba(20,40,60,.4);z-index:30;max-height:70vh;overflow:auto;padding:.3rem} +.results a{display:block;padding:.5rem .6rem;border-radius:7px;color:var(--ink)} +.results a:hover{background:var(--teal-t);text-decoration:none} +.results a b{display:block;color:var(--teal-dd);font-size:.92rem} +.results a span{display:block;color:var(--muted);font-size:.8rem;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.results .no-res{padding:.6rem;color:var(--faint);font-size:.88rem} +.side-sec{margin-bottom:1.3rem} +.side-h{font-size:.72rem;text-transform:uppercase;letter-spacing:.13em;color:var(--faint);font-weight:600;margin-bottom:.5rem} +.side-link{display:block;padding:.32rem .7rem;color:var(--muted);border-left:2px solid transparent;font-size:.94rem;border-radius:0 6px 6px 0} +.side-link:hover{color:var(--ink);background:#fff;text-decoration:none} +.side-link.active{color:var(--teal-dd);border-left-color:var(--teal);background:var(--teal-t);font-weight:500} + +/* content */ +.docs-body{padding-top:1.4rem;min-width:0;font-size:16px;line-height:1.72} +.docs-body>h1:first-child{margin-top:0} +.docs-body h1{font-size:2.05rem;letter-spacing:-.02em;margin:0 0 1rem} +.docs-body h2{font-size:1.45rem;margin:2.2rem 0 .8rem;padding-bottom:.35rem;border-bottom:1px solid var(--line)} +.docs-body h3{font-size:1.15rem;margin:1.6rem 0 .5rem} +.docs-body h2 .anchor,.docs-body h3 .anchor{margin-left:.4rem;color:var(--line2);opacity:0;font-weight:400;text-decoration:none} +.docs-body h2:hover .anchor,.docs-body h3:hover .anchor{opacity:1;color:var(--teal)} +.docs-body p{margin:0 0 1rem} +.docs-body ul,.docs-body ol{margin:0 0 1rem;padding-left:1.4rem} +.docs-body li{margin:.3rem 0} +.docs-body a{color:var(--teal-d);font-weight:500} +.docs-body code{font-family:var(--mono);font-size:.88em;background:rgba(18,165,148,.09);color:var(--teal-dd);padding:.08em .4em;border-radius:5px} +.docs-body pre{background:#f6f8fa;border:1px solid var(--line);border-radius:9px;padding:.9rem 1.1rem;overflow-x:auto;margin:0 0 1.2rem} +.docs-body pre code{background:none;color:#1f2733;padding:0;font-size:.87rem;line-height:1.65} +.docs-body table{border-collapse:collapse;width:100%;margin:0 0 1.2rem;font-size:.94rem} +.docs-body th,.docs-body td{text-align:left;padding:.5rem .8rem;border:1px solid var(--line)} +.docs-body th{background:#f7f9fa;font-weight:600} +.docs-body blockquote{margin:0 0 1.2rem;padding:.7rem 1rem;background:var(--teal-t);border-left:3px solid var(--teal);border-radius:0 8px 8px 0;color:#0c5a51} +.docs-body blockquote p:last-child{margin:0} +.docs-body hr{border:0;border-top:1px solid var(--line);margin:2rem 0} + +/* prev / next */ +.pager{display:flex;justify-content:space-between;gap:1rem;margin-top:2.5rem;padding-top:1.4rem;border-top:1px solid var(--line)} +.pager a{display:block;padding:.7rem 1rem;border:1px solid var(--line);border-radius:10px;min-width:44%;color:var(--ink)} +.pager a:hover{border-color:var(--teal);text-decoration:none} +.pager a span{display:block;font-size:.76rem;color:var(--faint)} +.pager a b{color:var(--teal-dd)} +.pager-next{text-align:right} + +/* toc */ +.docs-toc{position:sticky;top:74px;align-self:start;padding-top:1.9rem;font-size:.86rem;max-height:calc(100vh - 84px);overflow-y:auto} +.toc-h{font-size:.72rem;text-transform:uppercase;letter-spacing:.13em;color:var(--faint);font-weight:600;margin-bottom:.6rem} +.docs-toc nav{display:flex;flex-direction:column;gap:.05rem;border-left:1px solid var(--line)} +.docs-toc a{color:var(--muted);padding:.24rem .8rem;border-left:2px solid transparent;margin-left:-1px} +.docs-toc a:hover{color:var(--teal-dd);text-decoration:none;border-left-color:var(--line2)} +.docs-toc a.toc-l3{padding-left:1.5rem;font-size:.82rem} + +@media(max-width:1000px){ + .docs-shell{grid-template-columns:220px minmax(0,1fr)} + .docs-toc{display:none} +} +@media(max-width:720px){ + .docs-shell{grid-template-columns:1fr;gap:1rem} + .docs-side{position:static;max-height:none;border-bottom:1px solid var(--line);padding-bottom:1rem} + .results{right:0} +} diff --git a/templates/base.html b/templates/base.html index a3a052b..2ddf32e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,7 @@ +{% block head %}{% endblock %}
@@ -29,6 +30,7 @@ Home Features Connect + Docs Web client Source↗ @@ -58,5 +60,6 @@ +{% block scripts %}{% endblock %} diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..933518b --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} — echoIRCd Docs{% endblock %} +{% block head %} + + +{% endblock %} +{% block content %} +
+ + + + + +
+{% endblock %} +{% block scripts %} + + +{% endblock %}