feat: add Nex and Gopher listeners

Reuses md2txt's existing nex and text renderers (both already ship
unmodified) -- rendered unwrapped gemtext still works for Gemini/Spartan
clients that wrap themselves, but Nex and Gopher clients don't, so those
two get the classic 80-column pre-wrap instead.

Neither protocol has a redirect status of its own, so Site.resolve_flat()
follows the /foo.md -> /foo canonical bounce and the WML-card-only ->
parent redirect server-side and hands back real content directly, rather
than leaving the two new listeners to invent a redirect convention that
doesn't exist on the wire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-26 15:13:43 +03:00
parent 12956a5034
commit 0b67069760
8 changed files with 290 additions and 13 deletions

View file

@ -1,16 +1,19 @@
# smolweb # smolweb
Serve a folder of Markdown as [Gemini](https://geminiprotocol.net/) capsules, Serve a folder of Markdown as [Gemini](https://geminiprotocol.net/) capsules,
[Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/) capsules, and HTTP [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/) capsules,
pages content-negotiated between WML (WAP 1.x) and XHTML-MP (WAP 2.0 and [Nex](https://nightfall.city/nex/info/specification.txt) and
modern browsers) — all rendered on the fly, no build step. [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.
```bash ```bash
smolweb --root ./content --host example.com smolweb --root ./content --host example.com
``` ```
Every listener binds by default: Gemini on `:1965` (TLS), Spartan on `:300`, Every listener binds by default: Gemini on `:1965` (TLS), Spartan on `:300`,
HTTP on `:8080`. An empty address disables one: HTTP on `:8080`, Nex on `:1900`, Gopher on `:70`. An empty address disables
one:
```bash ```bash
smolweb --root ./content --host example.com --spartan "" smolweb --root ./content --host example.com --spartan ""
@ -21,9 +24,19 @@ smolweb --root ./content --host example.com --spartan ""
Markdown is parsed once per file (cached by mtime, re-parsed the moment the 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: file changes) and rendered into every format from the same source:
- **gemtext** via [md2txt](https://code.randogoth.com/randogoth/md2txt)'s `gemini` renderer. - **gemtext**, **Nex**, and **Gopher text** via [md2txt](https://code.randogoth.com/randogoth/md2txt)'s `gemini`, `nex`, and `text` renderers respectively.
- **WML** and **XHTML-MP** via [wapdown](https://code.randogoth.com/randogoth/wapdown). - **WML** and **XHTML-MP** via [wapdown](https://code.randogoth.com/randogoth/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 Both libraries read the same leading `---` frontmatter block independently
and ignore keys they don't recognise, so one file can freely mix wapdown's 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 deck-structure keys (`title`, `split_level`, `deck_per_card`, ...) with
@ -53,6 +66,8 @@ A document with `deck_per_card: true` gets its own URL space for WML only:
`/trail/weather` serves one card's own deck directly. Gemini, Spartan, and `/trail/weather` serves one card's own deck directly. Gemini, Spartan, and
an HTTP client negotiating gemtext/XHTML-MP all redirect `/trail/weather` an HTTP client negotiating gemtext/XHTML-MP all redirect `/trail/weather`
back to `/trail` instead — that URL space only ever makes sense for WML. 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 ## HTTP content negotiation

View file

@ -10,7 +10,7 @@
"scripts": { "scripts": {
"sync": ["uv sync"], "sync": ["uv sync"],
"test": ["uv run pytest"], "test": ["uv run pytest"],
"run": ["uv run smolweb --root ./content --host localhost --spartan :3000"] "run": ["uv run smolweb --root ./content --host localhost --spartan :3000 --gopher :7070"]
} }
} }
} }

View file

@ -1,5 +1,5 @@
{ {
description = "Serve a folder of Markdown as gemtext, Spartan, WML, and XHTML-MP"; description = "Serve a folder of Markdown as gemtext, Nex, Gopher, WML, and XHTML-MP";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@ -93,6 +93,16 @@
default = ":8080"; default = ":8080";
description = "HTTP listen address; empty disables it."; description = "HTTP listen address; empty disables it.";
}; };
nexAddr = mkOption {
type = types.str;
default = ":1900";
description = "Nex listen address; empty disables it.";
};
gopherAddr = mkOption {
type = types.str;
default = ":70";
description = "Gopher listen address; empty disables it.";
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@ -112,12 +122,15 @@
++ lib.optionals (cfg.geminiAddr != "") [ "--gemini" cfg.geminiAddr ] ++ lib.optionals (cfg.geminiAddr != "") [ "--gemini" cfg.geminiAddr ]
++ lib.optionals (cfg.spartanAddr != "") [ "--spartan" cfg.spartanAddr ] ++ lib.optionals (cfg.spartanAddr != "") [ "--spartan" cfg.spartanAddr ]
++ lib.optionals (cfg.httpAddr != "") [ "--http" cfg.httpAddr ] ++ lib.optionals (cfg.httpAddr != "") [ "--http" cfg.httpAddr ]
++ lib.optionals (cfg.nexAddr != "") [ "--nex" cfg.nexAddr ]
++ lib.optionals (cfg.gopherAddr != "") [ "--gopher" cfg.gopherAddr ]
); );
DynamicUser = true; DynamicUser = true;
StateDirectory = "smolweb"; StateDirectory = "smolweb";
# Spartan's default port (300) and Gemini's TLS handshake # Spartan's default port (300), Gopher's default port (70),
# both need this rather than running the whole service as # and Gemini's TLS handshake all need this rather than
# root -- DynamicUser alone can't bind a privileged port. # running the whole service as root -- DynamicUser alone
# can't bind a privileged port.
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";

