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

35
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

50
content/docs/accounts.md Normal file
View file

@ -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 <password> <email>
/msg NickServ IDENTIFY <password>
```
## 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 <printed-public-key>
```
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.

View file

@ -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.

View file

@ -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 <https://orbit.devtronic.pro>.
Once connected, [register your nick](/docs/accounts) to keep it and to log in with SASL.

View file

@ -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
```

View file

@ -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.

28
content/docs/ircv3.md Normal file
View file

@ -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.

45
content/docs/linking.md Normal file
View file

@ -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.

55
content/docs/operators.md Normal file
View file

@ -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.

30
content/docs/services.md Normal file
View file

@ -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.

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)

71
static/docs.css Normal file
View file

@ -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}
}

View file

@ -13,6 +13,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
{% block head %}{% endblock %}
</head>
<body>
<header class="topbar">
@ -29,6 +30,7 @@
<a href="/"{% if active == "home" %} class="on"{% endif %}>Home</a>
<a href="/features"{% if active == "features" %} class="on"{% endif %}>Features</a>
<a href="/connect"{% if active == "connect" %} class="on"{% endif %}>Connect</a>
<a href="/docs"{% if active == "docs" %} class="on"{% endif %}>Docs</a>
<a href="https://orbit.devtronic.pro">Web&nbsp;client</a>
<a href="https://git.devtronic.pro/echo/echoIRCd" class="ext">Source&#8599;</a>
</nav>
@ -58,5 +60,6 @@
</nav>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

69
templates/docs.html Normal file
View file

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ page_title }} — echoIRCd Docs{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/docs.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css">
{% endblock %}
{% block content %}
<div class="docs-shell">
<nav class="docs-side" aria-label="documentation navigation">
<div class="docs-search">
<input type="search" id="q" placeholder="Search the docs…" autocomplete="off" spellcheck="false">
<div id="results" class="results" hidden></div>
</div>
{% for s in sections %}
<div class="side-sec">
<div class="side-h">{{ s.title }}</div>
{% for pg in s.pages %}
<a href="/docs/{{ pg.slug }}" class="side-link{% if pg.active %} active{% endif %}">{{ pg.title }}</a>
{% endfor %}
</div>
{% endfor %}
</nav>
<article class="docs-body">
{{ body|safe }}
<nav class="pager">
{% if let Some((slug, title)) = prev %}<a class="pager-prev" href="/docs/{{ slug }}"><span>&#8592; Previous</span><b>{{ title }}</b></a>{% else %}<span></span>{% endif %}
{% if let Some((slug, title)) = next %}<a class="pager-next" href="/docs/{{ slug }}"><span>Next &#8594;</span><b>{{ title }}</b></a>{% endif %}
</nav>
</article>
<aside class="docs-toc">
{% if !toc.is_empty() %}
<div class="toc-h">On this page</div>
<nav>
{% for h in toc %}<a href="#{{ h.id }}" class="toc-l{{ h.level }}">{{ h.text }}</a>{% endfor %}
</nav>
{% endif %}
</aside>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script>
if (window.hljs) hljs.highlightAll();
(function () {
var q = document.getElementById('q'), box = document.getElementById('results'), idx = null;
function esc(s){ return s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
async function load(){ if(idx) return idx; try{ idx = await (await fetch('/docs/search.json')).json(); }catch(e){ idx=[]; } return idx; }
q.addEventListener('input', async function () {
var term = q.value.trim().toLowerCase();
if (term.length < 2) { box.hidden = true; box.innerHTML=''; return; }
var data = await load();
var hits = data.map(function (p) {
var t = p.title.toLowerCase().indexOf(term), b = p.text.toLowerCase().indexOf(term);
if (t < 0 && b < 0) return null;
var pos = b < 0 ? 0 : Math.max(0, b - 30);
var snip = b < 0 ? '' : '' + p.text.substr(pos, 90) + '';
return { slug: p.slug, title: p.title, snip: snip, score: (t >= 0 ? 0 : 1) };
}).filter(Boolean).sort(function(a,b){return a.score-b.score;}).slice(0, 8);
box.innerHTML = hits.length
? hits.map(function (h) { return '<a href="/docs/' + h.slug + '"><b>' + esc(h.title) + '</b><span>' + esc(h.snip) + '</span></a>'; }).join('')
: '<div class="no-res">No matches.</div>';
box.hidden = false;
});
document.addEventListener('click', function (e) { if (!e.target.closest('.docs-search')) box.hidden = true; });
})();
</script>
{% endblock %}