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
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
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
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
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
|
2026-10-09 17:19:50 +03:00
|
|
|
SMOLNET_COMMENTS None thread URL per article, `{slug}` substituted
|
|
|
|
|
SMOLNET_COMMENTS_FORMATS formats that get it, default gemtext and text
|
2026-10-10 15:24:39 +03:00
|
|
|
SMOLNET_FOOTNOTES False rewrite [^key] footnotes into a numbered
|
|
|
|
|
Notes section; off by default since not
|
|
|
|
|
every site using this plugin has been
|
|
|
|
|
checked for footnotes
|
2026-10-22 11:25:13 +03:00
|
|
|
SMOLNET_CALLOUTS {} callout kind -> label, for `> [!kind]`
|
|
|
|
|
blocks; see rewrite_callouts
|
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
|
|
|
|
2026-10-11 16:10:07 +03:00
|
|
|
Every run of links here is a **Markdown list**, which is what each format needs:
|
|
|
|
|
Mews Profile reads a dated link as a feed entry only when it starts a list item
|
|
|
|
|
(mews.page/spec 6.1), and the `text` renderer used for Nex and Gopher keeps one
|
|
|
|
|
item per line instead of reflowing a paragraph into a run-on line. gemtext has
|
|
|
|
|
one kind of link, a line beginning `=>`, and itsybitsy prints only those lines
|
|
|
|
|
for a list item that holds nothing but links, so no entry is listed twice.
|
|
|
|
|
|
|
|
|
|
That last part is a floor on the daemon version: an itsybitsy before the fix in
|
|
|
|
|
its gemtext renderer prints a bullet *and* a link line for every entry here.
|
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.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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",
|
2026-10-09 17:19:50 +03:00
|
|
|
"comments": "Comments",
|
2026-10-10 15:24:39 +03:00
|
|
|
"notes": "Notes",
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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]+)")
|
|
|
|
|
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
# 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)]+)[^)]*\)')
|
|
|
|
|
|
2026-10-10 15:24:39 +03:00
|
|
|
# A footnote definition, Markdown-Extra style: "[^key]: text". The text runs
|
|
|
|
|
# to the next blank line or the next definition, not to the next newline: a
|
|
|
|
|
# definition's own citation link is often hand-wrapped onto an unindented
|
|
|
|
|
# second physical line, which CommonMark treats as a lazy continuation of the
|
|
|
|
|
# same paragraph (rendered with no line break at all). An earlier version of
|
|
|
|
|
# this regex stopped at the first `$` and left that second line behind as an
|
|
|
|
|
# orphaned, bracket-broken paragraph in the body -- caught by building a real
|
|
|
|
|
# site and reading the output, not by the narrower check (indented
|
|
|
|
|
# continuations only) done beforehand.
|
|
|
|
|
FOOTNOTE_DEF = re.compile(
|
|
|
|
|
r"^\[\^([^\]]+)\]:[ \t]*(.*(?:\n(?![ \t]*\n|\[\^)[^\n]*)*)",
|
|
|
|
|
re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# An inline reference, "[^key]". No exclusion for a trailing ":" here: by the
|
|
|
|
|
# time this runs, FOOTNOTE_DEF.sub has already stripped every genuine
|
|
|
|
|
# definition line out of the text, so a "[^key]" that is still present can
|
|
|
|
|
# only be an inline reference -- including the fairly common case of one
|
|
|
|
|
# followed by an ordinary colon in the author's own prose ("Gnosis[^gnosis]:
|
|
|
|
|
# collective critique and experimentation..."), which an earlier version of
|
|
|
|
|
# this regex wrongly left unrewritten by trying to guard against a confusion
|
|
|
|
|
# that FOOTNOTE_DEF already resolves upstream.
|
|
|
|
|
FOOTNOTE_REF = re.compile(r"\[\^([^\]]+)\]")
|
|
|
|
|
|
|
|
|
|
# A CommonMark reference-link definition using a bare integer as its id, e.g.
|
|
|
|
|
# "[18]: https://...". At least one article here (sixnames.md) cites sources
|
|
|
|
|
# this way. A renderer resolves a later bare "[18]" in running text as a
|
|
|
|
|
# shortcut reference link to that target -- so a footnote number assigned
|
|
|
|
|
# without checking this would, if it ever landed on the same integer, turn an
|
|
|
|
|
# inserted footnote marker into a silent, wrong hyperlink instead of the
|
|
|
|
|
# citation it was meant to carry.
|
|
|
|
|
LINKREF_DEF = re.compile(r"^\[(\d+)\]:", re.MULTILINE)
|
|
|
|
|
|
2026-10-22 11:25:13 +03:00
|
|
|
# The opening line of a GitHub/Obsidian-style callout, "> [!kind] title".
|
2026-10-22 11:29:03 +03:00
|
|
|
# The trailing newline is part of the match, so a marker with no label can be
|
|
|
|
|
# dropped whole instead of leaving the quote to open on a blank line.
|
|
|
|
|
CALLOUT = re.compile(r"^([ ]{0,3}>[ ]?)\[!(\w+)\][ \t]*(.*)\n?", re.MULTILINE)
|
2026-10-22 11:25:13 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
def rewrite_html_links(text, urls, warn):
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
"""Point links written against the HTML build at the capsule instead.
|
|
|
|
|
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
Matched against each document's own rendered URL (Pelican's ARTICLE_URL /
|
|
|
|
|
PAGE_URL settings resolved per document), not by stripping ".html" off the
|
|
|
|
|
link's basename and hoping that equals the slug. That shortcut only holds
|
|
|
|
|
for a site whose URL *is* a bare "{slug}.html" -- true for randogoth.com,
|
|
|
|
|
where this was first written, false for a site like sublunar.space whose
|
|
|
|
|
ARTICLE_URL prefixes the date ("2018-06-chaxiraxi.html" for slug
|
|
|
|
|
"chaxiraxi"). There the stripped string never matched any slug, and every
|
|
|
|
|
article-to-article link passed through unrewritten, pointing the capsule
|
|
|
|
|
at a path itsybitsy has no document for.
|
|
|
|
|
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
`/pages/glossary.html` and `/go-west.html` both become `/<slug>`, since the
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
capsule is flat. A target whose rendered URL matches no document built is
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
reported and left alone rather than silently rewritten into a second broken
|
|
|
|
|
link.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def sub(match):
|
|
|
|
|
target, rest = match.group(1), match.group(2)
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
key = target.lstrip("/")
|
|
|
|
|
if key in urls:
|
|
|
|
|
return "](/%s%s)" % (urls[key], rest)
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 15:24:39 +03:00
|
|
|
def rewrite_footnotes(text, labels, warn):
|
|
|
|
|
"""Turn Markdown-Extra footnotes into something a footnote-less renderer
|
|
|
|
|
can show: inline refs become "[1]", "[2]", ... in order of first
|
|
|
|
|
appearance, and the definitions move to a "## Notes" section at the end.
|
|
|
|
|
|
|
|
|
|
itsybitsy's Markdown parser has no footnote extension enabled, so left
|
|
|
|
|
alone, both the inline "[^key]" and the "[^key]: text" definition line
|
|
|
|
|
print as literal bracket text in the capsule. Off by default (see
|
|
|
|
|
SMOLNET_FOOTNOTES) since not every site using this plugin has had its
|
|
|
|
|
content checked for footnotes.
|
|
|
|
|
"""
|
|
|
|
|
defs = {}
|
|
|
|
|
|
|
|
|
|
def strip_def(match):
|
|
|
|
|
# Collapse any lazy-continuation line breaks back into the single
|
|
|
|
|
# flowing line Markdown would have rendered them as, the way a line
|
|
|
|
|
# break folds to nothing right after an opening "[": the citation
|
|
|
|
|
# link itself can start on the wrapped line with nothing between the
|
|
|
|
|
# bracket and the break.
|
|
|
|
|
joined = re.sub(r"\s*\n\s*", " ", match.group(2)).strip()
|
|
|
|
|
joined = re.sub(r"\[\s+", "[", joined)
|
|
|
|
|
defs[match.group(1)] = joined
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
body = FOOTNOTE_DEF.sub(strip_def, text)
|
|
|
|
|
# The def lines are gone; squeeze the blank run they leave, same as
|
|
|
|
|
# drop_dead_images does for a dropped image's own empty line.
|
|
|
|
|
body = re.sub(r"\n[ \t]*\n[ \t]*\n+", "\n\n", body)
|
|
|
|
|
|
|
|
|
|
# Numbers assigned skip any integer a reference-link definition already
|
|
|
|
|
# owns elsewhere in this same document, so an inserted marker can never
|
|
|
|
|
# collide with one.
|
|
|
|
|
reserved = {int(n) for n in LINKREF_DEF.findall(body)}
|
|
|
|
|
numbers = {}
|
|
|
|
|
next_number = [1]
|
|
|
|
|
|
|
|
|
|
def number_ref(match):
|
|
|
|
|
key = match.group(1)
|
|
|
|
|
if key not in defs:
|
|
|
|
|
warn("smolnet: footnote ref [^%s] has no definition", key)
|
|
|
|
|
return match.group(0)
|
|
|
|
|
if key not in numbers:
|
|
|
|
|
while next_number[0] in reserved:
|
|
|
|
|
next_number[0] += 1
|
|
|
|
|
numbers[key] = next_number[0]
|
|
|
|
|
next_number[0] += 1
|
|
|
|
|
return "[%d]" % numbers[key]
|
|
|
|
|
|
|
|
|
|
body = FOOTNOTE_REF.sub(number_ref, body)
|
|
|
|
|
|
|
|
|
|
if not numbers:
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
notes = ["## %s" % labels["notes"], ""]
|
|
|
|
|
for key, n in sorted(numbers.items(), key=lambda kv: kv[1]):
|
|
|
|
|
notes.append("%d. %s" % (n, defs[key]))
|
|
|
|
|
return body.rstrip() + "\n\n" + "\n".join(notes) + "\n"
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 11:25:13 +03:00
|
|
|
def rewrite_callouts(text, kinds):
|
|
|
|
|
"""Turn a `> [!kind]` marker into a bold label line, leaving the rest of
|
|
|
|
|
the passage an ordinary blockquote.
|
|
|
|
|
|
|
|
|
|
The HTML build renders these as boxes (an <aside>, via a Markdown
|
|
|
|
|
extension in the site itself). gemtext has nothing to draw a box with, and
|
|
|
|
|
a quote is the one block it does set apart, so the passage stays one; only
|
2026-10-22 11:29:03 +03:00
|
|
|
the marker would otherwise print literally as "[!aside]". Kinds not in
|
2026-10-22 11:25:13 +03:00
|
|
|
`kinds` are left alone, matching the HTML side, which also only boxes the
|
|
|
|
|
kinds it was told about.
|
|
|
|
|
"""
|
|
|
|
|
if not kinds:
|
|
|
|
|
return text
|
|
|
|
|
kinds = {k.lower(): v for k, v in kinds.items()}
|
|
|
|
|
|
|
|
|
|
def sub(match):
|
|
|
|
|
kind = match.group(2).lower()
|
|
|
|
|
if kind not in kinds:
|
|
|
|
|
return match.group(0)
|
|
|
|
|
title = match.group(3).strip() or kinds[kind]
|
2026-10-22 11:29:03 +03:00
|
|
|
if not title:
|
|
|
|
|
# No label: the marker line goes, the quote stays.
|
|
|
|
|
return ""
|
2026-10-22 11:25:13 +03:00
|
|
|
# The label as a paragraph of its own inside the quote, so it can't run
|
|
|
|
|
# into the passage's first sentence.
|
2026-10-22 11:29:03 +03:00
|
|
|
return "%s**%s**\n>\n" % (match.group(1), title)
|
2026-10-22 11:25:13 +03:00
|
|
|
|
|
|
|
|
return CALLOUT.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 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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 16:10:07 +03:00
|
|
|
def bullets(items):
|
|
|
|
|
"""A Markdown list, one item per line. See the module header."""
|
|
|
|
|
return "\n".join("- %s" % item for item in items)
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 17:19:50 +03:00
|
|
|
def gate(block, formats):
|
|
|
|
|
"""Wrap a block in an itsybitsy format gate.
|
|
|
|
|
|
|
|
|
|
A gate names output format ids, not protocols: `gemtext` is the one Gemini,
|
|
|
|
|
Spartan, Nex and Gopher are all served from, so there is no gating on a
|
|
|
|
|
protocol name. Blank lines because a gate encloses *blocks*, and a
|
|
|
|
|
directive run together with the line under it is one block.
|
|
|
|
|
|
|
|
|
|
Note which way this can cut. Pelican does not implement the directive and
|
|
|
|
|
renders it as an inert HTML comment, so a gate can only ever subtract from
|
|
|
|
|
the capsule, never from the site. That is the right direction for anything
|
|
|
|
|
this plugin writes, because the capsule is all it writes.
|
|
|
|
|
"""
|
|
|
|
|
return "<!-- only %s -->\n\n%s\n\n<!-- end -->" % (" ".join(formats), block)
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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")
|
2026-10-10 15:24:39 +03:00
|
|
|
footnotes = s.get("SMOLNET_FOOTNOTES", False)
|
2026-10-22 11:25:13 +03:00
|
|
|
callouts = s.get("SMOLNET_CALLOUTS", {})
|
2026-10-09 17:19:50 +03:00
|
|
|
# kapara serves the same remark42 thread the HTML widget shows, over
|
|
|
|
|
# Gemini, Spartan and Nex. Articles only: the widget is on articles
|
|
|
|
|
# only, and a page with a thread nobody can reach from the site is a
|
|
|
|
|
# second conversation, which is the one thing the gateway exists to
|
|
|
|
|
# avoid. Gated because this tree can also be served as WML or
|
|
|
|
|
# XHTML-MP, and a handset cannot open a gemini:// link.
|
|
|
|
|
comments = s.get("SMOLNET_COMMENTS")
|
|
|
|
|
comments_formats = s.get("SMOLNET_COMMENTS_FORMATS", ("gemtext", "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
|
|
|
|
|
|
|
|
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 = {}
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
urls = {}
|
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
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
urls[doc.url] = 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 = {}
|
|
|
|
|
|
2026-10-09 17:19:50 +03:00
|
|
|
def emit(doc, trail, thread=False):
|
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 = strip_metadata(self._read(doc))
|
|
|
|
|
body = body.replace(SUMMARY_MARKER, "")
|
|
|
|
|
body = rewrite_internal(body, by_source, self._warn)
|
Fix rewrite_html_links for sites whose URL isn't a bare {slug}.html
It derived the slug to match against by stripping ".html" off a
hardcoded link's basename, which only equals the real slug on a site
whose ARTICLE_URL/PAGE_URL *is* "{slug}.html" -- true for randogoth.com,
where this was first written and verified, false for sublunar.space's
ARTICLE_URL ("{date:%Y}-{date:%m}-{slug}.html": "chaxiraxi"'s real link
target is "2018-06-chaxiraxi.html", whose basename-minus-extension is
"2018-06-chaxiraxi", never equal to the slug "chaxiraxi"). Every such
article-to-article link passed smolnet's own "no match" branch and
reached the capsule unrewritten, pointing at a path itsybitsy has no
document for.
Matches against each document's own rendered .url instead, which is
correct for any ARTICLE_URL/PAGE_URL shape rather than one specific
one. Verified against sublunar.space's full content: the reported
broken link and 160+ others like it across the corpus now resolve;
every remaining unrewritten .html link left in the capsule traces to a
target genuinely absent from it (a tag-index page, a draft, a hidden
page) or to a pre-existing dead link already broken on the live HTML
site, unrelated to this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 15:39:26 +03:00
|
|
|
body = rewrite_html_links(body, urls, 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)
|
2026-10-22 11:25:13 +03:00
|
|
|
body = rewrite_callouts(body, callouts)
|
2026-10-10 15:24:39 +03:00
|
|
|
if footnotes:
|
|
|
|
|
body = rewrite_footnotes(body, labels, 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
|
|
|
# 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"
|
2026-10-09 17:19:50 +03:00
|
|
|
if thread and comments:
|
|
|
|
|
text += "\n%s\n" % gate(
|
|
|
|
|
link(labels["comments"], comments.format(slug=doc.slug)),
|
|
|
|
|
comments_formats,
|
|
|
|
|
)
|
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
|
|
|
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
|
|
|
|
|
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
# 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 = []
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
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))
|
2026-10-09 17:19:50 +03:00
|
|
|
emit(article, " · ".join(bits), thread=True)
|
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 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])),
|
|
|
|
|
)
|
feat: make smolnet usable by a site that is not randogoth.com
Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.
A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.
Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.
The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:21:46 +03:00
|
|
|
# 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"], ""]
|
2026-10-11 16:10:07 +03:00
|
|
|
index.append(bullets(
|
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"], "",
|
2026-10-11 16:10:07 +03:00
|
|
|
bullets(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):
|
2026-10-11 16:10:07 +03:00
|
|
|
"""A list of dated links, one entry per item.
|
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
|
|
|
|
|
|
|
|
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.
|
2026-10-09 17:19:50 +03:00
|
|
|
|
|
|
|
|
The date is ISO 8601 and it leads the label, which is protocol surface
|
2026-10-11 16:10:07 +03:00
|
|
|
rather than a style choice. Both subscription conventions this capsule
|
|
|
|
|
is read through take a link as a feed entry when its label begins with
|
|
|
|
|
a `YYYY-MM-DD` date and read the rest as the title: Gemini's over the
|
|
|
|
|
`=>` lines, Mews Profile's over the list items (6.1). That one format is
|
|
|
|
|
what makes each index a feed: the front page, the archive and all six
|
|
|
|
|
areas, subscribable in a client and submittable to an aggregator. A
|
|
|
|
|
board's `09 Oct 26` parses as nothing and registers zero entries.
|
|
|
|
|
|
|
|
|
|
Nothing separates the date from the title. Gemini's examples put a dash
|
|
|
|
|
there and ask clients to strip whatever they find, but Mews reads the
|
|
|
|
|
label from the date to the end of the line, so a separator arrives as
|
|
|
|
|
part of the title -- as does a `·` in a Gemini client that skips the
|
|
|
|
|
sanitising step.
|
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
|
|
|
"""
|
|
|
|
|
rows = []
|
|
|
|
|
for doc in docs:
|
|
|
|
|
label = doc.title
|
|
|
|
|
if getattr(doc, "date", None):
|
2026-10-11 16:10:07 +03:00
|
|
|
label = "%s %s" % (doc.date.strftime("%Y-%m-%d"), doc.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
|
|
|
rows.append(link(label, "/%s" % doc.slug))
|
2026-10-11 16:10:07 +03:00
|
|
|
return bullets(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)
|