View file

@ -1,4 +1,4 @@
"""smolweb: serve a folder of Markdown as gemtext, Spartan, WML, and XHTML-MP.""" """smolweb: serve a folder of Markdown as gemtext, Nex, Gopher, WML, and XHTML-MP."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -10,7 +10,9 @@ from typing import Tuple
from .cert import ensure_certificate from .cert import ensure_certificate
from .servers.gemini import GeminiServer from .servers.gemini import GeminiServer
from .servers.gopher import GopherServer
from .servers.http import SmolwebHTTPServer from .servers.http import SmolwebHTTPServer
from .servers.nex import NexServer
from .servers.spartan import SpartanServer from .servers.spartan import SpartanServer
from .site import Site from .site import Site
@ -31,6 +33,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--gemini", default=":1965", metavar="[HOST]:PORT", help="Gemini listen address; empty disables it (default: :1965).") parser.add_argument("--gemini", default=":1965", metavar="[HOST]:PORT", help="Gemini listen address; empty disables it (default: :1965).")
parser.add_argument("--spartan", default=":300", metavar="[HOST]:PORT", help="Spartan listen address; empty disables it (default: :300).") parser.add_argument("--spartan", default=":300", metavar="[HOST]:PORT", help="Spartan listen address; empty disables it (default: :300).")
parser.add_argument("--http", default=":8080", metavar="[HOST]:PORT", help="HTTP listen address; empty disables it (default: :8080).") parser.add_argument("--http", default=":8080", metavar="[HOST]:PORT", help="HTTP listen address; empty disables it (default: :8080).")
parser.add_argument("--nex", default=":1900", metavar="[HOST]:PORT", help="Nex listen address; empty disables it (default: :1900).")
parser.add_argument("--gopher", default=":70", metavar="[HOST]:PORT", help="Gopher listen address; empty disables it (default: :70).")
parser.add_argument("--cert", type=Path, default=Path("cert.pem"), help="TLS certificate path, generated on first run if missing.") parser.add_argument("--cert", type=Path, default=Path("cert.pem"), help="TLS certificate path, generated on first run if missing.")
parser.add_argument("--key", type=Path, default=Path("key.pem"), help="TLS key path, generated on first run if missing.") parser.add_argument("--key", type=Path, default=Path("key.pem"), help="TLS key path, generated on first run if missing.")
return parser return parser
@ -67,6 +71,14 @@ def main(argv=None) -> int:
http_addr = _parse_address(args.http) http_addr = _parse_address(args.http)
threads.append(_serve_in_thread(SmolwebHTTPServer(http_addr, site), "http", http_addr)) threads.append(_serve_in_thread(SmolwebHTTPServer(http_addr, site), "http", http_addr))
if args.nex:
nex_addr = _parse_address(args.nex)
threads.append(_serve_in_thread(NexServer(nex_addr, site), "nex", nex_addr))
if args.gopher:
gopher_addr = _parse_address(args.gopher)
threads.append(_serve_in_thread(GopherServer(gopher_addr, site), "gopher", gopher_addr))
if not threads: if not threads:
sys.stderr.write("smolweb: every listener disabled, nothing to do\n") sys.stderr.write("smolweb: every listener disabled, nothing to do\n")
return 2 return 2

View file

@ -0,0 +1,81 @@
"""Gopher protocol listener (:70, plaintext TCP, RFC 1436).
Only type-0 (text) document retrieval is implemented -- no gophermap
directory listings, no type-7 search. A selector is read up to the first
CR-LF or TAB (a TAB introduces a search query, which a static Markdown
folder has no use for); the response for a text document is dot-stuffed
and terminated with a lone "." line per RFC 1436, then the connection
closes. Raw files are streamed as-is with no terminator, matching how
binary Gopher items are conventionally served.
"""
from __future__ import annotations
import socketserver
from ..site import NotFound, RawFile, Site
MAX_REQUEST_BYTES = 512 # RFC 1436: selectors should be no longer than 255 chars
SOCKET_TIMEOUT = 10.0
DEFAULT_PORT = 70
def _dot_stuff(body: bytes) -> bytes:
"""Double a leading '.' on any line, so it can't be read as the Lastline."""
lines = body.split(b"\n")
return b"\n".join(b"." + line if line.startswith(b".") else line for line in lines)
class GopherHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
self.request.settimeout(SOCKET_TIMEOUT)
try:
raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
except OSError:
return
selector = raw.split(b"\t", 1)[0].rstrip(b"\r\n")
try:
path = selector.decode("utf-8")
except UnicodeDecodeError:
return
try:
# No redirect status exists on this protocol, so a canonical
# /foo.md -> /foo bounce or a WML-card-only sub-path is
# resolved down to real content server-side instead.
resource = self.server.site.resolve_flat(path or "/")
except NotFound:
self._send_text(b"Not found.\n")
return
except Exception:
self._send_text(b"Internal error.\n")
return
if isinstance(resource, RawFile):
self._send_raw(resource.data)
else:
self._send_text(resource.gopher)
def _send_text(self, body: bytes) -> None:
try:
self.wfile.write(_dot_stuff(body))
if not body.endswith(b"\n"):
self.wfile.write(b"\n")
self.wfile.write(b".\r\n")
except OSError:
pass
def _send_raw(self, data: bytes) -> None:
try:
self.wfile.write(data)
except OSError:
pass
class GopherServer(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, site: Site) -> None:
self.site = site
super().__init__(address, GopherHandler)

