2026-09-26 12:45:51 +03:00
# smolweb
Serve a folder of Markdown as [Gemini ](https://geminiprotocol.net/ ) capsules,
2026-09-26 15:13:43 +03:00
[Spartan ](https://portal.mozz.us/gemini/spartan.mozz.us/ ) capsules,
[Nex ](https://nightfall.city/nex/info/specification.txt ) and
[Gopher ](https://www.rfc-editor.org/rfc/rfc1436 ) documents, and HTTP pages
content-negotiated between WML (WAP 1.x) and XHTML-MP (WAP 2.0 and modern
browsers) — all rendered on the fly, no build step.
2026-09-26 12:45:51 +03:00
```bash
smolweb --root ./content --host example.com
```
Every listener binds by default: Gemini on `:1965` (TLS), Spartan on `:300` ,
2026-09-26 15:13:43 +03:00
HTTP on `:8080` , Nex on `:1900` , Gopher on `:70` . An empty address disables
one:
2026-09-26 12:45:51 +03:00
```bash
smolweb --root ./content --host example.com --spartan ""
```
## Rendering
Markdown is parsed once per file (cached by mtime, re-parsed the moment the
file changes) and rendered into every format from the same source:
2026-09-26 15:13:43 +03:00
- **gemtext**, **Nex** , and **Gopher text** via [md2txt ](https://code.randogoth.com/randogoth/md2txt )'s `gemini` , `nex` , and `text` renderers respectively.
2026-09-26 12:45:51 +03:00
- **WML** and **XHTML-MP** via [wapdown ](https://code.randogoth.com/randogoth/wapdown ).
2026-09-26 15:13:43 +03:00
Gemini and Spartan clients wrap gemtext themselves, so it's rendered
unwrapped (`width=0` ). Nex and Gopher clients don't, so those two are
pre-wrapped at the classic 80-column convention instead.
Neither Nex nor Gopher has a redirect status of its own, so the `/foo.md ->
/foo` canonical-URL bounce and the WML-card-only-path-redirects-to-its-
parent behaviour (see below) are both resolved server-side instead of
bounced back to the client — the client always gets real content back
directly, on the first request.
2026-09-26 12:45:51 +03:00
Both libraries read the same leading `---` frontmatter block independently
and ignore keys they don't recognise, so one file can freely mix wapdown's
deck-structure keys (`title` , `split_level` , `deck_per_card` , ...) with
md2txt's typography keys (`links_per_block` , ...).
wapdown's `{.card Title}` card-break directive has no meaning to md2txt's
parser, so it is stripped before gemtext rendering rather than leaking
through as literal text — a card break already renders as nothing in
XHTML-MP for the same reason (cards are a WAP 1.x screen-budget concern;
a Gemini client or browser just scrolls through one continuous document).
## URLs
Links should be root-relative and extensionless (`[about](/about)` , not
`about.md` ) — the same link then resolves identically from a Gemini,
Spartan, or HTTP client:
| Request | Serves |
| --- | --- |
| `/` | `index.md` |
| `/foo` | `foo.md` , else `foo/index.md` |
| `/foo.md` | redirects to `/foo` |
| `/img.png` | served as-is, by MIME type |
A document with `deck_per_card: true` gets its own URL space for WML only:
`/trail` serves the whole document (the menu deck, if it has one), and
`/trail/weather` serves one card's own deck directly. Gemini, Spartan, and
an HTTP client negotiating gemtext/XHTML-MP all redirect `/trail/weather`
back to `/trail` instead — that URL space only ever makes sense for WML.
2026-09-26 15:13:43 +03:00
Nex and Gopher serve `/trail` 's own content directly for the same request,
since neither has a redirect status to bounce with.
2026-09-26 12:45:51 +03:00
## HTTP content negotiation
| Accept | Served as |
| --- | --- |
| `text/vnd.wap.wml` (literal, non-wildcard) | WML |
| `application/vnd.wap.xhtml+xml` (literal, non-wildcard) | XHTML-MP |
| anything else, including a `*/*` wildcard or no header at all | the same XHTML-MP body, as `text/html` |
A `*/*` wildcard never selects WML — a browser's `Accept: text/html,
application/xhtml+xml;q=0.9,*/*;q=0.8` must never be handed WML through the
wildcard. `?format=wml` , `?format=xhtml` , or `?format=html` overrides
negotiation outright, for testing without real WAP hardware.
## TLS
If `--cert` /`--key` don't exist, a self-signed EC P-256 certificate is
generated on first run (via `openssl` , so devbox must provide it), valid for
ten years. Gemini clients use TOFU (trust-on-first-use) fingerprint pinning
rather than CA validation, so this is the norm rather than a warning.
## Development
```bash
devbox run sync # install everything, including wapdown/md2txt
devbox run test # pytest
devbox run run # serve ./content on localhost, Spartan on :3000 (unprivileged)
```
`pyproject.toml` currently points `wapdown` /`md2txt` at local paths (see the
comment there) rather than their git origins, since this checkout carries
unpushed changes to both.