feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
214
Cargo.lock
generated
214
Cargo.lock
generated
|
|
@ -2,6 +2,15 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -58,6 +67,12 @@ version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
|
|
@ -101,7 +116,7 @@ dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -116,6 +131,60 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"env_filter",
|
||||||
|
"jiff",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -183,8 +252,13 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"env_logger",
|
||||||
"itsybitsy-core",
|
"itsybitsy-core",
|
||||||
|
"itsybitsy-gemtext",
|
||||||
|
"itsybitsy-wap",
|
||||||
|
"log",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -197,6 +271,57 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsybitsy-gemtext"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"itsybitsy-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsybitsy-wap"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"itsybitsy-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"jiff-core",
|
||||||
|
"jiff-static",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-core"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-core",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.190"
|
version = "0.2.190"
|
||||||
|
|
@ -209,6 +334,12 @@ version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
|
|
@ -227,6 +358,21 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
|
|
@ -242,7 +388,7 @@ version = "0.13.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"memchr",
|
"memchr",
|
||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
@ -262,13 +408,42 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
|
|
@ -302,7 +477,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -320,6 +495,17 @@ version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
|
|
@ -344,6 +530,26 @@ dependencies = [
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.6+spec-1.1.0"
|
version = "1.1.6+spec-1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
# Format crates (gemtext, wap, text) join at the milestone that implements them.
|
# Format crates join at the milestone that implements them.
|
||||||
members = ["core", "bin"]
|
members = ["core", "gemtext", "wap", "bin"]
|
||||||
default-members = ["bin"]
|
default-members = ["bin"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -8,15 +8,15 @@ A Rust re-implementation of [smolweb](https://code.randogoth.com/randogoth/smolw
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Early. Configuration, path resolution and Markdown parsing are implemented and tested; nothing is served yet.
|
It serves. HTTP, Spartan and Nex are working, with gemtext, XHTML-MP and HTML output and virtual hosting across all three.
|
||||||
|
|
||||||
| Area | State |
|
| Area | State |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Server configuration, virtual hosts, `--check` | implemented |
|
| Server configuration, virtual hosts, `--check` | implemented |
|
||||||
| Path resolution, traversal containment, live reload | implemented |
|
| Path resolution, traversal containment, live reload | implemented |
|
||||||
| Markdown parsing, includes, ASCII art, card breaks | implemented |
|
| Markdown parsing, includes, ASCII art, card breaks | implemented |
|
||||||
| gemtext and HTML output | planned |
|
| gemtext, XHTML-MP and HTML output | implemented |
|
||||||
| HTTP, Spartan and Nex listeners | planned |
|
| HTTP, Spartan and Nex listeners, virtual hosting | implemented |
|
||||||
| 80-column text and Nex output | planned |
|
| 80-column text and Nex output | planned |
|
||||||
| WML and XHTML-MP decks | planned |
|
| WML and XHTML-MP decks | planned |
|
||||||
| Gemini and Gopher | planned |
|
| Gemini and Gopher | planned |
|
||||||
|
|
@ -37,8 +37,16 @@ protocol = "http"
|
||||||
bind = "0.0.0.0:8080"
|
bind = "0.0.0.0:8080"
|
||||||
formats = ["xhtmlmp", "html"]
|
formats = ["xhtmlmp", "html"]
|
||||||
default_site = "smol"
|
default_site = "smol"
|
||||||
|
|
||||||
|
[listener.nex]
|
||||||
|
protocol = "nex"
|
||||||
|
bind = "0.0.0.0:1900"
|
||||||
|
formats = ["gemtext"]
|
||||||
|
site = "smol"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A page is rendered into the union of what the enabled listeners can serve, and no more: a configuration with no HTTP listener never builds any HTML. HTTP and Spartan route by the hostname the request carries, falling back to `default_site`; Nex carries none, so its listener names one site outright.
|
||||||
|
|
||||||
A `.itsybitsy.toml` in any content directory says how the Markdown in **that directory** renders. There is no inheritance: a subdirectory without its own file uses the built-in defaults rather than its parent's. Repetition in a deep tree is the price of never having to look elsewhere to know how a page renders.
|
A `.itsybitsy.toml` in any content directory says how the Markdown in **that directory** renders. There is no inheritance: a subdirectory without its own file uses the built-in defaults rather than its parent's. Repetition in a deep tree is the price of never having to look elsewhere to know how a page renders.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|
@ -92,6 +100,20 @@ Include and art targets resolve relative to the file holding the reference and m
|
||||||
|
|
||||||
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
||||||
|
|
||||||
|
## Output formats
|
||||||
|
|
||||||
|
Each format is a crate implementing one trait, compiled in behind a cargo feature. A renderer is handed the parsed document and may not open files or sockets, which is what keeps every path check in one place rather than in each format.
|
||||||
|
|
||||||
|
| Format | Served as | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gemtext` | `text/gemini` | Unwrapped; Gemini and Spartan clients wrap for themselves |
|
||||||
|
| `xhtmlmp` | `application/vnd.wap.xhtml+xml` | Well-formed XML with the Mobile Profile doctype |
|
||||||
|
| `html` | `text/html` | The same markup without the XML declaration |
|
||||||
|
|
||||||
|
HTTP negotiates between them from `Accept`. Only a literal media type counts as a match, so a browser's `*/*` can never be read as willingness to receive a WAP format; `?format=<id>` overrides negotiation outright, and naming an id the listener does not serve is a bad request rather than a silent fallback.
|
||||||
|
|
||||||
|
A third party adds a format by writing a crate that depends on `itsybitsy-core`, adding it to the workspace and one `#[cfg]` arm in the binary's registry, then naming its id in `formats`. Every listener's formats are checked against the registry at startup, so a format switched off by a feature is a configuration error naming the missing id rather than a server error on the first request.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Links should be root-relative and extensionless (`[about](/about)`, not `about.md`), so the same link resolves identically from every protocol.
|
Links should be root-relative and extensionless (`[about](/about)`, not `about.md`), so the same link resolves identically from every protocol.
|
||||||
|
|
@ -111,4 +133,7 @@ Nothing outside the content root is reachable. A request target is percent-decod
|
||||||
devbox run check # fmt --check, clippy -D warnings, test
|
devbox run check # fmt --check, clippy -D warnings, test
|
||||||
devbox run test
|
devbox run test
|
||||||
devbox run build
|
devbox run build
|
||||||
|
devbox run run # serve ./content on localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`RUST_LOG` sets the log level, which defaults to `info`: one line per request, and the real cause on failure.
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,27 @@ publish = false
|
||||||
name = "itsybitsy"
|
name = "itsybitsy"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["http", "spartan", "nex", "gemtext", "wap"]
|
||||||
|
http = []
|
||||||
|
spartan = []
|
||||||
|
nex = []
|
||||||
|
gemtext = ["dep:itsybitsy-gemtext"]
|
||||||
|
wap = ["dep:itsybitsy-wap"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4.6", features = ["derive"] }
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||||
itsybitsy-core = { path = "../core" }
|
itsybitsy-core = { path = "../core" }
|
||||||
|
itsybitsy-gemtext = { path = "../gemtext", optional = true }
|
||||||
|
itsybitsy-wap = { path = "../wap", optional = true }
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
itsybitsy-core = { path = "../core" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
toml = "1.1"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
|
||||||
|
|
@ -5,36 +5,45 @@
|
||||||
//! listener on port 0 in-process.
|
//! listener on port 0 in-process.
|
||||||
|
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod proto;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod serve;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use itsybitsy_core::config::{Checked, ServerConfig};
|
use itsybitsy_core::config::{Checked, ServerConfig};
|
||||||
|
use itsybitsy_core::render::Registry;
|
||||||
use itsybitsy_core::siteset::SiteSet;
|
use itsybitsy_core::siteset::SiteSet;
|
||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
|
|
||||||
pub fn run(cli: &Cli) -> Result<()> {
|
pub fn run(cli: &Cli) -> Result<()> {
|
||||||
let formats = registry::format_ids();
|
let registry = Arc::new(registry::build());
|
||||||
|
let (config, checked, sites) = load(&cli.config, ®istry.ids(), registry.clone())?;
|
||||||
if cli.check {
|
if cli.check {
|
||||||
return check(&cli.config, &formats);
|
report(&config, &checked, &sites);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
let _ = load(&cli.config, &formats)?;
|
serve::run(&config, registry, Arc::new(sites))
|
||||||
anyhow::bail!("serving is not implemented yet; run with --check to validate the configuration")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a configuration and report what it resolved to.
|
/// Validate a configuration and report what it resolved to.
|
||||||
///
|
///
|
||||||
/// Takes the format set rather than reading [`registry`] directly, so a test can
|
/// Takes the format set rather than reading [`registry`] directly, so a test can
|
||||||
/// supply the ids a later milestone's registry will provide.
|
/// check a configuration against ids this build may not provide.
|
||||||
pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> {
|
pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> {
|
||||||
let (config, checked, sites) = load(config_path, formats)?;
|
let (config, checked, sites) = load(config_path, formats, Arc::new(registry::build()))?;
|
||||||
report(&config, &checked, &sites);
|
report(&config, &checked, &sites);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked, SiteSet)> {
|
fn load(
|
||||||
|
config_path: &Path,
|
||||||
|
formats: &[&str],
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
) -> Result<(ServerConfig, Checked, SiteSet)> {
|
||||||
// No context on load: its errors already name the file.
|
// No context on load: its errors already name the file.
|
||||||
let config = ServerConfig::load(config_path)?;
|
let config = ServerConfig::load(config_path)?;
|
||||||
let checked = config
|
let checked = config
|
||||||
|
|
@ -42,7 +51,8 @@ fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked,
|
||||||
.with_context(|| format!("validating {}", config_path.display()))?;
|
.with_context(|| format!("validating {}", config_path.display()))?;
|
||||||
// Opening every root here means a broken site fails at startup rather than
|
// Opening every root here means a broken site fails at startup rather than
|
||||||
// on the first request that happens to name it.
|
// on the first request that happens to name it.
|
||||||
let sites = SiteSet::build(&config)
|
let rendered: Vec<String> = checked.formats.iter().cloned().collect();
|
||||||
|
let sites = SiteSet::build(&config, registry, rendered)
|
||||||
.with_context(|| format!("opening the sites in {}", config_path.display()))?;
|
.with_context(|| format!("opening the sites in {}", config_path.display()))?;
|
||||||
Ok((config, checked, sites))
|
Ok((config, checked, sites))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ use clap::Parser;
|
||||||
use itsybitsy::cli::Cli;
|
use itsybitsy::cli::Cli;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
|
// `info` by default so a running server says what it is serving; `RUST_LOG`
|
||||||
|
// overrides it, as it does across the rest of the tree.
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match itsybitsy::run(&cli) {
|
match itsybitsy::run(&cli) {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
|
|
||||||
224
bin/src/proto/http.rs
Normal file
224
bin/src/proto/http.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
//! HTTP/1.1, hand-rolled and deliberately narrow: `GET` and `HEAD`, origin-form
|
||||||
|
//! targets, one response per connection.
|
||||||
|
//!
|
||||||
|
//! A reverse proxy in front sends origin-form, so absolute-form is refused rather
|
||||||
|
//! than half-supported. There is no keep-alive, no chunked encoding and no
|
||||||
|
//! pipelining; `Connection: close` is always sent so a client knows it.
|
||||||
|
|
||||||
|
use std::io::{BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use itsybitsy_core::site::{Resolution, Resource};
|
||||||
|
|
||||||
|
use crate::proto::{for_log, negotiate, read_line_capped};
|
||||||
|
use crate::serve::Listener;
|
||||||
|
|
||||||
|
const MAX_REQUEST_LINE: usize = 8192;
|
||||||
|
const MAX_HEADER_LINE: usize = 8192;
|
||||||
|
const MAX_HEADERS: usize = 64;
|
||||||
|
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
|
let mut reader = BufReader::new(stream.try_clone()?);
|
||||||
|
let Some(line) = read_line_capped(&mut reader, MAX_REQUEST_LINE) else {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
};
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
let [method, target, version] = parts.as_slice() else {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
};
|
||||||
|
if !version.starts_with("HTTP/1.") {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
}
|
||||||
|
// Origin-form only. smolweb's `urlsplit` would have mishandled the others.
|
||||||
|
if !target.starts_with('/') {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut accept = None;
|
||||||
|
let mut total = 0usize;
|
||||||
|
for index in 0.. {
|
||||||
|
let Some(header) = read_line_capped(&mut reader, MAX_HEADER_LINE) else {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
};
|
||||||
|
if header.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += header.len();
|
||||||
|
if index >= MAX_HEADERS || total > MAX_HEADER_BYTES {
|
||||||
|
return bad_request(&mut stream);
|
||||||
|
}
|
||||||
|
let Some((name, value)) = header.split_once(':') else { continue };
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"accept" => accept = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let head_only = *method == "HEAD";
|
||||||
|
if *method != "GET" && !head_only {
|
||||||
|
return respond(
|
||||||
|
&mut stream,
|
||||||
|
405,
|
||||||
|
"Method Not Allowed",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"Method not allowed\n",
|
||||||
|
&[("Allow", "GET, HEAD")],
|
||||||
|
// Not HEAD: that case does not reach here.
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing Host on HTTP/1.1 is a malformed request, not a missing resource:
|
||||||
|
// 404 would imply the server looked somewhere.
|
||||||
|
let Some(host) = host else { return bad_request(&mut stream) };
|
||||||
|
let Some(site) = listener.site_for(&host) else {
|
||||||
|
log::info!("{} http unknown host {}", listener.name, for_log(&host));
|
||||||
|
return respond(
|
||||||
|
&mut stream,
|
||||||
|
404,
|
||||||
|
"Not Found",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"Not found\n",
|
||||||
|
&[],
|
||||||
|
head_only,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (path, query) = target.split_once('?').map_or((*target, ""), |(p, q)| (p, q));
|
||||||
|
log::info!("{} http {} {}", listener.name, for_log(&host), for_log(target));
|
||||||
|
|
||||||
|
// `?format=` overrides negotiation, for testing without the hardware.
|
||||||
|
let override_format = query
|
||||||
|
.split('&')
|
||||||
|
.find_map(|pair| pair.strip_prefix("format="))
|
||||||
|
.map(|value| value.to_ascii_lowercase());
|
||||||
|
let format = match &override_format {
|
||||||
|
Some(id) if listener.formats.iter().any(|f| f == id) => id.clone(),
|
||||||
|
Some(_) => {
|
||||||
|
return respond(
|
||||||
|
&mut stream,
|
||||||
|
400,
|
||||||
|
"Bad Request",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"Unknown format\n",
|
||||||
|
&[],
|
||||||
|
head_only,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
negotiate::choose(&listener.registry, &listener.formats, accept.as_deref()).to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match site.resolve(path) {
|
||||||
|
Ok(Resolution::Found(Resource::Document { page, .. })) => {
|
||||||
|
let Some(body) = page.body(&format) else {
|
||||||
|
return respond(
|
||||||
|
&mut stream,
|
||||||
|
500,
|
||||||
|
"Internal Server Error",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"",
|
||||||
|
&[],
|
||||||
|
head_only,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let cache = page.settings.cache_control.map(|age| format!("max-age={age}"));
|
||||||
|
let mut headers: Vec<(&str, &str)> = Vec::new();
|
||||||
|
// The response body depends on Accept, so a shared cache must not
|
||||||
|
// serve one client's format to another.
|
||||||
|
headers.push(("Vary", "Accept"));
|
||||||
|
if let Some(cache) = &cache {
|
||||||
|
headers.push(("Cache-Control", cache));
|
||||||
|
}
|
||||||
|
respond(&mut stream, 200, "OK", listener.media_type(&format), body, &headers, head_only)
|
||||||
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
||||||
|
let meta = std::fs::metadata(&path)?;
|
||||||
|
write_head(&mut stream, 200, "OK", media_type, meta.len(), &[])?;
|
||||||
|
if !head_only {
|
||||||
|
let mut file = std::fs::File::open(&path)?;
|
||||||
|
// Streamed, not buffered: smolweb reads the whole file into
|
||||||
|
// memory on every request.
|
||||||
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(Resolution::Redirect(location)) => respond(
|
||||||
|
&mut stream,
|
||||||
|
301,
|
||||||
|
"Moved Permanently",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"",
|
||||||
|
&[("Location", location.as_str())],
|
||||||
|
head_only,
|
||||||
|
),
|
||||||
|
Ok(Resolution::NotFound) => respond(
|
||||||
|
&mut stream,
|
||||||
|
404,
|
||||||
|
"Not Found",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"Not found\n",
|
||||||
|
&[],
|
||||||
|
head_only,
|
||||||
|
),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("{} http {}: {err}", listener.name, for_log(path));
|
||||||
|
respond(
|
||||||
|
&mut stream,
|
||||||
|
500,
|
||||||
|
"Internal Server Error",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"",
|
||||||
|
&[],
|
||||||
|
head_only,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(stream: &mut TcpStream) -> Result<()> {
|
||||||
|
respond(stream, 400, "Bad Request", "text/plain; charset=utf-8", b"Bad request\n", &[], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
code: u16,
|
||||||
|
reason: &str,
|
||||||
|
media_type: &str,
|
||||||
|
body: &[u8],
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
head_only: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
write_head(stream, code, reason, media_type, body.len() as u64, headers)?;
|
||||||
|
// HEAD sends the headers a GET would, including the length, and no body.
|
||||||
|
if !head_only {
|
||||||
|
stream.write_all(body)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_head(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
code: u16,
|
||||||
|
reason: &str,
|
||||||
|
media_type: &str,
|
||||||
|
length: u64,
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut head = format!(
|
||||||
|
"HTTP/1.1 {code} {reason}\r\nContent-Type: {media_type}\r\nContent-Length: {length}\r\n"
|
||||||
|
);
|
||||||
|
for (name, value) in headers {
|
||||||
|
head.push_str(&format!("{name}: {value}\r\n"));
|
||||||
|
}
|
||||||
|
head.push_str("Connection: close\r\n\r\n");
|
||||||
|
stream.write_all(head.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
104
bin/src/proto/mod.rs
Normal file
104
bin/src/proto/mod.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
//! Shared plumbing for the protocol listeners.
|
||||||
|
|
||||||
|
pub mod http;
|
||||||
|
pub mod negotiate;
|
||||||
|
pub mod nex;
|
||||||
|
pub mod spartan;
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, BufReader, Read};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Apply read and write timeouts to an accepted connection, so a client that
|
||||||
|
/// stops talking cannot hold a thread indefinitely.
|
||||||
|
pub fn set_timeouts(stream: &TcpStream, secs: u64) -> io::Result<()> {
|
||||||
|
let timeout = Some(Duration::from_secs(secs));
|
||||||
|
stream.set_read_timeout(timeout)?;
|
||||||
|
stream.set_write_timeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one CRLF- or LF-terminated line, refusing anything longer than `cap`.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the line exceeds the cap or the connection ends before a
|
||||||
|
/// terminator, both of which the caller answers with its bad-request status.
|
||||||
|
pub fn read_line_capped<R: Read>(reader: &mut BufReader<R>, cap: usize) -> Option<String> {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
// One past the cap, so a line of exactly `cap` bytes is still accepted.
|
||||||
|
let mut limited = reader.take(cap as u64 + 1);
|
||||||
|
limited.read_until(b'\n', &mut line).ok()?;
|
||||||
|
if line.len() > cap || !line.ends_with(b"\n") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
while line.last().is_some_and(|b| *b == b'\n' || *b == b'\r') {
|
||||||
|
line.pop();
|
||||||
|
}
|
||||||
|
String::from_utf8(line).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render untrusted text safe to put in a log line.
|
||||||
|
///
|
||||||
|
/// A request line can carry control bytes, and a newline in a log is how one
|
||||||
|
/// request's text becomes what looks like another's entry.
|
||||||
|
pub fn for_log(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars().take(256) {
|
||||||
|
if ch.is_control() {
|
||||||
|
out.push_str(&format!("\\x{:02x}", ch as u32 & 0xff));
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn read(input: &[u8], cap: usize) -> Option<String> {
|
||||||
|
read_line_capped(&mut BufReader::new(input), cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_a_line_with_either_terminator() {
|
||||||
|
assert_eq!(read(b"GET /\r\n", 64).as_deref(), Some("GET /"));
|
||||||
|
assert_eq!(read(b"GET /\n", 64).as_deref(), Some("GET /"));
|
||||||
|
assert_eq!(read(b"\n", 64).as_deref(), Some(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stops_at_the_first_line() {
|
||||||
|
assert_eq!(read(b"one\ntwo\n", 64).as_deref(), Some("one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_a_line_longer_than_the_cap() {
|
||||||
|
// The cap counts the terminator, so this is the longest accepted line.
|
||||||
|
assert_eq!(read(b"abcd\n", 5).as_deref(), Some("abcd"));
|
||||||
|
assert_eq!(read(b"abcde\n", 5), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_an_unterminated_line() {
|
||||||
|
// Otherwise a client could send a partial request and have it served.
|
||||||
|
assert_eq!(read(b"GET /", 64), None);
|
||||||
|
assert_eq!(read(b"", 64), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_a_line_that_is_not_utf8() {
|
||||||
|
assert_eq!(read(b"\xff\xfe\n", 64), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_control_bytes_for_logging() {
|
||||||
|
// A newline here is how one request forges another's log entry.
|
||||||
|
assert_eq!(for_log("GET /\r\nInjected: yes"), "GET /\\x0d\\x0aInjected: yes");
|
||||||
|
assert_eq!(for_log("/ordinary/path"), "/ordinary/path");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncates_a_very_long_value_for_logging() {
|
||||||
|
assert_eq!(for_log(&"a".repeat(1000)).len(), 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
bin/src/proto/negotiate.rs
Normal file
176
bin/src/proto/negotiate.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
//! Choosing an output format from an HTTP `Accept` header.
|
||||||
|
//!
|
||||||
|
//! The one invariant: a wildcard never selects a format the client did not name
|
||||||
|
//! outright. A browser sends `Accept: text/html, application/xhtml+xml;q=0.9,
|
||||||
|
//! */*;q=0.8`, and that `*/*` must not be read as willingness to receive WML.
|
||||||
|
//! Only a literal media type counts as a match.
|
||||||
|
|
||||||
|
use itsybitsy_core::render::Registry;
|
||||||
|
|
||||||
|
/// Pick a format id from `accept`, out of the listener's `formats`.
|
||||||
|
///
|
||||||
|
/// `formats` is in preference order; the last entry is the fallback used when the
|
||||||
|
/// client named nothing this listener can serve, which is the ordinary case for a
|
||||||
|
/// browser.
|
||||||
|
pub fn choose<'a>(registry: &Registry, formats: &'a [String], accept: Option<&str>) -> &'a str {
|
||||||
|
let fallback = formats.last().map(String::as_str).unwrap_or("");
|
||||||
|
let Some(accept) = accept else { return fallback };
|
||||||
|
|
||||||
|
let ranges = parse(accept);
|
||||||
|
let mut best: Option<(&'a str, f32, usize)> = None;
|
||||||
|
for (index, id) in formats.iter().enumerate() {
|
||||||
|
let Some(renderer) = registry.get(id) else { continue };
|
||||||
|
let media_type = base_type(renderer.media_type());
|
||||||
|
// Literal matches only: a wildcard range is ignored entirely.
|
||||||
|
let Some(quality) = ranges
|
||||||
|
.iter()
|
||||||
|
.filter(|(range, _)| *range == media_type)
|
||||||
|
.map(|(_, q)| *q)
|
||||||
|
.fold(None, |best: Option<f32>, q| Some(best.map_or(q, |b: f32| b.max(q))))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if quality <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A tie on quality is broken by the listener's own preference order.
|
||||||
|
let better = best.is_none_or(|(_, best_q, best_index)| {
|
||||||
|
quality > best_q || (quality == best_q && index < best_index)
|
||||||
|
});
|
||||||
|
if better {
|
||||||
|
best = Some((formats[index].as_str(), quality, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(id, _, _)| id).unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(media type, quality)` pairs, with parameters other than `q` discarded.
|
||||||
|
fn parse(accept: &str) -> Vec<(String, f32)> {
|
||||||
|
accept
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|range| {
|
||||||
|
let mut parts = range.split(';');
|
||||||
|
let media_type = parts.next()?.trim().to_ascii_lowercase();
|
||||||
|
if media_type.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let quality = parts
|
||||||
|
.find_map(|param| {
|
||||||
|
let (name, value) = param.split_once('=')?;
|
||||||
|
(name.trim().eq_ignore_ascii_case("q")).then(|| value.trim())
|
||||||
|
})
|
||||||
|
.and_then(|value| value.parse::<f32>().ok())
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
Some((media_type, quality))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A renderer's media type without its parameters, for comparison against a range.
|
||||||
|
fn base_type(media_type: &str) -> String {
|
||||||
|
media_type.split(';').next().unwrap_or(media_type).trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use itsybitsy_core::Error;
|
||||||
|
use itsybitsy_core::ir::Doc;
|
||||||
|
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Stub(&'static str, &'static str);
|
||||||
|
|
||||||
|
impl Renderer for Stub {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, _doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
Ok(Rendered::body(Vec::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry() -> Registry {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
registry.insert(Arc::new(Stub("wml", "text/vnd.wap.wml; charset=utf-8"))).unwrap();
|
||||||
|
registry
|
||||||
|
.insert(Arc::new(Stub("xhtmlmp", "application/vnd.wap.xhtml+xml; charset=utf-8")))
|
||||||
|
.unwrap();
|
||||||
|
registry.insert(Arc::new(Stub("html", "text/html; charset=utf-8"))).unwrap();
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formats() -> Vec<String> {
|
||||||
|
["wml", "xhtmlmp", "html"].iter().map(|s| s.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn choose_with(accept: Option<&str>) -> String {
|
||||||
|
choose(®istry(), &formats(), accept).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wildcard_never_selects_a_format_the_client_did_not_name() {
|
||||||
|
// The invariant the whole module exists for.
|
||||||
|
assert_eq!(choose_with(Some("*/*")), "html");
|
||||||
|
assert_eq!(choose_with(Some("text/*")), "html");
|
||||||
|
assert_eq!(choose_with(Some("text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_header_at_all_falls_back() {
|
||||||
|
assert_eq!(choose_with(None), "html");
|
||||||
|
assert_eq!(choose_with(Some("")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_literal_media_type_is_honoured() {
|
||||||
|
assert_eq!(choose_with(Some("text/vnd.wap.wml")), "wml");
|
||||||
|
assert_eq!(choose_with(Some("application/vnd.wap.xhtml+xml")), "xhtmlmp");
|
||||||
|
assert_eq!(choose_with(Some("text/html")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_media_type_with_parameters_still_matches() {
|
||||||
|
assert_eq!(choose_with(Some("text/vnd.wap.wml; charset=utf-8")), "wml");
|
||||||
|
assert_eq!(choose_with(Some("TEXT/VND.WAP.WML")), "wml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_decides_between_two_named_formats() {
|
||||||
|
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.5,text/html;q=0.9")), "html");
|
||||||
|
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.9,text/html;q=0.5")), "wml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tie_breaks_by_the_listeners_preference_order() {
|
||||||
|
assert_eq!(choose_with(Some("text/html,text/vnd.wap.wml")), "wml");
|
||||||
|
let reversed: Vec<String> =
|
||||||
|
["html", "xhtmlmp", "wml"].iter().map(|s| s.to_string()).collect();
|
||||||
|
assert_eq!(choose(®istry(), &reversed, Some("text/html,text/vnd.wap.wml")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_quality_refuses_that_format() {
|
||||||
|
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_format_this_listener_does_not_serve_is_not_chosen() {
|
||||||
|
let only_html = vec!["html".to_string()];
|
||||||
|
assert_eq!(choose(®istry(), &only_html, Some("text/vnd.wap.wml")), "html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_header_falls_back_rather_than_failing() {
|
||||||
|
assert_eq!(choose_with(Some(";;;")), "html");
|
||||||
|
assert_eq!(choose_with(Some("text/html;q=abc")), "html");
|
||||||
|
}
|
||||||
|
}
|
||||||
56
bin/src/proto/nex.rs
Normal file
56
bin/src/proto/nex.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! Nex: a bare path in, bytes out, no status line and no headers.
|
||||||
|
//!
|
||||||
|
//! There is nothing to signal an error with, so a failure is a plain-text body,
|
||||||
|
//! and nothing to redirect with either, which is why resolution follows the
|
||||||
|
//! canonical target server-side rather than bouncing the client.
|
||||||
|
|
||||||
|
use std::io::{BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use itsybitsy_core::site::{Resolution, Resource};
|
||||||
|
|
||||||
|
use crate::proto::{for_log, read_line_capped};
|
||||||
|
use crate::serve::Listener;
|
||||||
|
|
||||||
|
/// Nex requests are a single path; the cap is smolweb's.
|
||||||
|
const MAX_REQUEST: usize = 2048;
|
||||||
|
|
||||||
|
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
|
let selector = {
|
||||||
|
let mut reader = BufReader::new(stream.try_clone()?);
|
||||||
|
read_line_capped(&mut reader, MAX_REQUEST)
|
||||||
|
};
|
||||||
|
let Some(selector) = selector else {
|
||||||
|
log::warn!("{}: unreadable or oversized request", listener.name);
|
||||||
|
stream.write_all(b"Bad request\n")?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nex carries no hostname, so the listener names its site outright and
|
||||||
|
// configuration validation has already proved it exists.
|
||||||
|
let site = listener.only_site();
|
||||||
|
let path = if selector.is_empty() { "/" } else { &selector };
|
||||||
|
log::info!("{} nex {}", listener.name, for_log(path));
|
||||||
|
|
||||||
|
match site.resolve_flat(path) {
|
||||||
|
Ok(Resolution::Found(Resource::Document { url, page })) => {
|
||||||
|
match page.body(&listener.formats[0]) {
|
||||||
|
Some(body) => stream.write_all(body)?,
|
||||||
|
None => stream.write_all(b"Internal error\n")?,
|
||||||
|
}
|
||||||
|
let _ = url;
|
||||||
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Raw { path, .. })) => {
|
||||||
|
let mut file = std::fs::File::open(&path)?;
|
||||||
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
|
}
|
||||||
|
// resolve_flat never yields a redirect; it is matched for completeness.
|
||||||
|
Ok(_) => stream.write_all(b"Not found\n")?,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("{} nex {}: {err}", listener.name, for_log(path));
|
||||||
|
stream.write_all(b"Internal error\n")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
86
bin/src/proto/spartan.rs
Normal file
86
bin/src/proto/spartan.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
//! Spartan: `HOST PATH LENGTH` in, a one-digit status and a body out.
|
||||||
|
//!
|
||||||
|
//! The host field is what smolweb discards; here it selects the virtual host,
|
||||||
|
//! falling back to the listener's `default_site` when it names nothing known.
|
||||||
|
|
||||||
|
use std::io::{BufReader, Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use itsybitsy_core::site::{Resolution, Resource};
|
||||||
|
|
||||||
|
use crate::proto::{for_log, read_line_capped};
|
||||||
|
use crate::serve::Listener;
|
||||||
|
|
||||||
|
const MAX_REQUEST: usize = 4096;
|
||||||
|
/// An upload is refused either way, but a small body is drained first so the
|
||||||
|
/// connection stays in sync; a large one is not worth reading to discard.
|
||||||
|
const MAX_DRAIN: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
|
let mut reader = BufReader::new(stream.try_clone()?);
|
||||||
|
let Some(line) = read_line_capped(&mut reader, MAX_REQUEST) else {
|
||||||
|
log::warn!("{}: unreadable or oversized request", listener.name);
|
||||||
|
return status(&mut stream, 4, "Bad request", b"");
|
||||||
|
};
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.split(' ').collect();
|
||||||
|
let [host, path, length] = parts.as_slice() else {
|
||||||
|
log::warn!("{} spartan: malformed request {}", listener.name, for_log(&line));
|
||||||
|
return status(&mut stream, 4, "Bad request", b"");
|
||||||
|
};
|
||||||
|
let Ok(length) = length.parse::<u64>() else {
|
||||||
|
return status(&mut stream, 4, "Bad request", b"");
|
||||||
|
};
|
||||||
|
|
||||||
|
if length > 0 {
|
||||||
|
// Drain before refusing, or the client's body would be read as the next
|
||||||
|
// request on a reused connection.
|
||||||
|
if length <= MAX_DRAIN {
|
||||||
|
let mut sink = io_sink();
|
||||||
|
std::io::copy(&mut reader.take(length), &mut sink)?;
|
||||||
|
}
|
||||||
|
return status(&mut stream, 4, "Uploads are not accepted", b"");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(site) = listener.site_for(host) else {
|
||||||
|
log::info!("{} spartan unknown host {}", listener.name, for_log(host));
|
||||||
|
return status(&mut stream, 4, "Unknown host", b"");
|
||||||
|
};
|
||||||
|
log::info!("{} spartan {} {}", listener.name, for_log(host), for_log(path));
|
||||||
|
|
||||||
|
let format = &listener.formats[0];
|
||||||
|
match site.resolve(path) {
|
||||||
|
Ok(Resolution::Found(Resource::Document { page, .. })) => match page.body(format) {
|
||||||
|
Some(body) => {
|
||||||
|
let media_type = listener.media_type(format);
|
||||||
|
status(&mut stream, 2, media_type, body)
|
||||||
|
}
|
||||||
|
None => status(&mut stream, 5, "Not rendered", b""),
|
||||||
|
},
|
||||||
|
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
||||||
|
write!(stream, "2 {media_type}\r\n")?;
|
||||||
|
let mut file = std::fs::File::open(&path)?;
|
||||||
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(Resolution::Redirect(location)) => status(&mut stream, 3, &location, b""),
|
||||||
|
Ok(Resolution::NotFound) => status(&mut stream, 4, "Not found", b""),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("{} spartan {}: {err}", listener.name, for_log(path));
|
||||||
|
status(&mut stream, 5, "Internal error", b"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(stream: &mut TcpStream, code: u8, meta: &str, body: &[u8]) -> Result<()> {
|
||||||
|
write!(stream, "{code} {meta}\r\n")?;
|
||||||
|
if !body.is_empty() {
|
||||||
|
stream.write_all(body)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_sink() -> std::io::Sink {
|
||||||
|
std::io::sink()
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,21 @@
|
||||||
//! formats it was asked for. The registry is built once at startup and shared
|
//! formats it was asked for. The registry is built once at startup and shared
|
||||||
//! immutably across every connection.
|
//! immutably across every connection.
|
||||||
|
|
||||||
/// Ids of the formats this build provides.
|
use std::sync::Arc;
|
||||||
pub fn format_ids() -> Vec<&'static str> {
|
|
||||||
Vec::new()
|
use itsybitsy_core::render::Registry;
|
||||||
|
|
||||||
|
pub fn build() -> Registry {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "gemtext")]
|
||||||
|
registry.insert(Arc::new(itsybitsy_gemtext::Gemtext)).expect("duplicate renderer id");
|
||||||
|
|
||||||
|
#[cfg(feature = "wap")]
|
||||||
|
{
|
||||||
|
registry.insert(Arc::new(itsybitsy_wap::XhtmlMp)).expect("duplicate renderer id");
|
||||||
|
registry.insert(Arc::new(itsybitsy_wap::Html)).expect("duplicate renderer id");
|
||||||
|
}
|
||||||
|
|
||||||
|
registry
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
bin/src/serve.rs
Normal file
202
bin/src/serve.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
//! Binding the listeners and dispatching connections.
|
||||||
|
//!
|
||||||
|
//! One thread per connection, with a per-listener cap. The work is read a short
|
||||||
|
//! line, stat a file, maybe render, write a buffer, close: blocking and
|
||||||
|
//! CPU-bound rather than connection-bound, which is the shape threads suit.
|
||||||
|
//! Async's advantage is many idle sockets, and this server has few busy ones.
|
||||||
|
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use itsybitsy_core::config::{ListenerSpec, Protocol, ServerConfig};
|
||||||
|
use itsybitsy_core::render::Registry;
|
||||||
|
use itsybitsy_core::site::Site;
|
||||||
|
use itsybitsy_core::siteset::SiteSet;
|
||||||
|
|
||||||
|
use crate::proto;
|
||||||
|
|
||||||
|
/// Everything a connection handler needs, shared immutably across its threads.
|
||||||
|
pub struct Listener {
|
||||||
|
pub name: String,
|
||||||
|
pub protocol: Protocol,
|
||||||
|
/// Formats in preference order. Only HTTP has more than one.
|
||||||
|
pub formats: Vec<String>,
|
||||||
|
pub registry: Arc<Registry>,
|
||||||
|
pub sites: Arc<SiteSet>,
|
||||||
|
/// Served when a request names a host this server does not know.
|
||||||
|
pub default_site: Option<String>,
|
||||||
|
/// The single site served by a protocol that carries no hostname.
|
||||||
|
pub site: Option<String>,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
max_connections: u32,
|
||||||
|
open: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
/// The site a request naming `host` is served from, falling back to
|
||||||
|
/// `default_site`.
|
||||||
|
pub fn site_for(&self, host: &str) -> Option<&Arc<Site>> {
|
||||||
|
self.sites
|
||||||
|
.lookup(host)
|
||||||
|
.or_else(|| self.default_site.as_deref().and_then(|name| self.sites.by_name(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one site a hostless protocol serves. Configuration validation proved
|
||||||
|
/// it exists, and with a single site defined the name may be left implicit.
|
||||||
|
pub fn only_site(&self) -> &Arc<Site> {
|
||||||
|
match &self.site {
|
||||||
|
Some(name) => self.sites.by_name(name).expect("validated at startup"),
|
||||||
|
None => self.sites.first().expect("at least one site is validated at startup"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn media_type(&self, format: &str) -> &'static str {
|
||||||
|
self.registry.get(format).map(|r| r.media_type()).unwrap_or("application/octet-stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listener that has its socket but is not yet serving.
|
||||||
|
pub struct Bound {
|
||||||
|
pub listener: Arc<Listener>,
|
||||||
|
pub socket: TcpListener,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bound {
|
||||||
|
pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> {
|
||||||
|
self.socket.local_addr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind every listener without serving any of them.
|
||||||
|
///
|
||||||
|
/// Separate from serving so that a port already in use is a startup failure
|
||||||
|
/// rather than one listener quietly missing, and so a test can learn the address
|
||||||
|
/// of a listener bound to port 0 before sending to it.
|
||||||
|
pub fn bind(
|
||||||
|
config: &ServerConfig,
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
sites: Arc<SiteSet>,
|
||||||
|
) -> Result<Vec<Bound>> {
|
||||||
|
let mut bound = Vec::new();
|
||||||
|
for (name, spec) in &config.listener {
|
||||||
|
let socket = TcpListener::bind(&spec.bind)
|
||||||
|
.with_context(|| format!("listener '{name}' binding {}", spec.bind))?;
|
||||||
|
bound.push(Bound { listener: Arc::new(listener(name, spec, ®istry, &sites)), socket });
|
||||||
|
}
|
||||||
|
Ok(bound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start one accept thread per bound listener.
|
||||||
|
pub fn spawn_all(bound: Vec<Bound>) -> Vec<thread::JoinHandle<()>> {
|
||||||
|
bound
|
||||||
|
.into_iter()
|
||||||
|
.map(|Bound { listener, socket }| thread::spawn(move || accept_loop(listener, socket)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind every listener, then serve until the process ends.
|
||||||
|
pub fn run(config: &ServerConfig, registry: Arc<Registry>, sites: Arc<SiteSet>) -> Result<()> {
|
||||||
|
let bound = bind(config, registry, sites)?;
|
||||||
|
for entry in &bound {
|
||||||
|
let local = entry.local_addr().map(|a| a.to_string()).unwrap_or_default();
|
||||||
|
log::info!(
|
||||||
|
"{} {} listening on {local}",
|
||||||
|
entry.listener.name,
|
||||||
|
entry.listener.protocol.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for thread in spawn_all(bound) {
|
||||||
|
// A panicking accept loop is a bug; the others keep serving.
|
||||||
|
if thread.join().is_err() {
|
||||||
|
log::error!("an accept loop panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listener(
|
||||||
|
name: &str,
|
||||||
|
spec: &ListenerSpec,
|
||||||
|
registry: &Arc<Registry>,
|
||||||
|
sites: &Arc<SiteSet>,
|
||||||
|
) -> Listener {
|
||||||
|
Listener {
|
||||||
|
name: name.to_string(),
|
||||||
|
protocol: spec.protocol,
|
||||||
|
formats: spec.formats.clone(),
|
||||||
|
registry: registry.clone(),
|
||||||
|
sites: sites.clone(),
|
||||||
|
default_site: spec.default_site.clone(),
|
||||||
|
site: spec.site.clone(),
|
||||||
|
timeout_secs: spec.timeout_secs,
|
||||||
|
max_connections: spec.max_connections,
|
||||||
|
open: AtomicU32::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_loop(listener: Arc<Listener>, socket: TcpListener) {
|
||||||
|
for incoming in socket.incoming() {
|
||||||
|
let stream = match incoming {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("{}: accept failed: {err}", listener.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// At the cap, refuse immediately rather than queueing threads without
|
||||||
|
// bound. smolweb has no cap at all.
|
||||||
|
let open = listener.open.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if open >= listener.max_connections {
|
||||||
|
listener.open.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
log::warn!("{}: at {} connections, refusing", listener.name, listener.max_connections);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = listener.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle(&listener, stream);
|
||||||
|
listener.open.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(listener: &Listener, stream: TcpStream) {
|
||||||
|
if let Err(err) = proto::set_timeouts(&stream, listener.timeout_secs) {
|
||||||
|
log::warn!("{}: setting timeouts failed: {err}", listener.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One malformed document must not take the process down, so a panic in a
|
||||||
|
// handler is caught and logged. This is the Rust equivalent of smolweb's
|
||||||
|
// bare `except Exception`, except the cause is recorded rather than lost.
|
||||||
|
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
match listener.protocol {
|
||||||
|
Protocol::Http => proto::http::serve(listener, stream),
|
||||||
|
Protocol::Spartan => proto::spartan::serve(listener, stream),
|
||||||
|
Protocol::Nex => proto::nex::serve(listener, stream),
|
||||||
|
// Validation refuses these until their listeners exist.
|
||||||
|
Protocol::Gemini | Protocol::Gopher => {
|
||||||
|
unreachable!("{} is rejected at startup", listener.protocol.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
match caught {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
// A client that hung up mid-response is ordinary, not an error worth
|
||||||
|
// raising the level for.
|
||||||
|
Ok(Err(err)) => log::debug!("{}: {err}", listener.name),
|
||||||
|
Err(panic) => {
|
||||||
|
let reason = panic
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| panic.downcast_ref::<String>().cloned())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
log::error!("{}: handler panicked: {reason}", listener.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
403
bin/tests/listeners.rs
Normal file
403
bin/tests/listeners.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
//! The three listeners over real sockets, bound on port 0 in-process.
|
||||||
|
//!
|
||||||
|
//! Two sites on one HTTP port, so virtual-host routing is exercised rather than
|
||||||
|
//! assumed.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use itsybitsy::serve;
|
||||||
|
use itsybitsy_core::config::ServerConfig;
|
||||||
|
use itsybitsy_core::siteset::SiteSet;
|
||||||
|
|
||||||
|
/// A running server, with the address of each listener by name.
|
||||||
|
struct Server {
|
||||||
|
addrs: BTreeMap<String, SocketAddr>,
|
||||||
|
_dir: tempfile::TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
fn start() -> Self {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
write(dir.path(), "one/index.md", "# One\n\nFirst site.\n");
|
||||||
|
write(dir.path(), "one/about.md", "# About\n\nAbout the first.\n");
|
||||||
|
write(dir.path(), "one/img.png", "not really a png");
|
||||||
|
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\ncache_control = 120\n");
|
||||||
|
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
||||||
|
|
||||||
|
let text = format!(
|
||||||
|
r#"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[site.one]
|
||||||
|
root = "{one}"
|
||||||
|
hosts = ["one.test"]
|
||||||
|
|
||||||
|
[site.two]
|
||||||
|
root = "{two}"
|
||||||
|
hosts = ["two.test"]
|
||||||
|
|
||||||
|
[listener.web]
|
||||||
|
protocol = "http"
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
formats = ["xhtmlmp", "html"]
|
||||||
|
default_site = "one"
|
||||||
|
|
||||||
|
[listener.strict]
|
||||||
|
protocol = "http"
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
formats = ["html"]
|
||||||
|
|
||||||
|
[listener.spartan]
|
||||||
|
protocol = "spartan"
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
formats = ["gemtext"]
|
||||||
|
default_site = "one"
|
||||||
|
|
||||||
|
[listener.nex]
|
||||||
|
protocol = "nex"
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
formats = ["gemtext"]
|
||||||
|
site = "two"
|
||||||
|
"#,
|
||||||
|
one = dir.path().join("one").to_str().unwrap(),
|
||||||
|
two = dir.path().join("two").to_str().unwrap(),
|
||||||
|
);
|
||||||
|
let config: ServerConfig = toml::from_str(&text).unwrap();
|
||||||
|
let registry = Arc::new(itsybitsy::registry::build());
|
||||||
|
let formats: Vec<String> =
|
||||||
|
["gemtext", "html", "xhtmlmp"].iter().map(|s| s.to_string()).collect();
|
||||||
|
let sites = Arc::new(SiteSet::build(&config, registry.clone(), formats).unwrap());
|
||||||
|
|
||||||
|
let bound = serve::bind(&config, registry, sites).unwrap();
|
||||||
|
let addrs = bound
|
||||||
|
.iter()
|
||||||
|
.map(|entry| (entry.listener.name.clone(), entry.local_addr().unwrap()))
|
||||||
|
.collect();
|
||||||
|
serve::spawn_all(bound);
|
||||||
|
Server { addrs, _dir: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addr(&self, name: &str) -> SocketAddr {
|
||||||
|
self.addrs[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send raw bytes to a listener and read the whole reply.
|
||||||
|
fn send(&self, listener: &str, request: &[u8]) -> String {
|
||||||
|
let mut stream = TcpStream::connect(self.addr(listener)).unwrap();
|
||||||
|
stream.write_all(request).unwrap();
|
||||||
|
stream.flush().unwrap();
|
||||||
|
let mut reply = Vec::new();
|
||||||
|
stream.read_to_end(&mut reply).unwrap();
|
||||||
|
String::from_utf8_lossy(&reply).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, host: &str, target: &str) -> String {
|
||||||
|
self.request("web", host, target, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, listener: &str, host: &str, target: &str, extra: &str) -> String {
|
||||||
|
let request = format!("GET {target} HTTP/1.1\r\nHost: {host}\r\n{extra}\r\n");
|
||||||
|
self.send(listener, request.as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(root: &Path, rel: &str, body: &str) {
|
||||||
|
let path = root.join(rel);
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, body).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a response into its status line and body.
|
||||||
|
fn split(response: &str) -> (&str, &str) {
|
||||||
|
let status = response.lines().next().unwrap_or("");
|
||||||
|
let body = response.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("");
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- HTTP ----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serves_a_document() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/");
|
||||||
|
let (status, body) = split(&response);
|
||||||
|
assert_eq!(status, "HTTP/1.1 200 OK");
|
||||||
|
assert!(body.contains("<h1>One</h1>"), "{body}");
|
||||||
|
assert!(response.contains("Vary: Accept"), "{response}");
|
||||||
|
assert!(response.contains("Cache-Control: max-age=120"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routes_two_sites_on_one_port() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert!(server.get("one.test", "/").contains("<h1>One</h1>"));
|
||||||
|
assert!(server.get("two.test", "/").contains("<h1>Two</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_host_falls_back_to_the_default_site() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert!(server.get("elsewhere.test", "/").contains("<h1>One</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_host_with_no_default_is_not_found() {
|
||||||
|
let server = Server::start();
|
||||||
|
let (status, body) = {
|
||||||
|
let response = server.request("strict", "elsewhere.test", "/", "");
|
||||||
|
(response.lines().next().unwrap().to_string(), response)
|
||||||
|
};
|
||||||
|
assert_eq!(status, "HTTP/1.1 404 Not Found");
|
||||||
|
assert!(body.ends_with("Not found\n"), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_host_header_is_a_bad_request_not_a_missing_page() {
|
||||||
|
// 404 would imply the server looked somewhere.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("web", b"GET / HTTP/1.1\r\n\r\n");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_md_form_redirects_to_the_canonical_url() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/about.md");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 301 Moved Permanently");
|
||||||
|
assert!(response.contains("Location: /about"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_page_is_not_found_with_a_fixed_body() {
|
||||||
|
// The body never echoes the request path.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/nope");
|
||||||
|
let (status, body) = split(&response);
|
||||||
|
assert_eq!(status, "HTTP/1.1 404 Not Found");
|
||||||
|
assert_eq!(body, "Not found\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_raw_file_is_served_with_its_media_type_and_no_vary() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/img.png");
|
||||||
|
assert!(response.contains("Content-Type: image/png"), "{response}");
|
||||||
|
// Its bytes do not depend on Accept, so a shared cache need not vary on it.
|
||||||
|
assert!(!response.contains("Vary:"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_sends_the_headers_of_a_get_and_no_body() {
|
||||||
|
let server = Server::start();
|
||||||
|
let get = server.get("one.test", "/");
|
||||||
|
let head = server.send("web", b"HEAD / HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||||
|
let length = |response: &str| {
|
||||||
|
response.lines().find_map(|line| line.strip_prefix("Content-Length: ")).unwrap().to_string()
|
||||||
|
};
|
||||||
|
assert_eq!(length(&get), length(&head));
|
||||||
|
assert_eq!(split(&head).1, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsupported_method_is_refused_with_the_allowed_set() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("web", b"POST / HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||||
|
let (status, body) = split(&response);
|
||||||
|
assert_eq!(status, "HTTP/1.1 405 Method Not Allowed");
|
||||||
|
assert!(response.contains("Allow: GET, HEAD"), "{response}");
|
||||||
|
// The declared length has to match what is sent, or the response truncates.
|
||||||
|
assert_eq!(body, "Method not allowed\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_response_body_matches_its_declared_length() {
|
||||||
|
let server = Server::start();
|
||||||
|
let cases: Vec<Vec<u8>> = vec![
|
||||||
|
b"GET / HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||||
|
b"GET /nope HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||||
|
b"POST / HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||||
|
b"GARBAGE\r\n\r\n".to_vec(),
|
||||||
|
b"GET /?format=bogus HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||||
|
];
|
||||||
|
for request in cases {
|
||||||
|
let response = server.send("web", &request);
|
||||||
|
let (_, body) = split(&response);
|
||||||
|
let declared: usize = response
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body.len(),
|
||||||
|
declared,
|
||||||
|
"for {:?} -> {response:?}",
|
||||||
|
String::from_utf8_lossy(&request)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absolute_form_target_is_refused() {
|
||||||
|
// A reverse proxy in front sends origin-form.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("web", b"GET http://one.test/ HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_request_line_is_refused() {
|
||||||
|
let server = Server::start();
|
||||||
|
let mut request = b"GET /".to_vec();
|
||||||
|
request.extend(std::iter::repeat_n(b'a', 9000));
|
||||||
|
request.extend_from_slice(b" HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||||
|
assert_eq!(split(&server.send("web", &request)).0, "HTTP/1.1 400 Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_many_headers_are_refused() {
|
||||||
|
let server = Server::start();
|
||||||
|
let mut request = b"GET / HTTP/1.1\r\nHost: one.test\r\n".to_vec();
|
||||||
|
for index in 0..200 {
|
||||||
|
request.extend_from_slice(format!("X-Pad-{index}: x\r\n").as_bytes());
|
||||||
|
}
|
||||||
|
request.extend_from_slice(b"\r\n");
|
||||||
|
assert_eq!(split(&server.send("web", &request)).0, "HTTP/1.1 400 Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Negotiation ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_accept_header_gets_html() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.request(
|
||||||
|
"web",
|
||||||
|
"one.test",
|
||||||
|
"/",
|
||||||
|
"Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\r\n",
|
||||||
|
);
|
||||||
|
assert!(response.contains("Content-Type: text/html"), "{response}");
|
||||||
|
assert!(!split(&response).1.starts_with("<?xml"), "the html form carries no declaration");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_literal_wap_accept_header_gets_xhtml_mobile_profile() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response =
|
||||||
|
server.request("web", "one.test", "/", "Accept: application/vnd.wap.xhtml+xml\r\n");
|
||||||
|
assert!(response.contains("Content-Type: application/vnd.wap.xhtml+xml"), "{response}");
|
||||||
|
assert!(split(&response).1.starts_with("<?xml version=\"1.0\""), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_format_query_overrides_negotiation() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/?format=xhtmlmp");
|
||||||
|
assert!(response.contains("Content-Type: application/vnd.wap.xhtml+xml"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_format_query_is_a_bad_request() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/?format=bogus");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Spartan -------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_serves_gemtext() {
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("spartan", b"one.test / 0\r\n");
|
||||||
|
assert!(response.starts_with("2 text/gemini; charset=utf-8\r\n"), "{response}");
|
||||||
|
assert!(response.contains("# One"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_routes_by_the_host_field_smolweb_discards() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert!(server.send("spartan", b"one.test / 0\r\n").contains("# One"));
|
||||||
|
assert!(server.send("spartan", b"two.test / 0\r\n").contains("# Two"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_redirects_the_md_form() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert_eq!(server.send("spartan", b"one.test /about.md 0\r\n"), "3 /about\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_reports_a_missing_page() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert_eq!(server.send("spartan", b"one.test /nope 0\r\n"), "4 Not found\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_drains_an_upload_then_refuses_it() {
|
||||||
|
// Draining keeps the connection in sync rather than leaving a body to be
|
||||||
|
// read as the next request.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("spartan", b"one.test / 5\r\nhello");
|
||||||
|
assert_eq!(response, "4 Uploads are not accepted\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spartan_refuses_a_malformed_request_line() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert_eq!(server.send("spartan", b"only-two-fields 0\r\n"), "4 Bad request\r\n");
|
||||||
|
assert_eq!(server.send("spartan", b"one.test / notanumber\r\n"), "4 Bad request\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Nex -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nex_sends_bytes_with_no_status_or_headers() {
|
||||||
|
let server = Server::start();
|
||||||
|
// This listener names site two, which has no hosts in its request at all.
|
||||||
|
let response = server.send("nex", b"/\r\n");
|
||||||
|
assert!(response.starts_with("# Two"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nex_resolves_a_redirect_server_side() {
|
||||||
|
// There is no redirect status to bounce with, so the target's content comes
|
||||||
|
// back on the first request.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.send("nex", b"/index.md\r\n");
|
||||||
|
assert!(response.starts_with("# Two"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nex_reports_a_missing_page_as_a_plain_body() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert_eq!(server.send("nex", b"/nope\r\n"), "Not found\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_nex_selector_is_the_root() {
|
||||||
|
let server = Server::start();
|
||||||
|
assert!(server.send("nex", b"\r\n").starts_with("# Two"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Containment ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_outside_the_root_is_reachable_over_any_protocol() {
|
||||||
|
let server = Server::start();
|
||||||
|
for target in ["/../two/index.md", "/%2e%2e/two/index.md", "/.itsybitsy.toml"] {
|
||||||
|
let response = server.get("one.test", target);
|
||||||
|
let status = split(&response).0;
|
||||||
|
assert!(
|
||||||
|
status.starts_with("HTTP/1.1 404") || status.starts_with("HTTP/1.1 301"),
|
||||||
|
"{target} -> {status}"
|
||||||
|
);
|
||||||
|
assert!(!response.contains("<h1>Two</h1>"), "{target} reached the other site");
|
||||||
|
}
|
||||||
|
assert_eq!(server.send("spartan", b"one.test /.itsybitsy.toml 0\r\n"), "4 Not found\r\n");
|
||||||
|
assert_eq!(server.send("nex", b"/.itsybitsy.toml\r\n"), "Not found\n");
|
||||||
|
}
|
||||||
5
content/.itsybitsy.toml
Normal file
5
content/.itsybitsy.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[defaults]
|
||||||
|
cache_control = 60
|
||||||
|
|
||||||
|
[page."about.md"]
|
||||||
|
title = "About itsybitsy"
|
||||||
11
content/about.md
Normal file
11
content/about.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# About
|
||||||
|
|
||||||
|
One folder of Markdown, rendered on request into every format a listener serves.
|
||||||
|
|
||||||
|
<!-- card Rendering -->
|
||||||
|
|
||||||
|
Parsed once per document, not once per format.
|
||||||
|
|
||||||
|
<!-- card Configuration -->
|
||||||
|
|
||||||
|
A `.itsybitsy.toml` per directory, with no inheritance.
|
||||||
14
content/index.md
Normal file
14
content/index.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# itsybitsy
|
||||||
|
|
||||||
|
A small daemon serving this folder over HTTP, [Spartan](/about) and Nex.
|
||||||
|
|
||||||
|
- North Loop: open
|
||||||
|
- South Loop: closed
|
||||||
|
|
||||||
|
| Format | Port |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP | 8080 |
|
||||||
|
| Spartan | 3000 |
|
||||||
|
| Nex | 1900 |
|
||||||
|
|
||||||
|
See the [about page](/about) for more.
|
||||||
|
|
@ -15,22 +15,37 @@ use std::time::SystemTime;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
/// The file state a cached value was derived from.
|
/// The file state a cached value was derived from.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
///
|
||||||
|
/// It can cover several files, because a rendered page depends on its source
|
||||||
|
/// *and* on its directory's config: editing either has to invalidate it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Stamp {
|
pub struct Stamp {
|
||||||
modified: SystemTime,
|
/// One entry per file, in the order given. `None` records a file that does
|
||||||
len: u64,
|
/// not exist, so creating it later still counts as a change.
|
||||||
|
parts: Vec<Option<(SystemTime, u64)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stamp {
|
impl Stamp {
|
||||||
/// Stat `path`. Fails if it cannot be read, which is also how a deleted file
|
/// Stamp one file that must exist.
|
||||||
/// invalidates its entry.
|
|
||||||
pub fn of(path: &Path) -> Result<Self, Error> {
|
pub fn of(path: &Path) -> Result<Self, Error> {
|
||||||
let meta =
|
Ok(Stamp { parts: vec![Some(part(path)?)] })
|
||||||
fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
|
||||||
let modified =
|
|
||||||
meta.modified().map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
|
||||||
Ok(Stamp { modified, len: meta.len() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stamp a required file plus dependencies that may be absent.
|
||||||
|
pub fn of_with_deps(path: &Path, deps: &[&Path]) -> Result<Self, Error> {
|
||||||
|
let mut parts = vec![Some(part(path)?)];
|
||||||
|
for dep in deps {
|
||||||
|
parts.push(part(dep).ok());
|
||||||
|
}
|
||||||
|
Ok(Stamp { parts })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn part(path: &Path) -> Result<(SystemTime, u64), Error> {
|
||||||
|
let meta = fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
||||||
|
let modified =
|
||||||
|
meta.modified().map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
||||||
|
Ok((modified, meta.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A map from file path to a value derived from that file.
|
/// A map from file path to a value derived from that file.
|
||||||
|
|
@ -51,15 +66,14 @@ impl<V> StatCache<V> {
|
||||||
|
|
||||||
/// The cached value for `path`, or `build`'s result, stored for next time.
|
/// The cached value for `path`, or `build`'s result, stored for next time.
|
||||||
///
|
///
|
||||||
/// `build` is given the stamp the value will be recorded against, so a
|
/// The caller supplies the stamp, because only it knows which files the
|
||||||
/// caller that depends on a second file (a page on its directory's config)
|
/// value was derived from.
|
||||||
/// can combine stamps without stating the file twice.
|
|
||||||
pub fn get_or_insert_with(
|
pub fn get_or_insert_with(
|
||||||
&self,
|
&self,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
stamp: Stamp,
|
||||||
build: impl FnOnce() -> Result<V, Error>,
|
build: impl FnOnce() -> Result<V, Error>,
|
||||||
) -> Result<Arc<V>, Error> {
|
) -> Result<Arc<V>, Error> {
|
||||||
let stamp = Stamp::of(path)?;
|
|
||||||
if let Some(value) = self.get(path, &stamp) {
|
if let Some(value) = self.get(path, &stamp) {
|
||||||
return Ok(value);
|
return Ok(value);
|
||||||
}
|
}
|
||||||
|
|
@ -99,8 +113,7 @@ mod tests {
|
||||||
/// enough that two writes in one test can otherwise share a timestamp,
|
/// enough that two writes in one test can otherwise share a timestamp,
|
||||||
/// which is why the Python's live-reload test does the same thing.
|
/// which is why the Python's live-reload test does the same thing.
|
||||||
fn bump_mtime(path: &Path) {
|
fn bump_mtime(path: &Path) {
|
||||||
let stamp = Stamp::of(path).unwrap();
|
let later = SystemTime::now() + Duration::from_secs(5);
|
||||||
let later = stamp.modified + Duration::from_secs(5);
|
|
||||||
File::options()
|
File::options()
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(path)
|
.open(path)
|
||||||
|
|
@ -121,8 +134,8 @@ mod tests {
|
||||||
builds.fetch_add(1, Ordering::Relaxed);
|
builds.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(fs::read_to_string(&path).unwrap())
|
Ok(fs::read_to_string(&path).unwrap())
|
||||||
};
|
};
|
||||||
let first = cache.get_or_insert_with(&path, build).unwrap();
|
let first = cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
|
||||||
let second = cache.get_or_insert_with(&path, build).unwrap();
|
let second = cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
|
||||||
|
|
||||||
// Identity, not just equality: the Python asserts `first is second`.
|
// Identity, not just equality: the Python asserts `first is second`.
|
||||||
assert!(Arc::ptr_eq(&first, &second));
|
assert!(Arc::ptr_eq(&first, &second));
|
||||||
|
|
@ -137,12 +150,18 @@ mod tests {
|
||||||
let cache: StatCache<String> = StatCache::new();
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
|
||||||
let build = || Ok(fs::read_to_string(&path).unwrap());
|
let build = || Ok(fs::read_to_string(&path).unwrap());
|
||||||
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "first");
|
assert_eq!(
|
||||||
|
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
|
||||||
|
"first"
|
||||||
|
);
|
||||||
|
|
||||||
fs::write(&path, "second").unwrap();
|
fs::write(&path, "second").unwrap();
|
||||||
bump_mtime(&path);
|
bump_mtime(&path);
|
||||||
|
|
||||||
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "second");
|
assert_eq!(
|
||||||
|
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
|
||||||
|
"second"
|
||||||
|
);
|
||||||
assert_eq!(cache.len(), 1, "the entry is replaced, not added to");
|
assert_eq!(cache.len(), 1, "the entry is replaced, not added to");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,24 +174,60 @@ mod tests {
|
||||||
let cache: StatCache<String> = StatCache::new();
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
|
||||||
let build = || Ok(fs::read_to_string(&path).unwrap());
|
let build = || Ok(fs::read_to_string(&path).unwrap());
|
||||||
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "aaaaa");
|
assert_eq!(
|
||||||
|
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
|
||||||
|
"aaaaa"
|
||||||
|
);
|
||||||
|
|
||||||
fs::write(&path, "bbbbb").unwrap();
|
fs::write(&path, "bbbbb").unwrap();
|
||||||
bump_mtime(&path);
|
bump_mtime(&path);
|
||||||
|
|
||||||
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "bbbbb");
|
assert_eq!(
|
||||||
|
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
|
||||||
|
"bbbbb"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_file_is_an_error_not_a_stale_value() {
|
fn a_missing_file_cannot_be_stamped_so_no_stale_value_is_served() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("page.md");
|
let path = dir.path().join("page.md");
|
||||||
fs::write(&path, "here").unwrap();
|
fs::write(&path, "here").unwrap();
|
||||||
let cache: StatCache<String> = StatCache::new();
|
let cache: StatCache<String> = StatCache::new();
|
||||||
let build = || Ok(fs::read_to_string(&path).unwrap_or_default());
|
let build = || Ok(fs::read_to_string(&path).unwrap_or_default());
|
||||||
cache.get_or_insert_with(&path, build).unwrap();
|
cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
|
||||||
|
|
||||||
fs::remove_file(&path).unwrap();
|
fs::remove_file(&path).unwrap();
|
||||||
assert!(matches!(cache.get_or_insert_with(&path, build), Err(Error::Io { .. })));
|
assert!(matches!(Stamp::of(&path), Err(Error::Io { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dependency_changing_invalidates_the_value() {
|
||||||
|
// A rendered page depends on its directory's config as much as on its own
|
||||||
|
// source, so editing the config has to be a change.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let page = dir.path().join("page.md");
|
||||||
|
let config = dir.path().join("dir.toml");
|
||||||
|
fs::write(&page, "body").unwrap();
|
||||||
|
fs::write(&config, "a = 1").unwrap();
|
||||||
|
|
||||||
|
let first = Stamp::of_with_deps(&page, &[&config]).unwrap();
|
||||||
|
fs::write(&config, "a = 2").unwrap();
|
||||||
|
bump_mtime(&config);
|
||||||
|
assert_ne!(first, Stamp::of_with_deps(&page, &[&config]).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dependency_appearing_invalidates_the_value() {
|
||||||
|
// An absent dependency is recorded as absent rather than skipped, so
|
||||||
|
// adding a config where there was none counts as a change.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let page = dir.path().join("page.md");
|
||||||
|
let config = dir.path().join("dir.toml");
|
||||||
|
fs::write(&page, "body").unwrap();
|
||||||
|
|
||||||
|
let without = Stamp::of_with_deps(&page, &[&config]).unwrap();
|
||||||
|
fs::write(&config, "a = 1").unwrap();
|
||||||
|
assert_ne!(without, Stamp::of_with_deps(&page, &[&config]).unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ pub mod mime;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
pub mod path;
|
pub mod path;
|
||||||
pub mod preprocess;
|
pub mod preprocess;
|
||||||
|
pub mod render;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
pub mod siteset;
|
pub mod siteset;
|
||||||
|
|
||||||
|
|
|
||||||
279
core/src/render.rs
Normal file
279
core/src/render.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
//! The output-format contract, and what a rendered page holds.
|
||||||
|
//!
|
||||||
|
//! A format is a crate implementing [`Renderer`], registered at startup behind a
|
||||||
|
//! cargo feature. The trait takes a parsed [`Doc`] rather than source text so
|
||||||
|
//! that every format reads one parse: gemtext's `=>` link catalogue and the text
|
||||||
|
//! formats' `[n]` references must agree about link identity and order, and in
|
||||||
|
//! smolweb they can disagree because each library re-parses.
|
||||||
|
//!
|
||||||
|
//! A renderer must not open files or sockets. Includes and art are already
|
||||||
|
//! resolved by the time it runs, which is what keeps the root-containment check
|
||||||
|
//! in one place rather than in every format.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::PageSettings;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::ir::Doc;
|
||||||
|
|
||||||
|
/// One output format.
|
||||||
|
///
|
||||||
|
/// Instances are built once at startup and shared across every connection, so a
|
||||||
|
/// renderer holds configuration, never per-document state.
|
||||||
|
pub trait Renderer: Send + Sync {
|
||||||
|
/// Stable identifier: the registry key, the `formats` entry in the server
|
||||||
|
/// config, and the `?format=` query value.
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Content-Type, charset included, for every body this renderer emits.
|
||||||
|
fn media_type(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Columns to wrap to. `None` means the client wraps, which is the right
|
||||||
|
/// answer for gemtext over Gemini and Spartan; the fixed-width formats
|
||||||
|
/// return a column count because Nex and Gopher clients do not wrap.
|
||||||
|
fn default_width(&self) -> Option<u16> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RenderCtx<'a> {
|
||||||
|
/// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A
|
||||||
|
/// format that addresses sub-documents builds their URLs from this.
|
||||||
|
pub url: &'a str,
|
||||||
|
/// The page's title, already resolved through the whole fallback chain, so
|
||||||
|
/// no renderer repeats it.
|
||||||
|
pub title: &'a str,
|
||||||
|
pub settings: &'a PageSettings,
|
||||||
|
pub width: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Rendered {
|
||||||
|
/// The body served at [`RenderCtx::url`].
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
/// Extra documents this format addresses under `<url>/<slug>`. Empty for
|
||||||
|
/// every format that does not paginate.
|
||||||
|
pub parts: Vec<Part>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rendered {
|
||||||
|
/// A body with no sub-documents, which is every format but WML decks.
|
||||||
|
pub fn body(body: Vec<u8>) -> Self {
|
||||||
|
Rendered { body, parts: Vec::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Part {
|
||||||
|
/// URL-safe and unique within the page; the renderer guarantees both.
|
||||||
|
pub slug: String,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The formats this build provides, keyed by id.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Registry {
|
||||||
|
by_id: BTreeMap<&'static str, Arc<dyn Renderer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Registry::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors on a duplicate id, which can only be a wiring mistake.
|
||||||
|
pub fn insert(&mut self, renderer: Arc<dyn Renderer>) -> Result<(), Error> {
|
||||||
|
let id = renderer.id();
|
||||||
|
if self.by_id.insert(id, renderer).is_some() {
|
||||||
|
return Err(Error::config(format!("two renderers claim the id '{id}'")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Option<&Arc<dyn Renderer>> {
|
||||||
|
self.by_id.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ids(&self) -> Vec<&'static str> {
|
||||||
|
self.by_id.keys().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one document into every format in `formats`.
|
||||||
|
///
|
||||||
|
/// Eager, because the result is cached per source file: a page is rendered
|
||||||
|
/// once per change rather than once per request. The format set is the union
|
||||||
|
/// of what the enabled listeners can serve, so a site with no HTTP listener
|
||||||
|
/// never renders HTML.
|
||||||
|
pub fn page(
|
||||||
|
&self,
|
||||||
|
formats: &[String],
|
||||||
|
doc: &Doc,
|
||||||
|
url: &str,
|
||||||
|
settings: &PageSettings,
|
||||||
|
fallback_title: &str,
|
||||||
|
) -> Result<Page, Error> {
|
||||||
|
let title = settings
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.or_else(|| doc.first_h1.clone())
|
||||||
|
.unwrap_or_else(|| fallback_title.to_string());
|
||||||
|
|
||||||
|
let mut page = Page {
|
||||||
|
bodies: BTreeMap::new(),
|
||||||
|
parts: BTreeMap::new(),
|
||||||
|
settings: settings.clone(),
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
for id in formats {
|
||||||
|
let renderer = self.get(id).ok_or_else(|| {
|
||||||
|
// Validation proves this cannot happen at startup; a later
|
||||||
|
// caller passing an unknown id is a bug, not bad input.
|
||||||
|
Error::config(format!("no renderer provides the format '{id}'"))
|
||||||
|
})?;
|
||||||
|
let ctx =
|
||||||
|
RenderCtx { url, title: &page.title, settings, width: renderer.default_width() };
|
||||||
|
let rendered = renderer.render(doc, &ctx)?;
|
||||||
|
for part in rendered.parts {
|
||||||
|
page.parts.insert((id.clone(), part.slug), part.body);
|
||||||
|
}
|
||||||
|
page.bodies.insert(id.clone(), rendered.body);
|
||||||
|
}
|
||||||
|
Ok(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One document, rendered into every format the server can serve it as.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Page {
|
||||||
|
/// Body per format id.
|
||||||
|
pub bodies: BTreeMap<String, Vec<u8>>,
|
||||||
|
/// Sub-documents, keyed by format id and slug. A format that does not
|
||||||
|
/// paginate contributes none.
|
||||||
|
pub parts: BTreeMap<(String, String), Vec<u8>>,
|
||||||
|
pub settings: PageSettings,
|
||||||
|
/// Resolved title: configured, else the first level-1 heading, else derived
|
||||||
|
/// from the file name.
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
pub fn body(&self, format: &str) -> Option<&[u8]> {
|
||||||
|
self.bodies.get(format).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn part(&self, format: &str, slug: &str) -> Option<&[u8]> {
|
||||||
|
self.parts.get(&(format.to_string(), slug.to_string())).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this format addresses any sub-documents of this page.
|
||||||
|
pub fn has_parts(&self, format: &str) -> bool {
|
||||||
|
self.parts.keys().any(|(id, _)| id == format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a file stem into the last-resort title: `my-notes` becomes `my notes`.
|
||||||
|
pub fn title_from_stem(stem: &str) -> String {
|
||||||
|
stem.replace(['-', '_'], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ir::{Block, Inline};
|
||||||
|
|
||||||
|
/// Records what it was asked to render, so the contract can be tested with
|
||||||
|
/// no real format crate present.
|
||||||
|
struct Stub {
|
||||||
|
id: &'static str,
|
||||||
|
width: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderer for Stub {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_width(&self) -> Option<u16> {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, _doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
Ok(Rendered::body(
|
||||||
|
format!("{} {} {:?} {}", self.id, ctx.url, ctx.width, ctx.title).into_bytes(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry() -> Registry {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
registry.insert(Arc::new(Stub { id: "one", width: None })).unwrap();
|
||||||
|
registry.insert(Arc::new(Stub { id: "two", width: Some(80) })).unwrap();
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(first_h1: Option<&str>) -> Doc {
|
||||||
|
Doc {
|
||||||
|
blocks: vec![Block::Paragraph(vec![Inline::Text("x".into())])],
|
||||||
|
first_h1: first_h1.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_two_renderers_claiming_one_id() {
|
||||||
|
let mut registry = registry();
|
||||||
|
assert!(registry.insert(Arc::new(Stub { id: "one", width: None })).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_only_the_formats_asked_for() {
|
||||||
|
let page = registry()
|
||||||
|
.page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
|
||||||
|
.unwrap();
|
||||||
|
assert!(page.body("one").is_some());
|
||||||
|
assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_renderer_gets_its_own_declared_width() {
|
||||||
|
let formats = vec!["one".to_string(), "two".to_string()];
|
||||||
|
let page =
|
||||||
|
registry().page(&formats, &doc(None), "/x", &PageSettings::default(), "x").unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None"));
|
||||||
|
assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_title_falls_back_from_config_to_heading_to_file_name() {
|
||||||
|
let configured = PageSettings { title: Some("Configured".into()), ..Default::default() };
|
||||||
|
let formats = vec!["one".to_string()];
|
||||||
|
let reg = registry();
|
||||||
|
|
||||||
|
let from_config = reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem");
|
||||||
|
assert_eq!(from_config.unwrap().title, "Configured");
|
||||||
|
|
||||||
|
let from_heading =
|
||||||
|
reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem");
|
||||||
|
assert_eq!(from_heading.unwrap().title, "Heading");
|
||||||
|
|
||||||
|
let from_stem = reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem");
|
||||||
|
assert_eq!(from_stem.unwrap().title, "stem");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_format_is_a_bug_not_bad_input() {
|
||||||
|
let err = registry()
|
||||||
|
.page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("no renderer provides"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_stem_becomes_a_readable_title() {
|
||||||
|
assert_eq!(title_from_stem("my-notes_page"), "my notes page");
|
||||||
|
}
|
||||||
|
}
|
||||||
150
core/src/site.rs
150
core/src/site.rs
|
|
@ -10,11 +10,13 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::cache::StatCache;
|
use crate::cache::{Stamp, StatCache};
|
||||||
use crate::config::{DIR_CONFIG, DirConfig, PageSettings};
|
use crate::config::{DIR_CONFIG, DirConfig};
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::mime;
|
use crate::mime;
|
||||||
|
use crate::parse;
|
||||||
use crate::path::{clean_path, url_for};
|
use crate::path::{clean_path, url_for};
|
||||||
|
use crate::render::{Page, Registry, title_from_stem};
|
||||||
|
|
||||||
/// How deep a chain of server-side redirects `resolve_flat` will follow. Every
|
/// How deep a chain of server-side redirects `resolve_flat` will follow. Every
|
||||||
/// redirect currently points at a directly resolvable document, so one hop is
|
/// redirect currently points at a directly resolvable document, so one hop is
|
||||||
|
|
@ -32,30 +34,42 @@ pub enum Resolution {
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Resource {
|
pub enum Resource {
|
||||||
/// A Markdown document, to be rendered into the format the caller serves.
|
/// A Markdown document, rendered into every format this server serves.
|
||||||
Document { url: String, source: PathBuf, settings: PageSettings },
|
Document { url: String, page: Arc<Page> },
|
||||||
/// A file served byte for byte.
|
/// A file served byte for byte, streamed rather than buffered.
|
||||||
Raw { path: PathBuf, media_type: &'static str },
|
Raw { path: PathBuf, media_type: &'static str },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Site {
|
pub struct Site {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
dir_configs: StatCache<DirConfig>,
|
dir_configs: StatCache<DirConfig>,
|
||||||
|
pages: StatCache<Page>,
|
||||||
/// Shared by every directory without a config of its own, so the common case
|
/// Shared by every directory without a config of its own, so the common case
|
||||||
/// allocates nothing.
|
/// allocates nothing.
|
||||||
built_in: Arc<DirConfig>,
|
built_in: Arc<DirConfig>,
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
/// Formats every page here is rendered into: the union of what the enabled
|
||||||
|
/// listeners can serve, so a site with no HTTP listener never renders HTML.
|
||||||
|
formats: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Site {
|
impl Site {
|
||||||
/// Open a content root, canonicalising it so containment checks have a
|
/// Open a content root, canonicalising it so containment checks have a
|
||||||
/// stable base and a missing root fails now rather than per request.
|
/// stable base and a missing root fails now rather than per request.
|
||||||
pub fn new(root: &Path) -> Result<Self, Error> {
|
pub fn new(root: &Path, registry: Arc<Registry>, formats: Vec<String>) -> Result<Self, Error> {
|
||||||
let root =
|
let root =
|
||||||
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
|
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(Error::config(format!("{} is not a directory", root.display())));
|
return Err(Error::config(format!("{} is not a directory", root.display())));
|
||||||
}
|
}
|
||||||
Ok(Site { root, dir_configs: StatCache::new(), built_in: Arc::new(DirConfig::default()) })
|
Ok(Site {
|
||||||
|
root,
|
||||||
|
dir_configs: StatCache::new(),
|
||||||
|
pages: StatCache::new(),
|
||||||
|
built_in: Arc::new(DirConfig::default()),
|
||||||
|
registry,
|
||||||
|
formats,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
|
|
@ -89,8 +103,8 @@ impl Site {
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(url) = url_for(&self.root, &source) else { return Ok(Resolution::NotFound) };
|
let Some(url) = url_for(&self.root, &source) else { return Ok(Resolution::NotFound) };
|
||||||
let settings = self.settings_for(&source)?;
|
let page = self.page(&source, &url)?;
|
||||||
Ok(Resolution::Found(Resource::Document { url, source, settings }))
|
Ok(Resolution::Found(Resource::Document { url, page }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
|
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
|
||||||
|
|
@ -135,12 +149,23 @@ impl Site {
|
||||||
(resolved.starts_with(&self.root) && resolved.is_file()).then_some(resolved)
|
(resolved.starts_with(&self.root) && resolved.is_file()).then_some(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings_for(&self, source: &Path) -> Result<PageSettings, Error> {
|
/// The rendered page for a source file, built on first request and again
|
||||||
let Some(name) = source.file_name().and_then(|n| n.to_str()) else {
|
/// whenever the file or its directory's config changes.
|
||||||
return Ok(PageSettings::default());
|
fn page(&self, source: &Path, url: &str) -> Result<Arc<Page>, Error> {
|
||||||
};
|
|
||||||
let dir = source.parent().unwrap_or(&self.root);
|
let dir = source.parent().unwrap_or(&self.root);
|
||||||
Ok(self.dir_config(dir)?.settings_for(name))
|
let config_path = dir.join(DIR_CONFIG);
|
||||||
|
// The config is a dependency of the render, not just of the settings, so
|
||||||
|
// editing it re-renders this page rather than only changing what the next
|
||||||
|
// fresh render would see.
|
||||||
|
let stamp = Stamp::of_with_deps(source, &[&config_path])?;
|
||||||
|
|
||||||
|
self.pages.get_or_insert_with(source, stamp, || {
|
||||||
|
let name = source.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||||
|
let settings = self.dir_config(dir)?.settings_for(name);
|
||||||
|
let doc = parse::document(source, &self.root)?;
|
||||||
|
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||||
|
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dir_config(&self, dir: &Path) -> Result<Arc<DirConfig>, Error> {
|
fn dir_config(&self, dir: &Path) -> Result<Arc<DirConfig>, Error> {
|
||||||
|
|
@ -148,7 +173,7 @@ impl Site {
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
return Ok(self.built_in.clone());
|
return Ok(self.built_in.clone());
|
||||||
}
|
}
|
||||||
self.dir_configs.get_or_insert_with(&path, || DirConfig::load(&path))
|
self.dir_configs.get_or_insert_with(&path, Stamp::of(&path)?, || DirConfig::load(&path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +182,41 @@ mod tests {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::PageSettings;
|
||||||
|
use crate::ir::Doc;
|
||||||
|
use crate::render::{RenderCtx, Rendered, Renderer};
|
||||||
|
|
||||||
|
/// Echoes the document's plain text, so a resolution test can assert on what
|
||||||
|
/// was rendered without depending on a real format crate.
|
||||||
|
struct Stub;
|
||||||
|
|
||||||
|
impl Renderer for Stub {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"stub"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
let mut body = format!("title={}\n", ctx.title);
|
||||||
|
for block in &doc.blocks {
|
||||||
|
body.push_str(&format!("{block:?}\n"));
|
||||||
|
}
|
||||||
|
Ok(Rendered::body(body.into_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry() -> Arc<Registry> {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
registry.insert(Arc::new(Stub)).unwrap();
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(root: &Path) -> Site {
|
||||||
|
Site::new(root, registry(), vec!["stub".to_string()]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Mirrors smolweb's `tests/conftest.py` fixture, so its assertions port
|
/// Mirrors smolweb's `tests/conftest.py` fixture, so its assertions port
|
||||||
/// across directly.
|
/// across directly.
|
||||||
|
|
@ -169,20 +229,25 @@ mod tests {
|
||||||
fs::create_dir(root.join("dir")).unwrap();
|
fs::create_dir(root.join("dir")).unwrap();
|
||||||
fs::write(root.join("dir/index.md"), "# Nested\n\nNested body.\n").unwrap();
|
fs::write(root.join("dir/index.md"), "# Nested\n\nNested body.\n").unwrap();
|
||||||
fs::write(root.join(".secret.md"), "# Hidden\n").unwrap();
|
fs::write(root.join(".secret.md"), "# Hidden\n").unwrap();
|
||||||
let site = Site::new(root).unwrap();
|
let site = open(root);
|
||||||
(dir, site)
|
(dir, site)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn document(site: &Site, path: &str) -> (String, PathBuf, PageSettings) {
|
fn document(site: &Site, path: &str) -> (String, Arc<Page>) {
|
||||||
match site.resolve(path).unwrap() {
|
match site.resolve(path).unwrap() {
|
||||||
Resolution::Found(Resource::Document { url, source, settings }) => {
|
Resolution::Found(Resource::Document { url, page }) => (url, page),
|
||||||
(url, source, settings)
|
|
||||||
}
|
|
||||||
other => panic!("expected a document at {path}, got {other:?}"),
|
other => panic!("expected a document at {path}, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stub's rendered body, as text.
|
||||||
|
#[track_caller]
|
||||||
|
fn rendered(site: &Site, path: &str) -> String {
|
||||||
|
let (_, page) = document(site, path);
|
||||||
|
String::from_utf8(page.body("stub").expect("the stub format is rendered").to_vec()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Force a modification time change: filesystem granularity is coarse
|
/// Force a modification time change: filesystem granularity is coarse
|
||||||
/// enough that two writes in one test can share a timestamp.
|
/// enough that two writes in one test can share a timestamp.
|
||||||
fn bump_mtime(path: &Path) {
|
fn bump_mtime(path: &Path) {
|
||||||
|
|
@ -229,7 +294,7 @@ mod tests {
|
||||||
fs::create_dir(&root).unwrap();
|
fs::create_dir(&root).unwrap();
|
||||||
std::os::unix::fs::symlink(outside.join("secret.md"), root.join("escape.md")).unwrap();
|
std::os::unix::fs::symlink(outside.join("secret.md"), root.join("escape.md")).unwrap();
|
||||||
|
|
||||||
let site = Site::new(&root).unwrap();
|
let site = open(&root);
|
||||||
assert_not_found(&site, "/escape");
|
assert_not_found(&site, "/escape");
|
||||||
// The literal form is refused on the same grounds, not redirected.
|
// The literal form is refused on the same grounds, not redirected.
|
||||||
assert_not_found(&site, "/escape.md");
|
assert_not_found(&site, "/escape.md");
|
||||||
|
|
@ -245,7 +310,7 @@ mod tests {
|
||||||
fs::create_dir(&root).unwrap();
|
fs::create_dir(&root).unwrap();
|
||||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||||
|
|
||||||
let site = Site::new(&root).unwrap();
|
let site = open(&root);
|
||||||
assert_not_found(&site, "/link");
|
assert_not_found(&site, "/link");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,17 +335,18 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn root_serves_index() {
|
fn root_serves_index() {
|
||||||
let (dir, site) = fixture();
|
let (dir, site) = fixture();
|
||||||
let (url, source, _) = document(&site, "/");
|
let (url, _) = document(&site, "/");
|
||||||
assert_eq!(url, "/");
|
assert_eq!(url, "/");
|
||||||
assert_eq!(source, dir.path().canonicalize().unwrap().join("index.md"));
|
assert!(rendered(&site, "/").contains("Home"));
|
||||||
|
let _ = dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_extensionless_path_serves_the_md_file() {
|
fn an_extensionless_path_serves_the_md_file() {
|
||||||
let (_dir, site) = fixture();
|
let (_dir, site) = fixture();
|
||||||
let (url, source, _) = document(&site, "/about");
|
let (url, _) = document(&site, "/about");
|
||||||
assert_eq!(url, "/about");
|
assert_eq!(url, "/about");
|
||||||
assert_eq!(source.file_name().unwrap(), "about.md");
|
assert!(rendered(&site, "/about").contains("About"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -289,9 +355,9 @@ mod tests {
|
||||||
// Both forms resolve to the same page, and neither is redirected to the
|
// Both forms resolve to the same page, and neither is redirected to the
|
||||||
// other. That is the behaviour being ported, not an oversight.
|
// other. That is the behaviour being ported, not an oversight.
|
||||||
for path in ["/dir", "/dir/"] {
|
for path in ["/dir", "/dir/"] {
|
||||||
let (url, source, _) = document(&site, path);
|
let (url, _) = document(&site, path);
|
||||||
assert_eq!(url, "/dir/");
|
assert_eq!(url, "/dir/");
|
||||||
assert!(source.ends_with("dir/index.md"), "{source:?}");
|
assert!(rendered(&site, path).contains("Nested"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,18 +465,19 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (_, _, about) = document(&site, "/about");
|
let (_, about) = document(&site, "/about");
|
||||||
assert_eq!(about.title.as_deref(), Some("About Us"));
|
assert_eq!(about.title, "About Us");
|
||||||
assert_eq!(about.cache_control, Some(3600));
|
assert_eq!(about.settings.cache_control, Some(3600));
|
||||||
|
|
||||||
// index.md shares the directory defaults but has no title configured.
|
// index.md shares the directory defaults; with no title configured it
|
||||||
let (_, _, index) = document(&site, "/");
|
// falls back to its own first heading.
|
||||||
assert_eq!(index.title, None);
|
let (_, index) = document(&site, "/");
|
||||||
assert_eq!(index.cache_control, Some(3600));
|
assert_eq!(index.title, "Home");
|
||||||
|
assert_eq!(index.settings.cache_control, Some(3600));
|
||||||
|
|
||||||
// The subdirectory does not inherit: it has no config of its own.
|
// The subdirectory does not inherit: it has no config of its own.
|
||||||
let (_, _, nested) = document(&site, "/dir");
|
let (_, nested) = document(&site, "/dir");
|
||||||
assert_eq!(nested, PageSettings::default());
|
assert_eq!(nested.settings, PageSettings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -418,12 +485,12 @@ mod tests {
|
||||||
let (dir, site) = fixture();
|
let (dir, site) = fixture();
|
||||||
let path = dir.path().join(DIR_CONFIG);
|
let path = dir.path().join(DIR_CONFIG);
|
||||||
fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap();
|
fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap();
|
||||||
assert_eq!(document(&site, "/about").2.cache_control, Some(60));
|
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(60));
|
||||||
|
|
||||||
fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap();
|
fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap();
|
||||||
bump_mtime(&path);
|
bump_mtime(&path);
|
||||||
|
|
||||||
assert_eq!(document(&site, "/about").2.cache_control, Some(120));
|
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(120));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -437,7 +504,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_root_is_rejected_at_construction() {
|
fn a_missing_root_is_rejected_at_construction() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
assert!(matches!(Site::new(&dir.path().join("absent")), Err(Error::Io { .. })));
|
let absent = dir.path().join("absent");
|
||||||
|
assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -445,6 +513,6 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let file = dir.path().join("not-a-dir");
|
let file = dir.path().join("not-a-dir");
|
||||||
fs::write(&file, "x").unwrap();
|
fs::write(&file, "x").unwrap();
|
||||||
assert!(matches!(Site::new(&file), Err(Error::Config { .. })));
|
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{ServerConfig, normalize_host};
|
use crate::config::{ServerConfig, normalize_host};
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
use crate::render::Registry;
|
||||||
use crate::site::Site;
|
use crate::site::Site;
|
||||||
|
|
||||||
pub struct SiteSet {
|
pub struct SiteSet {
|
||||||
|
|
@ -25,12 +26,19 @@ impl SiteSet {
|
||||||
///
|
///
|
||||||
/// Validation has already proved the roots canonicalise and that no host is
|
/// Validation has already proved the roots canonicalise and that no host is
|
||||||
/// claimed twice, so the only work left is opening each site once.
|
/// claimed twice, so the only work left is opening each site once.
|
||||||
pub fn build(config: &ServerConfig) -> Result<Self, Error> {
|
///
|
||||||
|
/// `formats` is the union of what the enabled listeners can serve; every page
|
||||||
|
/// is rendered into exactly that set and no more.
|
||||||
|
pub fn build(
|
||||||
|
config: &ServerConfig,
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
formats: Vec<String>,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
let mut set =
|
let mut set =
|
||||||
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
|
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
|
||||||
|
|
||||||
for (name, spec) in &config.site {
|
for (name, spec) in &config.site {
|
||||||
let site = Site::new(&spec.root)?;
|
let site = Site::new(&spec.root, registry.clone(), formats.clone())?;
|
||||||
// One `Site` per distinct root, so sites sharing a folder share its
|
// One `Site` per distinct root, so sites sharing a folder share its
|
||||||
// caches rather than each building their own.
|
// caches rather than each building their own.
|
||||||
let index = match set.sites.iter().position(|open| open.root() == site.root()) {
|
let index = match set.sites.iter().position(|open| open.root() == site.root()) {
|
||||||
|
|
@ -60,6 +68,12 @@ impl SiteSet {
|
||||||
self.sites.get(*self.by_host.get(&host)?)
|
self.sites.get(*self.by_host.get(&host)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The only site, for a hostless listener in a single-site configuration.
|
||||||
|
/// Validation refuses to leave the name implicit when there is more than one.
|
||||||
|
pub fn first(&self) -> Option<&Arc<Site>> {
|
||||||
|
self.sites.first()
|
||||||
|
}
|
||||||
|
|
||||||
/// The site a listener named outright in its configuration.
|
/// The site a listener named outright in its configuration.
|
||||||
pub fn by_name(&self, name: &str) -> Option<&Arc<Site>> {
|
pub fn by_name(&self, name: &str) -> Option<&Arc<Site>> {
|
||||||
self.sites.get(*self.by_name.get(name)?)
|
self.sites.get(*self.by_name.get(name)?)
|
||||||
|
|
@ -97,7 +111,7 @@ mod tests {
|
||||||
dir.join(second).to_str().unwrap(),
|
dir.join(second).to_str().unwrap(),
|
||||||
);
|
);
|
||||||
let config: ServerConfig = toml::from_str(&text).unwrap();
|
let config: ServerConfig = toml::from_str(&text).unwrap();
|
||||||
SiteSet::build(&config).unwrap()
|
SiteSet::build(&config, Arc::new(Registry::new()), Vec::new()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"cargo test --workspace"
|
"cargo test --workspace"
|
||||||
],
|
],
|
||||||
"test": "cargo test --workspace",
|
"test": "cargo test --workspace",
|
||||||
"build": "cargo build --release"
|
"build": "cargo build --release",
|
||||||
|
"run": "cargo run -- --config ./itsybitsy.toml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
gemtext/Cargo.toml
Normal file
14
gemtext/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "itsybitsy-gemtext"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.97"
|
||||||
|
description = "gemtext output for itsybitsy"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
itsybitsy-core = { path = "../core" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
389
gemtext/src/lib.rs
Normal file
389
gemtext/src/lib.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
//! gemtext output, for Gemini and Spartan.
|
||||||
|
//!
|
||||||
|
//! gemtext is line-oriented and has no inline markup at all: a link is a whole
|
||||||
|
//! line beginning `=>`, so links inside a paragraph have to be lifted out of it.
|
||||||
|
//! They are emitted directly after the paragraph that held them, which keeps a
|
||||||
|
//! link next to the sentence that introduced it rather than in one list at the
|
||||||
|
//! end of the document.
|
||||||
|
//!
|
||||||
|
//! Nothing here wraps. Gemini and Spartan clients wrap for themselves, and
|
||||||
|
//! pre-wrapping would fight whatever width the reader actually has.
|
||||||
|
|
||||||
|
use itsybitsy_core::Error;
|
||||||
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
||||||
|
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
||||||
|
|
||||||
|
pub struct Gemtext;
|
||||||
|
|
||||||
|
impl Renderer for Gemtext {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"gemtext"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"text/gemini; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
let mut out = Writer::default();
|
||||||
|
out.blocks(&doc.blocks);
|
||||||
|
Ok(Rendered::body(out.finish()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Writer {
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Writer {
|
||||||
|
fn finish(mut self) -> Vec<u8> {
|
||||||
|
if !self.text.ends_with('\n') && !self.text.is_empty() {
|
||||||
|
self.text.push('\n');
|
||||||
|
}
|
||||||
|
self.text.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(&mut self, line: &str) {
|
||||||
|
self.text.push_str(line.trim_end());
|
||||||
|
self.text.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank(&mut self) {
|
||||||
|
if !self.text.is_empty() && !self.text.ends_with("\n\n") {
|
||||||
|
self.text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks(&mut self, blocks: &[Block]) {
|
||||||
|
for block in blocks {
|
||||||
|
self.block(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(&mut self, block: &Block) {
|
||||||
|
match block {
|
||||||
|
Block::Heading { level, inline } => {
|
||||||
|
self.blank();
|
||||||
|
// gemtext has three heading levels; deeper ones flatten onto the
|
||||||
|
// third rather than disappearing.
|
||||||
|
let hashes = "#".repeat((*level).clamp(1, 3) as usize);
|
||||||
|
self.line(&format!("{hashes} {}", Doc::plain_text(inline)));
|
||||||
|
self.links(inline);
|
||||||
|
}
|
||||||
|
Block::Paragraph(inline) => {
|
||||||
|
self.blank();
|
||||||
|
// A paragraph that is only a link needs no text line of its own.
|
||||||
|
let text = Doc::plain_text(inline).trim().to_string();
|
||||||
|
if !is_only_links(inline) && !text.is_empty() {
|
||||||
|
self.line(&text);
|
||||||
|
}
|
||||||
|
self.links(inline);
|
||||||
|
}
|
||||||
|
Block::CodeBlock { info, lines } => {
|
||||||
|
self.blank();
|
||||||
|
// The info string becomes the fence's alt text, which is where
|
||||||
|
// gemtext puts a description of a preformatted block.
|
||||||
|
self.line(&format!("```{}", info.as_deref().unwrap_or("")));
|
||||||
|
for line in lines {
|
||||||
|
self.line(line);
|
||||||
|
}
|
||||||
|
self.line("```");
|
||||||
|
}
|
||||||
|
Block::Art { alt, lines } => {
|
||||||
|
self.blank();
|
||||||
|
self.line(&format!("```{alt}"));
|
||||||
|
for line in lines {
|
||||||
|
self.line(line);
|
||||||
|
}
|
||||||
|
self.line("```");
|
||||||
|
}
|
||||||
|
Block::BlockQuote(blocks) => {
|
||||||
|
self.blank();
|
||||||
|
for text in quoted(blocks) {
|
||||||
|
self.line(&format!("> {text}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Block::List { ordered, start, items } => {
|
||||||
|
self.blank();
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
let text = item_text(item);
|
||||||
|
// gemtext has one list marker, so an ordered list carries its
|
||||||
|
// number in the text to keep the sequence readable.
|
||||||
|
if *ordered {
|
||||||
|
self.line(&format!("* {}. {text}", start + index as u64));
|
||||||
|
} else {
|
||||||
|
self.line(&format!("* {text}"));
|
||||||
|
}
|
||||||
|
for block in item {
|
||||||
|
if let Block::Paragraph(inline) | Block::Heading { inline, .. } = block {
|
||||||
|
self.links(inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A nested list is flattened one level, since gemtext has no
|
||||||
|
// nesting; its items keep their own markers.
|
||||||
|
for block in item {
|
||||||
|
if let Block::List { .. } = block {
|
||||||
|
self.block(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Block::Table { head, rows, .. } => {
|
||||||
|
self.blank();
|
||||||
|
self.table(head, rows);
|
||||||
|
}
|
||||||
|
// gemtext has no rule, and a blank line alone reads as nothing, so a
|
||||||
|
// visible divider is spelled out.
|
||||||
|
Block::Rule => {
|
||||||
|
self.blank();
|
||||||
|
self.line("---");
|
||||||
|
}
|
||||||
|
// Alignment and pagination have no expression here. Unwrapping keeps
|
||||||
|
// the content; the presentation is simply not available.
|
||||||
|
Block::Aligned { block, .. } => self.block(block),
|
||||||
|
Block::CardBreak { .. } => {}
|
||||||
|
// Raw markup would be shown literally, which is worse than omitting it.
|
||||||
|
Block::Html(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `=>` lines for every link and image in a run of inlines.
|
||||||
|
fn links(&mut self, inline: &[Inline]) {
|
||||||
|
for (href, label) in collect_links(inline) {
|
||||||
|
let label = label.trim();
|
||||||
|
// An autolink's label is its own URL, which would print twice.
|
||||||
|
if label.is_empty() || label == href {
|
||||||
|
self.line(&format!("=> {href}"));
|
||||||
|
} else {
|
||||||
|
self.line(&format!("=> {href} {label}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table as a preformatted block, with columns padded to line up. gemtext
|
||||||
|
/// has no table syntax, and a run of pipes without alignment is unreadable.
|
||||||
|
fn table(&mut self, head: &[Vec<Inline>], rows: &[Vec<Vec<Inline>>]) {
|
||||||
|
let mut all: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
|
||||||
|
if !head.is_empty() {
|
||||||
|
all.push(head.iter().map(|cell| Doc::plain_text(cell)).collect());
|
||||||
|
}
|
||||||
|
for row in rows {
|
||||||
|
all.push(row.iter().map(|cell| Doc::plain_text(cell)).collect());
|
||||||
|
}
|
||||||
|
let columns = all.iter().map(Vec::len).max().unwrap_or(0);
|
||||||
|
let widths: Vec<usize> = (0..columns)
|
||||||
|
.map(|index| {
|
||||||
|
all.iter()
|
||||||
|
.map(|row| row.get(index).map_or(0, |c| c.chars().count()))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.line("```");
|
||||||
|
for (index, row) in all.iter().enumerate() {
|
||||||
|
let cells: Vec<String> =
|
||||||
|
row.iter().zip(&widths).map(|(cell, width)| format!("{cell:<width$}")).collect();
|
||||||
|
self.line(&cells.join(" "));
|
||||||
|
// A rule under the header row, matching the padded column widths.
|
||||||
|
if index == 0 && !head.is_empty() {
|
||||||
|
let rule: Vec<String> = widths.iter().map(|width| "-".repeat(*width)).collect();
|
||||||
|
self.line(&rule.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.line("```");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a paragraph holds links and nothing else worth printing.
|
||||||
|
fn is_only_links(inline: &[Inline]) -> bool {
|
||||||
|
let mut saw_link = false;
|
||||||
|
for item in inline {
|
||||||
|
match item {
|
||||||
|
Inline::Link { .. } | Inline::Image { .. } => saw_link = true,
|
||||||
|
Inline::Text(text) if text.trim().is_empty() => {}
|
||||||
|
Inline::SoftBreak => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saw_link
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Links and images in document order, as `(href, label)`.
|
||||||
|
///
|
||||||
|
/// Order is taken from the single parsed representation, so gemtext's catalogue
|
||||||
|
/// and the text formats' numbered references cannot disagree about it.
|
||||||
|
fn collect_links(inline: &[Inline]) -> Vec<(String, String)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
walk_links(inline, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_links(inline: &[Inline], out: &mut Vec<(String, String)>) {
|
||||||
|
for item in inline {
|
||||||
|
match item {
|
||||||
|
Inline::Link { href, label, .. } => {
|
||||||
|
out.push((href.clone(), Doc::plain_text(label)));
|
||||||
|
walk_links(label, out);
|
||||||
|
}
|
||||||
|
Inline::Image { src, alt, .. } => out.push((src.clone(), Doc::plain_text(alt))),
|
||||||
|
Inline::Emph(inner) | Inline::Strong(inner) | Inline::Strike(inner) => {
|
||||||
|
walk_links(inner, out)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of a list item, flattened onto one line.
|
||||||
|
fn item_text(blocks: &[Block]) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for block in blocks {
|
||||||
|
match block {
|
||||||
|
Block::Paragraph(inline) | Block::Heading { inline, .. } => {
|
||||||
|
parts.push(Doc::plain_text(inline))
|
||||||
|
}
|
||||||
|
Block::Aligned { block, .. } => parts.push(item_text(std::slice::from_ref(block))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join(" ").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines of a quote, flattened: gemtext quotes one line at a time.
|
||||||
|
fn quoted(blocks: &[Block]) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for block in blocks {
|
||||||
|
match block {
|
||||||
|
Block::Paragraph(inline) | Block::Heading { inline, .. } => {
|
||||||
|
out.push(Doc::plain_text(inline))
|
||||||
|
}
|
||||||
|
Block::BlockQuote(inner) => out.extend(quoted(inner)),
|
||||||
|
Block::List { items, .. } => {
|
||||||
|
out.extend(items.iter().map(|item| format!("* {}", item_text(item))))
|
||||||
|
}
|
||||||
|
Block::CodeBlock { lines, .. } | Block::Art { lines, .. } => {
|
||||||
|
out.extend(lines.iter().cloned())
|
||||||
|
}
|
||||||
|
Block::Aligned { block, .. } => out.extend(quoted(std::slice::from_ref(block))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use itsybitsy_core::config::PageSettings;
|
||||||
|
use itsybitsy_core::parse;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Render a Markdown fragment the way the server would.
|
||||||
|
fn render(markdown: &str) -> String {
|
||||||
|
let doc = parse::markdown(markdown);
|
||||||
|
let settings = PageSettings::default();
|
||||||
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
|
let out = Gemtext.render(&doc, &ctx).unwrap();
|
||||||
|
String::from_utf8(out.body).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headings_flatten_onto_three_levels() {
|
||||||
|
assert_eq!(render("# a\n## b\n### c\n#### d\n"), "# a\n\n## b\n\n### c\n\n### d\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paragraphs_are_one_line_each() {
|
||||||
|
// Unwrapped: a Gemini client wraps to the width the reader actually has.
|
||||||
|
assert_eq!(render("one two\nthree\n\nnext\n"), "one two three\n\nnext\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_markup_is_flattened_to_text() {
|
||||||
|
assert_eq!(render("a *b* **c** `d`\n"), "a b c d\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_follow_the_paragraph_that_held_them() {
|
||||||
|
assert_eq!(
|
||||||
|
render("See the [about page](/about) and [home](/).\n"),
|
||||||
|
"See the about page and home.\n=> /about about page\n=> / home\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paragraph_that_is_only_a_link_emits_just_the_link() {
|
||||||
|
assert_eq!(render("[about](/about)\n"), "=> /about about\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_with_no_label_still_emits_a_line() {
|
||||||
|
assert_eq!(render("<https://example.com/>\n"), "=> https://example.com/\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_use_the_single_gemtext_marker() {
|
||||||
|
assert_eq!(render("- a\n- b\n"), "* a\n* b\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordered_list_keeps_its_numbers_in_the_text() {
|
||||||
|
// gemtext has one marker, so the sequence has to be spelled out or it is
|
||||||
|
// lost entirely.
|
||||||
|
assert_eq!(render("3. a\n4. b\n"), "* 3. a\n* 4. b\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_links_inside_a_list_item_are_emitted_after_it() {
|
||||||
|
assert_eq!(render("- see [x](/x)\n"), "* see x\n=> /x x\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_blocks_become_fences_carrying_their_info_string() {
|
||||||
|
assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n");
|
||||||
|
assert_eq!(render(" plain\n"), "```\nplain\n```\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quotes_are_marked_line_by_line() {
|
||||||
|
assert_eq!(render("> a\n>\n> b\n"), "> a\n> b\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_is_spelled_out_because_gemtext_has_none() {
|
||||||
|
assert_eq!(render("a\n\n---\n\nb\n"), "a\n\n---\n\nb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tables_become_aligned_preformatted_blocks() {
|
||||||
|
// gemtext has no table syntax, and unaligned pipes are unreadable.
|
||||||
|
let out = render("| Format | Port |\n| --- | --- |\n| Gemini | 1965 |\n| Nex | 1900 |\n");
|
||||||
|
assert_eq!(out, "```\nFormat Port\n------ ----\nGemini 1965\nNex 1900\n```\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_card_divider_emits_nothing() {
|
||||||
|
// Pagination is a WML concern; a Gemini client scrolls one document.
|
||||||
|
let doc = parse::markdown("a\n\nb\n");
|
||||||
|
let settings = PageSettings::default();
|
||||||
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
|
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
|
assert_eq!(plain, "a\n\nb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_html_is_omitted_rather_than_shown_literally() {
|
||||||
|
assert_eq!(render("a\n\n<div>x</div>\n\nb\n"), "a\n\nb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_always_ends_in_exactly_one_newline() {
|
||||||
|
for source in ["a\n", "# a\n", "- a\n", "```\nx\n```\n"] {
|
||||||
|
let out = render(source);
|
||||||
|
assert!(out.ends_with('\n'), "{source:?} -> {out:?}");
|
||||||
|
assert!(!out.ends_with("\n\n"), "{source:?} -> {out:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
itsybitsy.toml
Normal file
23
itsybitsy.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[site.demo]
|
||||||
|
root = "./content"
|
||||||
|
hosts = ["localhost", "127.0.0.1"]
|
||||||
|
|
||||||
|
[listener.web]
|
||||||
|
protocol = "http"
|
||||||
|
bind = "127.0.0.1:8080"
|
||||||
|
formats = ["xhtmlmp", "html"]
|
||||||
|
default_site = "demo"
|
||||||
|
|
||||||
|
[listener.spartan]
|
||||||
|
protocol = "spartan"
|
||||||
|
bind = "127.0.0.1:3000"
|
||||||
|
formats = ["gemtext"]
|
||||||
|
default_site = "demo"
|
||||||
|
|
||||||
|
[listener.nex]
|
||||||
|
protocol = "nex"
|
||||||
|
bind = "127.0.0.1:1900"
|
||||||
|
formats = ["gemtext"]
|
||||||
|
site = "demo"
|
||||||
14
wap/Cargo.toml
Normal file
14
wap/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "itsybitsy-wap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.97"
|
||||||
|
description = "XHTML-MP and HTML output for itsybitsy"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
itsybitsy-core = { path = "../core" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
48
wap/src/escape.rs
Normal file
48
wap/src/escape.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//! XML escaping.
|
||||||
|
|
||||||
|
/// Escape text for an element's content.
|
||||||
|
pub fn text(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape text for an attribute value, which is always double-quoted here.
|
||||||
|
pub fn attr(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_markup_characters() {
|
||||||
|
assert_eq!(text("a & b < c > d"), "a & b < c > d");
|
||||||
|
// A quote is only significant inside an attribute, so content keeps it.
|
||||||
|
assert_eq!(text("say \"hi\""), "say \"hi\"");
|
||||||
|
assert_eq!(attr("say \"hi\" & bye"), "say "hi" & bye");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_ordinary_text_alone() {
|
||||||
|
assert_eq!(text("plain text 123"), "plain text 123");
|
||||||
|
}
|
||||||
|
}
|
||||||
176
wap/src/lib.rs
Normal file
176
wap/src/lib.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
//! The WAP family: XHTML Mobile Profile, and the `text/html` body derived from it.
|
||||||
|
//!
|
||||||
|
//! One crate rather than one per format, because the HTML form is the XHTML-MP
|
||||||
|
//! bytes with the XML declaration removed, and because WML's deck configuration
|
||||||
|
//! is shared with nothing outside this family. Splitting them would duplicate the
|
||||||
|
//! escaper and the markup builder.
|
||||||
|
|
||||||
|
mod escape;
|
||||||
|
mod xhtmlmp;
|
||||||
|
|
||||||
|
use itsybitsy_core::Error;
|
||||||
|
use itsybitsy_core::ir::Doc;
|
||||||
|
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
||||||
|
|
||||||
|
/// XHTML Mobile Profile, for WAP 2.0 clients: well-formed XML with the prolog.
|
||||||
|
pub struct XhtmlMp;
|
||||||
|
|
||||||
|
impl Renderer for XhtmlMp {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"xhtmlmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"application/vnd.wap.xhtml+xml; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true).into_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same markup served as `text/html`, for an ordinary browser.
|
||||||
|
pub struct Html;
|
||||||
|
|
||||||
|
impl Renderer for Html {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"html"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"text/html; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false).into_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use itsybitsy_core::config::PageSettings;
|
||||||
|
use itsybitsy_core::parse;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
|
||||||
|
let doc = parse::markdown(markdown);
|
||||||
|
let settings = PageSettings::default();
|
||||||
|
let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None };
|
||||||
|
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(markdown: &str) -> String {
|
||||||
|
let out = render(&Html, markdown);
|
||||||
|
let start = out.find("<body>\n").unwrap() + "<body>\n".len();
|
||||||
|
let end = out.find("</body>").unwrap();
|
||||||
|
out[start..end].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_html_form_carries_no_xml_declaration() {
|
||||||
|
// A browser parsing this as text/html tolerates the declaration as a
|
||||||
|
// bogus comment and renders the page, but logs a warning for it.
|
||||||
|
let out = render(&Html, "# a\n");
|
||||||
|
assert!(!out.starts_with("<?xml"));
|
||||||
|
assert!(out.starts_with("<!DOCTYPE html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_xhtml_form_keeps_it_because_wap_clients_need_valid_xml() {
|
||||||
|
assert!(render(&XhtmlMp, "# a\n").starts_with("<?xml version=\"1.0\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_forms_differ_only_in_that_line() {
|
||||||
|
let xml = render(&XhtmlMp, "# a\n\ntext\n");
|
||||||
|
let html = render(&Html, "# a\n\ntext\n");
|
||||||
|
assert_eq!(&xml[xml.find("<!DOCTYPE").unwrap()..], html);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_resolved_title_reaches_the_head() {
|
||||||
|
assert!(render(&Html, "# ignored\n").contains("<title>The Title</title>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_become_their_markup() {
|
||||||
|
assert_eq!(body("# a\n"), "<h1>a</h1>\n");
|
||||||
|
assert_eq!(body("###### f\n"), "<h6>f</h6>\n");
|
||||||
|
assert_eq!(body("text\n"), "<p>text</p>\n");
|
||||||
|
assert_eq!(body("---\n"), "<hr/>\n");
|
||||||
|
assert_eq!(body("> q\n"), "<blockquote>\n<p>q</p>\n</blockquote>\n");
|
||||||
|
assert_eq!(body("```\nx\n```\n"), "<pre>x</pre>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_keep_a_single_paragraph_item_inline() {
|
||||||
|
assert_eq!(body("- a\n- b\n"), "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
|
||||||
|
assert_eq!(body("1. a\n"), "<ol>\n<li>a</li>\n</ol>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_markup_becomes_elements() {
|
||||||
|
assert_eq!(
|
||||||
|
body("a *b* **c** `d`\n"),
|
||||||
|
"<p>a <em>b</em> <strong>c</strong> <code>d</code></p>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strikethrough_keeps_its_text_since_the_profile_has_no_element_for_it() {
|
||||||
|
assert_eq!(body("~~gone~~\n"), "<p>gone</p>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_and_images_carry_their_attributes() {
|
||||||
|
assert_eq!(body("[label](/x \"T\")\n"), "<p><a href=\"/x\" title=\"T\">label</a></p>\n");
|
||||||
|
assert_eq!(body("\n"), "<p><img src=\"/i.png\" alt=\"alt\"/></p>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markup_characters_in_text_are_escaped() {
|
||||||
|
assert_eq!(body("a < b & c\n"), "<p>a < b & c</p>\n");
|
||||||
|
assert_eq!(body("[x](/a?b=1&c=2)\n"), "<p><a href=\"/a?b=1&c=2\">x</a></p>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tables_become_tables() {
|
||||||
|
assert_eq!(
|
||||||
|
body("| h |\n| --- |\n| c |\n"),
|
||||||
|
"<table>\n<tr><th>h</th></tr>\n<tr><td>c</td></tr>\n</table>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn art_keeps_its_label_as_a_title() {
|
||||||
|
let doc = Doc {
|
||||||
|
blocks: vec![itsybitsy_core::ir::Block::Art {
|
||||||
|
alt: "Dragon".into(),
|
||||||
|
lines: vec!["/\\".into()],
|
||||||
|
}],
|
||||||
|
first_h1: None,
|
||||||
|
};
|
||||||
|
let settings = PageSettings::default();
|
||||||
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
|
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
|
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_html_is_passed_through_because_it_is_already_the_right_language() {
|
||||||
|
assert_eq!(body("<div>x</div>\n"), "<div>x</div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_card_divider_emits_nothing() {
|
||||||
|
let doc = Doc {
|
||||||
|
blocks: vec![itsybitsy_core::ir::Block::CardBreak { title: Some("W".into()) }],
|
||||||
|
first_h1: None,
|
||||||
|
};
|
||||||
|
let settings = PageSettings::default();
|
||||||
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
|
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
|
assert!(out.contains("<body>\n</body>"), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
170
wap/src/xhtmlmp.rs
Normal file
170
wap/src/xhtmlmp.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
//! XHTML Mobile Profile markup, and the `text/html` body derived from it.
|
||||||
|
//!
|
||||||
|
//! One document serves both: a WAP 2.0 client needs well-formed XML with the
|
||||||
|
//! prolog and the Mobile Profile doctype, while a browser parsing the same bytes
|
||||||
|
//! as `text/html` tolerates the leading `<?xml ...?>` as a bogus comment but
|
||||||
|
//! logs a warning for it. So the HTML form is the same markup with that one line
|
||||||
|
//! removed.
|
||||||
|
|
||||||
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
||||||
|
|
||||||
|
use crate::escape;
|
||||||
|
|
||||||
|
/// The XML declaration, which only the XHTML-MP form carries.
|
||||||
|
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
||||||
|
|
||||||
|
const DOCTYPE: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
|
||||||
|
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n";
|
||||||
|
|
||||||
|
/// Build the whole document. `prolog` distinguishes the two media types.
|
||||||
|
pub fn document(doc: &Doc, title: &str, prolog: bool) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
if prolog {
|
||||||
|
out.push_str(PROLOG);
|
||||||
|
}
|
||||||
|
out.push_str(DOCTYPE);
|
||||||
|
out.push_str("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
|
||||||
|
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
||||||
|
out.push_str("<body>\n");
|
||||||
|
blocks(&doc.blocks, &mut out);
|
||||||
|
out.push_str("</body>\n</html>\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks(blocks: &[Block], out: &mut String) {
|
||||||
|
for block in blocks {
|
||||||
|
self_block(block, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_block(block: &Block, out: &mut String) {
|
||||||
|
match block {
|
||||||
|
Block::Heading { level, inline } => {
|
||||||
|
let level = (*level).clamp(1, 6);
|
||||||
|
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline)));
|
||||||
|
}
|
||||||
|
Block::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline))),
|
||||||
|
Block::CodeBlock { lines, .. } => {
|
||||||
|
out.push_str("<pre>");
|
||||||
|
out.push_str(&escape::text(&lines.join("\n")));
|
||||||
|
out.push_str("</pre>\n");
|
||||||
|
}
|
||||||
|
Block::Art { alt, lines } => {
|
||||||
|
// The label is the only description a client that cannot show the
|
||||||
|
// art would have, so it is kept as the title attribute.
|
||||||
|
out.push_str(&format!("<pre title=\"{}\">", escape::attr(alt)));
|
||||||
|
out.push_str(&escape::text(&lines.join("\n")));
|
||||||
|
out.push_str("</pre>\n");
|
||||||
|
}
|
||||||
|
Block::BlockQuote(inner) => {
|
||||||
|
out.push_str("<blockquote>\n");
|
||||||
|
blocks(inner, out);
|
||||||
|
out.push_str("</blockquote>\n");
|
||||||
|
}
|
||||||
|
Block::List { ordered, start, items } => {
|
||||||
|
if *ordered {
|
||||||
|
// XHTML-MP has no `start` attribute, so a list beginning
|
||||||
|
// elsewhere than 1 cannot be expressed; the numbers restart.
|
||||||
|
out.push_str("<ol>\n");
|
||||||
|
} else {
|
||||||
|
out.push_str("<ul>\n");
|
||||||
|
}
|
||||||
|
let _ = start;
|
||||||
|
for item in items {
|
||||||
|
out.push_str("<li>");
|
||||||
|
// A single paragraph needs no block wrapper inside the item.
|
||||||
|
match item.as_slice() {
|
||||||
|
[Block::Paragraph(inline)] => out.push_str(&inlines(inline)),
|
||||||
|
blocks_in_item => {
|
||||||
|
out.push('\n');
|
||||||
|
blocks(blocks_in_item, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str("</li>\n");
|
||||||
|
}
|
||||||
|
out.push_str(if *ordered { "</ol>\n" } else { "</ul>\n" });
|
||||||
|
}
|
||||||
|
Block::Table { head, rows, .. } => {
|
||||||
|
out.push_str("<table>\n");
|
||||||
|
if !head.is_empty() {
|
||||||
|
out.push_str("<tr>");
|
||||||
|
for cell in head {
|
||||||
|
out.push_str(&format!("<th>{}</th>", inlines(cell)));
|
||||||
|
}
|
||||||
|
out.push_str("</tr>\n");
|
||||||
|
}
|
||||||
|
for row in rows {
|
||||||
|
out.push_str("<tr>");
|
||||||
|
for cell in row {
|
||||||
|
out.push_str(&format!("<td>{}</td>", inlines(cell)));
|
||||||
|
}
|
||||||
|
out.push_str("</tr>\n");
|
||||||
|
}
|
||||||
|
out.push_str("</table>\n");
|
||||||
|
}
|
||||||
|
Block::Rule => out.push_str("<hr/>\n"),
|
||||||
|
// Raw HTML is passed through: this is the one family of formats where it
|
||||||
|
// is already in the right language.
|
||||||
|
Block::Html(html) => {
|
||||||
|
out.push_str(html.trim_end());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
// Alignment reaches only the fixed-width text formats. Pagination is a
|
||||||
|
// WML concern; a browser scrolls one document.
|
||||||
|
Block::Aligned { block, .. } => self_block(block, out),
|
||||||
|
Block::CardBreak { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inlines(inline: &[Inline]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for item in inline {
|
||||||
|
match item {
|
||||||
|
Inline::Text(text) => out.push_str(&escape::text(text)),
|
||||||
|
Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))),
|
||||||
|
Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner))),
|
||||||
|
Inline::Strong(inner) => out.push_str(&format!("<strong>{}</strong>", inlines(inner))),
|
||||||
|
// XHTML-MP 1.0 has no <del>, and <strike> is not in the profile, so
|
||||||
|
// the text survives without its markup rather than being dropped.
|
||||||
|
Inline::Strike(inner) => out.push_str(&inlines(inner)),
|
||||||
|
Inline::Link { href, title, label } => {
|
||||||
|
let title = title
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<a href=\"{}\"{title}>{}</a>",
|
||||||
|
escape::attr(href),
|
||||||
|
inlines(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Inline::Image { src, title, alt } => {
|
||||||
|
let title = title
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<img src=\"{}\" alt=\"{}\"{title}/>",
|
||||||
|
escape::attr(src),
|
||||||
|
escape::attr(&Doc::plain_text(alt))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Inline::SoftBreak => out.push('\n'),
|
||||||
|
Inline::HardBreak => out.push_str("<br/>\n"),
|
||||||
|
Inline::Html(html) => out.push_str(html),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prolog_is_present_only_when_asked_for() {
|
||||||
|
let doc = Doc { blocks: Vec::new(), first_h1: None };
|
||||||
|
assert!(document(&doc, "T", true).starts_with("<?xml"));
|
||||||
|
assert!(document(&doc, "T", false).starts_with("<!DOCTYPE html"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue