sites/plugins/smolnet.py

437 lines
18 KiB
Python
Raw Normal View History

feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
"""Render a Pelican site as a small-net capsule beside its HTML.
A capsule is a directory of plain Markdown that a protocol daemon serves over
Gemini, Spartan, Nex, Gopher and WAP, converting per request. This writes one
out of the same content the HTML build uses, so the two never drift.
Written against `itsybitsy`, which parses **no frontmatter at all**: a document
is pure Markdown and its metadata lives beside it in a per-directory sidecar.
Pelican's own metadata is a block of bare `Title:`/`Date:` lines at the head of
every file, so served unchanged it becomes the first paragraph of every page.
Stripping that is most of what this plugin does.
Nothing here is specific to one site. Everything that differs between sites is a
setting, and every setting has a default, so a site can switch it on with just
`PLUGINS = ['smolnet']`.
SMOLNET_OUTPUT 'smolnet' directory under OUTPUT_PATH
SMOLNET_SECTION_DIR 'section' subdirectory holding one index per category
SMOLNET_LATEST 15 entries on the front page; 0 drops the section
SMOLNET_META [date, category] fields for the line under an article title
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
SMOLNET_SECTION_ORDER {} category name -> sort key, else alphabetical
SMOLNET_MIN_SECTIONS 2 below this, the section index is dropped
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
SMOLNET_LABELS {} override any heading, see LABELS
SMOLNET_ASSETS None directories to copy; defaults to STATIC_PATHS
SMOLNET_SIDECAR '.itsybitsy.toml'
SMOLNET_ROT13_ATTR None attribute holding a rot13 address to reveal
SMOLNET_TAGLINE None one line at the foot of the index
Why link-only paragraphs rather than lists: gemtext has one kind of link, a line
beginning `=>`, so a renderer has to lift links out of the prose. itsybitsy's
gemtext renderer emits a bare `=>` line for a paragraph that holds nothing but
links, so a run of links comes out as a clean run of link lines. A Markdown list
would instead print a bullet *and* a link line for every entry, listing
everything twice.
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
Gemini clients number link lines themselves, which means a menu of areas is
dialled by number without this plugin writing a single bracket.
The links are separated by **hard breaks**, which is two trailing spaces. gemtext
lifts each link out either way, but the inline formats lay a paragraph out as
flowing text, so with soft breaks a six-item menu renders as one run-on line in a
browser and on a handset. A hard break becomes `<br/>` there and costs gemtext
nothing. The `text` renderer used for Nex and Gopher flattens both break kinds to
a space and still reflows, which is the one format this shape does not serve; a
site whose main face is Gopher should emit lists instead.
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
"""
import os
import re
import shutil
from pelican import signals
from pelican.generators import Generator
LABELS = {
"sections": "Sections",
"latest": "Latest",
"archive": "All entries",
"pages": "Pages",
"back": "Back to the index",
}
# Pelican's metadata block: bare `Key: value` lines from the first line on,
# with indented continuations, ending at the first blank line. This is
# Python-Markdown's Meta extension, not YAML, and it has no delimiters.
META_LINE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*:")
META_CONT = re.compile(r"^\s+\S")
# Pelican's internal link syntax. Left alone these reach the capsule verbatim.
INTERNAL = re.compile(r"\{(filename|static|attach)\}([^)\s]+)")
# Links written against the HTML build, e.g. ](/go-west.html). Pelican's
# {filename} form is the portable one, but plenty of content predates knowing
# that and hardcodes the output URL instead. A capsule has no .html, and the
# daemon resolves a name carrying an extension literally, so these 404 unless
# they are rewritten.
HARDCODED = re.compile(r"\]\(\s*(/[^)\s]*?\.html)([^)]*)\)")
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
SUMMARY_MARKER = "<!-- PELICAN_END_SUMMARY -->"
fix: recover one hotlinked image, retire the other eleven Twelve images across seven articles were hotlinked from servers that no longer exist, so they were broken on the live sites. Checked every one against the Wayback Machine, patiently enough to trust the answer: an earlier pass appeared to find seven, but that was a test treating an empty response as a hit. One survives. visualberlin.org's p1010789, a 2011 capture of the projector set up against Mannheim city hall during the Flimmerkiste wildscreening, is now a local asset in flux.vision/content/library/ and the reference points at it. The other eleven were never captured. Both dead domains are archived, so this is not a gap in coverage: of 8,438 visualberlin.org URLs and 204 for mutanten.org, only that one image is there. mutanten.org's archive does not reach back to its fp-content era at all, which is where the Logan's Run stills lived. So lostimages.py learns about dead hosts, which it matches rather than fetching, because a build must not depend on the network and a dead host is a fact about the world rather than about the document. The references stay in the Markdown, as the existing ones do: the reference records what was there. It matters more on the capsule than on the web. A browser shows a broken image as a silent gap, but gemtext has no inline images, so every one becomes a visible => link line and the page would advertise a dead host as somewhere worth going. smolnet.py therefore strips them too, reading the same host list, since it works on the Markdown source and never sees Pelican's output. lostimages.py moves to the repo-level plugins/ directory beside smolnet. It was shared already, with flux.vision reaching into tobias.raayoni-last.com for it, and randogoth.com now needs it too; a shared plugin living inside one site was the wrong shape for three consumers. Left alone deliberately: the two mutanten.org links in mutantenkino-website.md. That entry is about having built the site, so the link is its subject rather than decoration, and a dead link does not make the record wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:04:28 +03:00
# Markdown image syntax, for dropping the ones whose host is gone. Matched on
# the source rather than the rendered HTML because this plugin reads the
# Markdown directly, so lostimages.py, which works on Pelican's output, never
# sees the capsule.
MD_IMAGE = re.compile(r'!\[[^\]]*\]\(\s*(https?://[^\s)]+)[^)]*\)')
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
def rot13(text):
"""The obfuscation used on addresses in the HTML templates."""
out = []
for ch in text:
if "a" <= ch <= "z":
out.append(chr((ord(ch) - 97 + 13) % 26 + 97))
elif "A" <= ch <= "Z":
out.append(chr((ord(ch) - 65 + 13) % 26 + 65))
else:
out.append(ch)
return "".join(out)
def strip_metadata(text):
"""Drop the leading metadata block, returning the body alone.
Stops at the first line that is neither a `Key:` line nor an indented
continuation of one, so a document that happens to open on prose is
returned untouched rather than having its first paragraph eaten.
"""
lines = text.splitlines()
i = 0
seen = False
while i < len(lines):
line = lines[i]
if not line.strip():
if seen:
i += 1
break
if META_LINE.match(line):
seen = True
elif seen and META_CONT.match(line):
pass
else:
break
i += 1
if not seen:
return text
return "\n".join(lines[i:]).lstrip("\n")
def rewrite_internal(text, by_source, warn):
"""Resolve `{filename}other.md` against the documents actually built.
The target is a path relative to the referring file, which for a flat
content directory means a bare basename. Anything that does not resolve is
reported rather than silently emitted, because a literal `{filename}` in a
capsule is both broken and ugly.
"""
def sub(match):
kind, target = match.group(1), match.group(2)
key = os.path.basename(target)
if kind == "filename" and key in by_source:
return "/" + by_source[key]
warn("smolnet: unresolved {%s}%s", kind, target)
return "/" + key.rsplit(".", 1)[0]
return INTERNAL.sub(sub, text)
def rewrite_html_links(text, slugs, warn):
"""Point links written against the HTML build at the capsule instead.
`/pages/glossary.html` and `/go-west.html` both become `/<slug>`, since the
capsule is flat. A target whose slug is not among the documents built is
reported and left alone rather than silently rewritten into a second broken
link.
"""
def sub(match):
target, rest = match.group(1), match.group(2)
slug = os.path.basename(target)[: -len(".html")]
if slug in slugs:
return "](/%s%s)" % (slug, rest)
warn("smolnet: link to %s matches no document", target)
return match.group(0)
return HARDCODED.sub(sub, text)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
def reveal_rot13(text, attr):
"""Replace a block of HTML carrying a rot13 address with the address itself.
Address obfuscation defends against web crawlers and depends on JavaScript,
so over Gemini it is both pointless and impossible. The renderer drops raw
HTML blocks outright, which would take the address with them: on a page
whose purpose is to carry it, that is a silent loss rather than a tidy-up.
The whole block is replaced rather than the tag that carries the attribute.
Matching tags means guessing where the element ends, and the first attempt
at this stopped at the first closing tag and left the rest of the markup
stranded in the output. Markdown already separates block-level HTML by blank
lines, so the block is a boundary the format gives us for free.
"""
if not attr:
return text
carrier = re.compile(r"\b" + re.escape(attr) + r'="([^"]+)"', re.IGNORECASE)
blocks = re.split(r"(\n[ \t]*\n)", text)
out = []
for block in blocks:
found = carrier.search(block)
out.append(rot13(found.group(1)) if found else block)
return "".join(out)
fix: recover one hotlinked image, retire the other eleven Twelve images across seven articles were hotlinked from servers that no longer exist, so they were broken on the live sites. Checked every one against the Wayback Machine, patiently enough to trust the answer: an earlier pass appeared to find seven, but that was a test treating an empty response as a hit. One survives. visualberlin.org's p1010789, a 2011 capture of the projector set up against Mannheim city hall during the Flimmerkiste wildscreening, is now a local asset in flux.vision/content/library/ and the reference points at it. The other eleven were never captured. Both dead domains are archived, so this is not a gap in coverage: of 8,438 visualberlin.org URLs and 204 for mutanten.org, only that one image is there. mutanten.org's archive does not reach back to its fp-content era at all, which is where the Logan's Run stills lived. So lostimages.py learns about dead hosts, which it matches rather than fetching, because a build must not depend on the network and a dead host is a fact about the world rather than about the document. The references stay in the Markdown, as the existing ones do: the reference records what was there. It matters more on the capsule than on the web. A browser shows a broken image as a silent gap, but gemtext has no inline images, so every one becomes a visible => link line and the page would advertise a dead host as somewhere worth going. smolnet.py therefore strips them too, reading the same host list, since it works on the Markdown source and never sees Pelican's output. lostimages.py moves to the repo-level plugins/ directory beside smolnet. It was shared already, with flux.vision reaching into tobias.raayoni-last.com for it, and randogoth.com now needs it too; a shared plugin living inside one site was the wrong shape for three consumers. Left alone deliberately: the two mutanten.org links in mutantenkino-website.md. That entry is about having built the site, so the link is its subject rather than decoration, and a dead link does not make the record wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:04:28 +03:00
def drop_dead_images(text, dead):
"""Remove images hotlinked from hosts that no longer exist.
It matters more here than on the web. A browser shows a broken image as a
silent gap, but gemtext has no inline images: every one becomes a visible
`=>` link line, so the page would advertise a dead host as though it were
somewhere worth going.
Shares LOSTIMAGES_DEAD_HOSTS with lostimages.py so there is one list, and
matches on the host rather than fetching, because a build must not depend on
the network.
"""
if not dead:
return text
def sub(match):
host = match.group(1).split("//", 1)[1].split("/")[0].lower().split(":")[0]
if any(host == d or host.endswith("." + d) for d in dead):
return ""
return match.group(0)
text = MD_IMAGE.sub(sub, text)
# a line that held nothing but the dropped image would leave a blank
return re.sub(r"\n[ \t]*\n[ \t]*\n+", "\n\n", text)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
def toml_escape(value):
return str(value).replace("\\", "\\\\").replace('"', '\\"')
def sidecar(titles):
"""A `.itsybitsy.toml` naming each file beside it.
The daemon falls back to the first heading and then to the filename, so
this is belt and braces, but it is the only place a title can live that
survives a document having no heading of its own.
"""
lines = []
for name in sorted(titles):
lines.append('[page."%s"]' % toml_escape(name))
lines.append('title = "%s"' % toml_escape(titles[name]))
lines.append("")
return "\n".join(lines)
def link(label, href):
return "[%s](%s)" % (label.replace("]", ""), href)
class CapsuleGenerator(Generator):
"""Writes the capsule once the content generators have read everything."""
def generate_context(self):
pass
def generate_output(self, writer=None):
s = self.settings
out = os.path.join(self.output_path, s.get("SMOLNET_OUTPUT", "smolnet"))
section_dir = s.get("SMOLNET_SECTION_DIR", "section")
labels = dict(LABELS, **s.get("SMOLNET_LABELS", {}))
order = s.get("SMOLNET_SECTION_ORDER", {}) or {}
latest_n = s.get("SMOLNET_LATEST", 15)
rot_attr = s.get("SMOLNET_ROT13_ATTR")
fix: recover one hotlinked image, retire the other eleven Twelve images across seven articles were hotlinked from servers that no longer exist, so they were broken on the live sites. Checked every one against the Wayback Machine, patiently enough to trust the answer: an earlier pass appeared to find seven, but that was a test treating an empty response as a hit. One survives. visualberlin.org's p1010789, a 2011 capture of the projector set up against Mannheim city hall during the Flimmerkiste wildscreening, is now a local asset in flux.vision/content/library/ and the reference points at it. The other eleven were never captured. Both dead domains are archived, so this is not a gap in coverage: of 8,438 visualberlin.org URLs and 204 for mutanten.org, only that one image is there. mutanten.org's archive does not reach back to its fp-content era at all, which is where the Logan's Run stills lived. So lostimages.py learns about dead hosts, which it matches rather than fetching, because a build must not depend on the network and a dead host is a fact about the world rather than about the document. The references stay in the Markdown, as the existing ones do: the reference records what was there. It matters more on the capsule than on the web. A browser shows a broken image as a silent gap, but gemtext has no inline images, so every one becomes a visible => link line and the page would advertise a dead host as somewhere worth going. smolnet.py therefore strips them too, reading the same host list, since it works on the Markdown source and never sees Pelican's output. lostimages.py moves to the repo-level plugins/ directory beside smolnet. It was shared already, with flux.vision reaching into tobias.raayoni-last.com for it, and randogoth.com now needs it too; a shared plugin living inside one site was the wrong shape for three consumers. Left alone deliberately: the two mutanten.org links in mutantenkino-website.md. That entry is about having built the site, so the link is its subject rather than decoration, and a dead link does not make the record wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:04:28 +03:00
dead_hosts = {h.lower().lstrip(".") for h in
s.get("LOSTIMAGES_DEAD_HOSTS", [])}
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
sidecar_name = s.get("SMOLNET_SIDECAR", ".itsybitsy.toml")
articles = [a for a in self.context.get("articles", [])]
pages = [p for p in self.context.get("pages", [])]
if not articles and not pages:
return
# Map source basename -> slug, so {filename} can be resolved. Built from
# everything published, pages included, since an article may link one.
by_source = {}
slugs = set()
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
for doc in articles + pages:
by_source[os.path.basename(doc.source_path)] = doc.slug
slugs.add(doc.slug)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
os.makedirs(out, exist_ok=True)
root_titles = {}
def emit(doc, trail):
body = strip_metadata(self._read(doc))
body = body.replace(SUMMARY_MARKER, "")
body = rewrite_internal(body, by_source, self._warn)
body = rewrite_html_links(body, slugs, self._warn)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
body = reveal_rot13(body, rot_attr)
fix: recover one hotlinked image, retire the other eleven Twelve images across seven articles were hotlinked from servers that no longer exist, so they were broken on the live sites. Checked every one against the Wayback Machine, patiently enough to trust the answer: an earlier pass appeared to find seven, but that was a test treating an empty response as a hit. One survives. visualberlin.org's p1010789, a 2011 capture of the projector set up against Mannheim city hall during the Flimmerkiste wildscreening, is now a local asset in flux.vision/content/library/ and the reference points at it. The other eleven were never captured. Both dead domains are archived, so this is not a gap in coverage: of 8,438 visualberlin.org URLs and 204 for mutanten.org, only that one image is there. mutanten.org's archive does not reach back to its fp-content era at all, which is where the Logan's Run stills lived. So lostimages.py learns about dead hosts, which it matches rather than fetching, because a build must not depend on the network and a dead host is a fact about the world rather than about the document. The references stay in the Markdown, as the existing ones do: the reference records what was there. It matters more on the capsule than on the web. A browser shows a broken image as a silent gap, but gemtext has no inline images, so every one becomes a visible => link line and the page would advertise a dead host as somewhere worth going. smolnet.py therefore strips them too, reading the same host list, since it works on the Markdown source and never sees Pelican's output. lostimages.py moves to the repo-level plugins/ directory beside smolnet. It was shared already, with flux.vision reaching into tobias.raayoni-last.com for it, and randogoth.com now needs it too; a shared plugin living inside one site was the wrong shape for three consumers. Left alone deliberately: the two mutanten.org links in mutantenkino-website.md. That entry is about having built the site, so the link is its subject rather than decoration, and a dead link does not make the record wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:04:28 +03:00
body = drop_dead_images(body, dead_hosts)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
# Blank lines, not newlines: a heading and the line under it are
# separate blocks only if a blank line divides them, otherwise the
# date runs into the first sentence as one paragraph.
head = "# %s\n\n" % doc.title
if trail:
head += "%s\n\n" % trail
text = head + body.strip() + "\n"
name = "%s.md" % doc.slug
with open(os.path.join(out, name), "w", encoding="utf-8") as fh:
fh.write(text)
root_titles[name] = doc.title
# Which metadata goes under the title. Default is date and category,
# but a site that files by tag has no useful category and may well have
# a field that means more: zonetoast carries `era`, which is the point
# of the site in a way that Pelican's default `misc` is not.
meta_fields = s.get("SMOLNET_META", ["date", "category"])
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
for article in articles:
bits = []
for field in meta_fields:
value = getattr(article, field, None)
if not value:
continue
if field == "date":
bits.append(value.strftime("%d %b %Y"))
else:
bits.append(str(value))
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
emit(article, " · ".join(bits))
for page in pages:
emit(page, "")
# One index per category.
sections = sorted(
self.context.get("categories", []),
key=lambda pair: (order.get(str(pair[0]), 99), str(pair[0])),
)
# A site that files by tag rather than category still has one category,
# Pelican's DEFAULT_CATEGORY, holding everything. Offering "misc, 14" as
# the only way in is worse than offering nothing, so one bucket is not a
# taxonomy and the whole section index goes.
if len(sections) < s.get("SMOLNET_MIN_SECTIONS", 2):
sections = []
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
if sections:
os.makedirs(os.path.join(out, section_dir), exist_ok=True)
section_titles = {}
for category, items in sections:
lines = ["# %s" % category, ""]
lines.append(self._links(sorted(items, key=lambda a: a.date, reverse=True)))
lines += ["", link(labels["back"], "/")]
name = "%s.md" % category.slug
with open(os.path.join(out, section_dir, name), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines).rstrip() + "\n")
section_titles[name] = str(category)
self._write_sidecar(os.path.join(out, section_dir, sidecar_name), section_titles)
by_date = sorted(articles, key=lambda a: a.date, reverse=True)
# The full listing, so the index can stay short without hiding anything.
archive = ["# %s" % labels["archive"], "", self._links(by_date), "",
link(labels["back"], "/")]
with open(os.path.join(out, "archive.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(archive).rstrip() + "\n")
root_titles["archive.md"] = labels["archive"]
index = ["# %s" % s.get("SITENAME", "index"), ""]
if sections:
index += ["## %s" % labels["sections"], ""]
index.append(" \n".join(
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
link("%s · %d" % (category, len(items)),
"/%s/%s" % (section_dir, category.slug))
for category, items in sections
))
index.append("")
if latest_n and by_date:
index += ["## %s" % labels["latest"], "",
self._links(by_date[:latest_n]), "",
link(labels["archive"], "/archive"), ""]
if pages:
index += ["## %s" % labels["pages"], "",
" \n".join(link(p.title, "/%s" % p.slug) for p in pages), ""]
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
tagline = s.get("SMOLNET_TAGLINE")
if tagline:
index += ["---", "", tagline, ""]
with open(os.path.join(out, "index.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(index).rstrip() + "\n")
root_titles["index.md"] = s.get("SITENAME", "index")
self._write_sidecar(os.path.join(out, sidecar_name), root_titles)
self._copy_assets(out)
def _links(self, docs):
"""One link per line in a single paragraph.
Deliberately not a Markdown list: a list item renders as a bullet *and*
a link line, so every entry would appear twice.
Targets are root-relative. A section index sits one directory down, so
relative targets would need `../` and would break again at any further
depth; `/slug` is correct from anywhere and the daemon resolves it
against the site root.
"""
rows = []
for doc in docs:
label = doc.title
if getattr(doc, "date", None):
label = "%s · %s" % (doc.date.strftime("%d %b %y"), doc.title)
rows.append(link(label, "/%s" % doc.slug))
# Hard breaks, which is two trailing spaces in CommonMark. gemtext lifts
# each link to its own => line either way, but the inline formats lay a
# paragraph out as flowing text, so without these the whole menu renders
# as one run-on line in a browser and on a handset.
return " \n".join(rows)
feat: render randogoth.com as a small-net capsule A Pelican generator that writes a capsule beside the HTML build: plain Markdown plus per-directory .itsybitsy.toml sidecars, which a protocol daemon converts per request to gemtext, XHTML-MP, WML or plain text. The obstacle was recorded in the README as "smolweb reads YAML frontmatter while Pelican uses bare Title: lines". Both halves turned out to be wrong: itsybitsy parses no frontmatter at all, by design, and metadata lives outside the document. So the transform is not a translation between dialects, it is a removal, and a protocol daemon stays ignorant of one static generator. Nothing in plugins/smolnet.py is specific to this site. Every difference is a setting and every setting has a default, so another site enables it with PLUGINS = ['smolnet']. It sits at the repo root for the same reason flux.vision reaches into tobias.raayoni-last.com for lostimages. Index entries are emitted as link-only paragraphs rather than Markdown lists, which is not a style choice: the gemtext renderer prints a bare => line for a paragraph holding nothing but links, and a bullet *and* a => line for a list item, so a list would publish every entry twice. Gemini clients number link lines themselves, so the board's dial-an-area-by-number idiom survives into the capsule without emitting a single bracket. The trade-off is in the docstring: the text renderer reflows such a run into prose, which costs nothing here because randogoth.com is not getting Nex or Gopher. Also drops a Zemanta tracking pixel that came in with a 2012 blog import and is live on the site today, firing a third-party request on every view of that article. It would have been worse in the capsule, where an image becomes a visible link line advertising a dead tracker. It was the only one across all three sites. Verified by serving the output with the real daemon: 41 articles, 2 pages, 6 area indexes and an archive, no metadata surviving, no {filename} left, trl@posteo.net revealed on Connect where the renderer would otherwise have dropped it with its enclosing HTML, images served as image/png, and the index rendering as a clean run of => lines in gemtext and as XHTML Mobile over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 08:24:18 +03:00
def _read(self, doc):
with open(doc.source_path, encoding="utf-8") as fh:
return fh.read()
def _write_sidecar(self, path, titles):
with open(path, "w", encoding="utf-8") as fh:
fh.write(sidecar(titles))
def _copy_assets(self, out):
"""Copy the static directories in, so the capsule stands on its own.
A capsule whose images are fetched over HTTPS from the web site is not
really a capsule, and link targets are resolved against the capsule
root, so pointing outside it would not work anyway.
"""
s = self.settings
names = s.get("SMOLNET_ASSETS")
if names is None:
names = [n for n in s.get("STATIC_PATHS", []) if not n.startswith(".")]
for name in names:
src = os.path.join(self.path, name)
if not os.path.isdir(src):
continue
shutil.copytree(src, os.path.join(out, name), dirs_exist_ok=True)
def _warn(self, fmt, *args):
try:
from pelican.log import logger
logger.warning(fmt, *args)
except Exception:
pass
def get_generators(pelican_object):
return CapsuleGenerator
def register():
signals.get_generators.connect(get_generators)