View file

@ -0,0 +1,63 @@
"""Nex protocol listener (:1900, plaintext TCP).
Nex is deliberately simpler than Gemini or Spartan: the client sends a bare
path and the server sends back text or binary data and closes the
connection -- no headers, no status codes, no MIME type, no redirect
status. See https://nightfall.city/nex/info/specification.txt.
"""
from __future__ import annotations
import socketserver
from ..site import NotFound, RawFile, Site
MAX_REQUEST_BYTES = 2048
SOCKET_TIMEOUT = 10.0
DEFAULT_PORT = 1900
class NexHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
self.request.settimeout(SOCKET_TIMEOUT)
try:
raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
except OSError:
return
try:
path = raw.rstrip(b"\r\n").decode("utf-8")
except UnicodeDecodeError:
self._send(b"Bad request\n")
return
try:
# No redirect status exists on this protocol, so a canonical
# /foo.md -> /foo bounce or a WML-card-only sub-path is
# resolved down to real content server-side instead.
resource = self.server.site.resolve_flat(path or "/")
except NotFound:
self._send(b"Not found\n")
return
except Exception:
self._send(b"Internal error\n")
return
if isinstance(resource, RawFile):
self._send(resource.data)
else:
self._send(resource.nex)
def _send(self, body: bytes) -> None:
try:
self.wfile.write(body)
except OSError:
pass
class NexServer(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, site: Site) -> None:
self.site = site
super().__init__(address, NexHandler)

