No description
Find a file
randogoth 3e659df9dd Serve the Mews Profile stylesheet from an embedded copy
mews_profile pins both the stylesheet's name and its content, so a site
never actually chooses either; leaving the file itself to be placed in
every content root by hand just gave operators a way to get it stale or
missing. The byte-for-byte copy from mews.page/spec 5.1 now ships in the
binary and resolves `mews-0.1.css` at any depth, matching how a page's
relative link to it resolves, across every protocol that can hit it.
2026-10-11 10:14:49 +03:00
.forgejo/workflows build: wire a Forgejo Actions workflow for checks and the static-binary release 2026-10-11 09:07:54 +03:00
bin Serve the Mews Profile stylesheet from an embedded copy 2026-10-11 10:14:49 +03:00
content feat: serve gemini behind a tls terminator 2026-10-04 22:04:52 +03:00
core Serve the Mews Profile stylesheet from an embedded copy 2026-10-11 10:14:49 +03:00
docker build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
gemtext feat: let a site render its xhtmlmp/html output as a Mews Profile page 2026-10-11 09:07:54 +03:00
text feat: let a site render its xhtmlmp/html output as a Mews Profile page 2026-10-11 09:07:54 +03:00
wap feat: let a site render its xhtmlmp/html output as a Mews Profile page 2026-10-11 09:07:54 +03:00
.dockerignore build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
.gitignore feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
AGENTS.md feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
AI-DECLARATION.md docs: condense the README, fold the audit summary in, and tighten the declaration 2026-10-06 11:04:09 +03:00
Cargo.lock feat: render wml decks with paginated card sub-documents 2026-10-04 21:05:05 +03:00
Cargo.toml feat: render fixed-width text for nex 2026-10-04 20:27:29 +03:00
devbox.json feat: serve rendered pages over http, spartan and nex 2026-10-04 19:55:30 +03:00
devbox.lock feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
Dockerfile build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
flake.lock build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00
flake.nix build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00
itsybitsy.toml feat: serve gemini behind a tls terminator 2026-10-04 22:04:52 +03:00
LICENSE feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
README.md Serve the Mews Profile stylesheet from an embedded copy 2026-10-11 10:14:49 +03:00
rustfmt.toml feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
THIRD_PARTY_NOTICES.md build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00

🕸 itsybitsy

License: Apache-2.0 AI-DECLARATION: copilot formats Built with Devbox

Serve folders of Markdown to different domains over HTTP, Gemini, Spartan, Nex and Gopher, rendered on request with no build step. One process serves many virtual hosts, each output format is a separate crate behind a cargo feature, and configuration lives in TOML files. Gemini expects TLS to be terminated in front of it; that is the one deployment requirement itsybitsy does not satisfy by itself.

The output formats are Rust ports of previous projects: the standalone Markdown to esoteric text format converter md2txt and a spinoff from that for WML, wapdown.

Deployments

Itsybitsy can be seen in action running the Gemini/Spartan/Nex/Gopher content for https://smol.place, the Gemini/Spartan capsules for https://randogoth.com and https://wap.randogoth.com

Usage

itsybitsy --config /etc/itsybitsy.toml          # serve
itsybitsy --config /etc/itsybitsy.toml --check  # validate configuration only

A server file names the sites and the listeners:

version = 1

[site.smol]
root = "/srv/smol/content"
hosts = ["smol.place", "www.smol.place"]

[listener.web]
protocol = "http"
bind = "0.0.0.0:8080"
formats = ["xhtmlmp", "html"]
default_site = "smol"

[listener.nex]
protocol = "nex"
bind = "0.0.0.0:1900"
formats = ["gemtext"]
site = "smol"

HTTP and Spartan route by the hostname the request carries, falling back to default_site; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which --check reports. A .itsybitsy.toml in a content directory sets rendering options for that directory — [defaults] for all its Markdown files, [page."name.md"] for one — with no inheritance from parent directories. Editing it re-renders on the next request.

A site can set mews_profile = true to render its xhtmlmp and html output as Mews Profile pages: the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, with raw HTML and off-site or data: images dropped rather than passed through. The stylesheet itself is served from an embedded copy, at whatever path the link's href resolves to, so there is nothing to place in the content root. Sites sharing one root must agree on this setting, since they share one render cache.

