No description
Find a file
randogoth 5e80e84041 fix: serve a one-item menu at the Gopher root, not raw prose
RFC 4266 defines a bare gopher://host/ (no item-type prefix in the URL)
as defaulting to type '1' -- a menu -- so every RFC-following client,
Lagrange included, parses whatever the empty selector returns as
tab-delimited menu lines rather than displaying it as text. Handing back
the homepage's own rendered prose there has no tabs in it, so it parses
as zero valid entries and renders blank.

Only the truly empty selector gets the synthetic menu; its one item
points at the homepage under "/", a distinguishable non-empty selector
that still resolves to real content exactly as before. This still isn't
directory browsing -- there's still no listing of the content tree, just
the one link needed to make the root itself navigable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:31:07 +03:00
content feat: initial implementation 2026-09-26 13:34:12 +03:00
src/smolweb fix: serve a one-item menu at the Gopher root, not raw prose 2026-09-26 15:31:07 +03:00
tests fix: serve a one-item menu at the Gopher root, not raw prose 2026-09-26 15:31:07 +03:00
.gitignore chore: gitignore nix build result symlinks 2026-09-26 13:34:12 +03:00
devbox.json feat: add Nex and Gopher listeners 2026-09-26 15:16:40 +03:00
flake.lock chore: gitignore nix build result symlinks 2026-09-26 13:34:12 +03:00
flake.nix feat: add Nex and Gopher listeners 2026-09-26 15:16:40 +03:00
pyproject.toml feat: add flake.nix for NixOS deployment 2026-09-26 13:34:12 +03:00
README.md feat: add Nex and Gopher listeners 2026-09-26 15:16:40 +03:00
uv.lock feat: add flake.nix for NixOS deployment 2026-09-26 13:34:12 +03:00

smolweb

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

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

Every listener binds by default: Gemini on :1965 (TLS), Spartan on :300, HTTP on :8080, Nex on :1900, Gopher on :70. 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, Nex, and Gopher text via md2txt's gemini, nex, and text renderers respectively.
  • WML and XHTML-MP via wapdown.

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.

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. Nex and Gopher serve /trail's own content directly for the same request, since neither has a redirect status to bounce with.

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.