View file

@ -1,7 +1,8 @@
"""Path resolution, live-reload caching, and multi-format rendering. """Path resolution, live-reload caching, and multi-format rendering.
A folder of Markdown is rendered on request into gemtext (md2txt), and WML A folder of Markdown is rendered on request into gemtext, Nex, and Gopher
plus XHTML-MP (wapdown). Both libraries parse the same leading `---` text (md2txt), and WML plus XHTML-MP (wapdown). Both libraries parse the
same leading `---`
frontmatter block independently and ignore keys they don't recognise, so frontmatter block independently and ignore keys they don't recognise, so
one file can carry both a wapdown deck-structure key (`title`, one file can carry both a wapdown deck-structure key (`title`,
`split_level`, `deck_per_card`) and an md2txt typography key `split_level`, `deck_per_card`) and an md2txt typography key
@ -60,6 +61,8 @@ class Document:
xhtml: bytes xhtml: bytes
html: bytes # xhtml with the XML prolog stripped, for the text/html fallback html: bytes # xhtml with the XML prolog stripped, for the text/html fallback
wml_index: bytes # the deck served at the document's own URL wml_index: bytes # the deck served at the document's own URL
nex: bytes # md2txt's nex renderer, for the Nex protocol
gopher: bytes # md2txt's text renderer, for a Gopher type-0 response
wml_cards: Dict[str, bytes] = field(default_factory=dict) # deck_per_card only wml_cards: Dict[str, bytes] = field(default_factory=dict) # deck_per_card only
cache_control: Optional[int] = None cache_control: Optional[int] = None
@ -145,6 +148,29 @@ class Site:
raise Redirect(self._url_for(parent_source)) raise Redirect(self._url_for(parent_source))
raise NotFound(url_path) raise NotFound(url_path)
def resolve_flat(self, url_path: str) -> Union[Document, RawFile]:
"""Resolve a request path the way `resolve()` does, but never hands
the caller a Redirect or a WmlCard.
Nex and Gopher have no redirect status of their own -- there is
nothing to bounce the client back with -- so instead of a 3xx-style
response, the canonical target's content is resolved and served
directly. The loop cap is defensive: both Redirect and WmlCard
currently only ever point at a real, directly-resolvable document,
so one extra hop always suffices in practice.
"""
for _ in range(5):
try:
resource = self.resolve(url_path)
except Redirect as redirect:
url_path = redirect.location
continue
if isinstance(resource, WmlCard):
url_path = resource.parent_url
continue
return resource
raise NotFound(url_path)
# -- Path resolution -------------------------------------------------- # -- Path resolution --------------------------------------------------
def _clean_path(self, url_path: str) -> str: def _clean_path(self, url_path: str) -> str:
@ -243,6 +269,19 @@ class Site:
) )
gemtext = ("\n".join(gemtext_lines) + "\n").encode("utf-8") gemtext = ("\n".join(gemtext_lines) + "\n").encode("utf-8")
# Nex and Gopher clients don't wrap on their own the way Gemini and
# Spartan clients do, so unlike gemtext these are pre-wrapped at the
# classic 80-column convention rather than passed width=0.
nex_lines = convert_markdown(
md_body, width=80, frontmatter=md_fm, renderer_name="nex", base_path=base_path
)
nex = ("\n".join(nex_lines) + "\n").encode("utf-8")
gopher_lines = convert_markdown(
md_body, width=80, frontmatter=md_fm, renderer_name="text", base_path=base_path
)
gopher = ("\n".join(gopher_lines) + "\n").encode("utf-8")
xhtml = wapdown_convert( xhtml = wapdown_convert(
wap_body, config=config, renderer_name="xhtmlmp", base_path=base_path wap_body, config=config, renderer_name="xhtmlmp", base_path=base_path
).encode("utf-8") ).encode("utf-8")
@ -258,6 +297,8 @@ class Site:
xhtml=xhtml, xhtml=xhtml,
html=html, html=html,
wml_index=wml_index, wml_index=wml_index,
nex=nex,
gopher=gopher,
wml_cards=wml_cards, wml_cards=wml_cards,
cache_control=config.cache_control, cache_control=config.cache_control,
) )