Formats and protocols

Format Served as Behind feature
gemtext text/gemini default
xhtmlmp, html WAP and desktop markup default
text text/plain, wrapped to 80 columns default
wml WML 1.3 decks for WAP 1.x handsets wml
FIGlet banners, hyphenation for text figlet, hyphenation

HTTP negotiates formats from Accept and ?format=<id> overrides it. Two decorative text features are off by default to save binary size: cargo build --release --features "figlet hyphenation".

Markdown

Markdown is parsed once per document and rendered to every format. Everything itsybitsy adds to CommonMark stays portable:

Written Means
![[path]] splice that file's lines in here
![alt](art.txt) an image whose target is a text file, inlined verbatim
<!-- card Title --> a card divider, for formats that paginate
<!-- center -->, <!-- right margin=4 --> alignment for the block that follows
<!-- only gemtext text --> … <!-- end --> a run of blocks only those formats get
<!-- except wml --> … <!-- end --> a run of blocks every other format gets

A gate names format ids, not protocols: gemtext is what Gemini, Spartan, Nex and Gopher all serve, so there is no "only on Gemini". Gates nest, each one closes inside the quote or list item it was opened in, and one left unclosed is refused rather than run on to the end of the page. An id this build does not provide matches nothing, so only wml is hidden everywhere when the wml feature is off — and so is a misspelled id.

Links should be root-relative and extensionless ([about](/about)), so the same link resolves identically from every protocol:

Request Serves
/ index.md
/foo foo.md, else foo/index.md
/foo.md redirects to /foo
/img.png the file itself, by media type

Nothing outside a content root is reachable, dot-prefixed components are refused, and include expansion is depth-, line- and byte-capped. There are no generated directory listings — only index.md.

Gemini and TLS

itsybitsy does not speak TLS. A TLS wrapper (stunnel, ghostunnel) or a load balancer owns 1965 and forwards plaintext to a loopback port the Gemini listener binds; it supports SNI, so each virtual host can present its own certificate. Use a long-lived self-signed certificate rather than an ACME one: Gemini clients pin the fingerprint they first saw. A TLS terminator in front is also how the container deployment below runs.

Deployment

The image builds from the repository root and runs on any OCI container host:

docker build -t itsybitsy .
docker run -p 8080:8080 -p 3000:3000 -p 1900:1900 -p 7070:7070 itsybitsy

It serves the demo site with docker/itsybitsy.toml, which binds 0.0.0.0 where the checkout's own server file binds loopback, so published ports are reachable. A real site mounts over those paths:

docker run -p 8080:8080 \
  -v /srv/mysite.toml:/srv/itsybitsy/itsybitsy.toml:ro \
  -v /srv/mysite:/srv/content:ro itsybitsy

The container runs as an unprivileged user. Port 1965 stays unexposed on the host: the Gemini listener is plaintext and a TLS terminator must own it in front, as always.

The flake deploys without Docker:

nix run . -- --config ./itsybitsy.toml   # build and serve
nix build .#static                        # fully static musl binary
scp result/bin/itsybitsy host:/usr/local/bin/

packages.static links against musl with no C dependency, so the one binary runs unchanged on any Linux host, including one that has never seen Nix; nix run .#release-static publishes it as a release asset.

Security audit

An adversarial audit of integration, containment, protocol abuse and load (2026-10-04, 138 checks over raw sockets) found three defects, all fixed with regression tests: deeply nested documents overflowed the stack and aborted the process (parsing now refuses past 100 levels), a file name with CR LF could inject a response header via redirect targets (generated URLs are now percent-encoded), and a large Markdown file was read into memory before the 8 MiB expansion cap rejected it (reads are now capped). After the fixes, 135 traversal attempts across all five protocols returned nothing outside the content roots and include bombs were bounded, at roughly 8–9k req/s with flat memory.

Outstanding: the render cache is unbounded (6.2× the source size across formats), an oversized request header yields a TCP reset instead of a 400, a listener's width is ignored, and there is no graceful shutdown. The TLS leg in front of Gemini was not tested, and the threat model assumes the content tree is author-controlled.

Development

devbox run check   # fmt --check, clippy -D warnings, test
devbox run test
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.