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
35
Cargo.lock
generated
35
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
50
content/docs/accounts.md
Normal 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.
|
||||
62
content/docs/configuration.md
Normal file
62
content/docs/configuration.md
Normal 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.
|
||||
36
content/docs/connecting.md
Normal file
36
content/docs/connecting.md
Normal 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.
|
||||
50
content/docs/installation.md
Normal file
50
content/docs/installation.md
Normal 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
|
||||
```
|
||||
25
content/docs/introduction.md
Normal file
25
content/docs/introduction.md
Normal 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
28
content/docs/ircv3.md
Normal 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
45
content/docs/linking.md
Normal 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
55
content/docs/operators.md
Normal 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
30
content/docs/services.md
Normal 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
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)
|
||||
|
|
|
|||
71
static/docs.css
Normal file
71
static/docs.css
Normal 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}
|
||||
}
|
||||
|
|
@ -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 client</a>
|
||||
<a href="https://git.devtronic.pro/echo/echoIRCd" class="ext">Source↗</a>
|
||||
</nav>
|
||||
|
|
@ -58,5 +60,6 @@
|
|||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
69
templates/docs.html
Normal file
69
templates/docs.html
Normal 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>← 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 →</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 => ({'&':'&','<':'<','>':'>'}[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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue