No description
Find a file
2026-09-26 13:34:12 +03:00
content feat: initial implementation 2026-09-26 13:34:12 +03:00
src/smolweb feat: initial implementation 2026-09-26 13:34:12 +03:00
tests feat: initial implementation 2026-09-26 13:34:12 +03:00
.gitignore feat: initial implementation 2026-09-26 13:34:12 +03:00
devbox.json feat: initial implementation 2026-09-26 13:34:12 +03:00
pyproject.toml feat: initial implementation 2026-09-26 13:34:12 +03:00
README.md feat: initial implementation 2026-09-26 13:34:12 +03:00
uv.lock feat: initial implementation 2026-09-26 13:34:12 +03:00

smolweb

Serve a folder of Markdown as Gemini capsules, Spartan capsules, 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.

smolweb --root ./content --host example.com

Every listener binds by default: Gemini on :1965 (TLS), Spartan on :300, HTTP on :8080. An empty address disables one:

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:

  • gemtext via md2txt's gemini renderer.
  • WML and XHTML-MP via wapdown.

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.

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

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.