View file

@ -114,6 +114,58 @@ class TestWmlCardUrls:
doc = site.resolve("/trail") doc = site.resolve("/trail")
assert b"{.card" not in doc.xhtml assert b"{.card" not in doc.xhtml
def test_directive_line_does_not_leak_into_nex_or_gopher(self, site: Site):
doc = site.resolve("/trail")
assert b"{.card" not in doc.nex
assert b"{.card" not in doc.gopher
assert b"Cold and clear" in doc.nex
assert b"Cold and clear" in doc.gopher
class TestNexAndGopherRendering:
def test_nex_and_gopher_fields_are_rendered(self, site: Site):
doc = site.resolve("/about")
assert isinstance(doc, Document)
assert b"About" in doc.nex
assert b"Body." in doc.nex
# md2txt's `text` renderer (unlike `nex`) still FIGlet-banners
# h1-h3 by default, so only plain paragraph text is checked here.
assert b"Body." in doc.gopher
def test_nex_heading_has_no_figlet_banner(self, site: Site):
# The nex renderer uses a plain setext-style underline, never a
# FIGlet banner -- unlike md2txt's own `text` renderer, which does
# use one for h1-h3 by default.
doc = site.resolve("/")
assert b"# Home" not in doc.nex
assert b"Home" in doc.nex
assert b"====" in doc.nex
class TestResolveFlat:
def test_canonical_md_redirect_is_resolved_not_bounced(self, site: Site):
# Nex and Gopher have no redirect status: a request for the .md
# form must come back with the real document, not a Redirect.
resource = site.resolve_flat("/about.md")
assert isinstance(resource, Document)
assert b"About" in resource.nex
def test_unmatched_card_is_resolved_to_parent_document(self, site: Site):
resource = site.resolve_flat("/trail/nonexistent-card")
assert isinstance(resource, Document)
assert b"North: open" in resource.gopher
def test_card_subpath_is_resolved_to_parent_document_not_the_wml_card(self, site: Site):
# /trail/weather is WML-only URL space; resolve_flat must land on
# trail.md's own Document (which has .nex/.gopher), never leak a
# WmlCard (which has neither) out to the Nex/Gopher servers.
resource = site.resolve_flat("/trail/weather")
assert isinstance(resource, Document)
def test_still_raises_not_found_for_a_missing_path(self, site: Site):
with pytest.raises(NotFound):
site.resolve_flat("/nope")
class TestHtmlFallbackHasNoXmlProlog: class TestHtmlFallbackHasNoXmlProlog:
def test_html_field_has_no_prolog(self, site: Site): def test_html_field_has_no_prolog(self, site: Site):