feat: make the capsule subscribable over Gemini

Link labels in every capsule index are now ISO 8601 and lead with the date,
which is what Gemini's subscription convention reads a feed entry from: the
first ten characters of a label must parse as YYYY-MM-DD or the line is
ignored. The board's `09 Oct 26` parsed as nothing, so the front page, the
archive and all six areas registered zero entries and the capsule was
invisible to every aggregator in Geminispace. `/archive` now carries 36.

The Connect page advertised `/feeds/all.rss.xml` and `/feeds/all.atom.xml`,
which exist only in the HTML build and 404 over Gemini. That paragraph now
sits behind an itsybitsy `only html` format gate, with prose outside it that
is true on both faces. The gate can only cut that way round: Pelican does not
implement the directive and renders it as an inert comment, so a gate
subtracts from the capsule and never from the site.

Also adds dormant `SMOLNET_COMMENTS` support, for a per-article thread link
gated to the text formats. Off until kapara is deployed, since setting it
would put a dead link under every article.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-10-09 17:19:50 +03:00
parent 17f3a4e79e
commit 9817e7190c
3 changed files with 69 additions and 4 deletions

View file

@ -25,6 +25,8 @@ setting, and every setting has a default, so a site can switch it on with just
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
SMOLNET_COMMENTS None thread URL per article, `{slug}` substituted
SMOLNET_COMMENTS_FORMATS formats that get it, default gemtext and text
Why link-only paragraphs rather than lists: gemtext has one kind of link, a line
beginning `=>`, so a renderer has to lift links out of the prose. itsybitsy's
@ -58,6 +60,7 @@ LABELS = {
"archive": "All entries",
"pages": "Pages",
"back": "Back to the index",
"comments": "Comments",
}
# Pelican's metadata block: bare `Key: value` lines from the first line on,
@ -240,6 +243,22 @@ def link(label, href):
return "[%s](%s)" % (label.replace("]", ""), href)
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)
class CapsuleGenerator(Generator):
"""Writes the capsule once the content generators have read everything."""
@ -257,6 +276,14 @@ class CapsuleGenerator(Generator):
dead_hosts = {h.lower().lstrip(".") for h in
s.get("LOSTIMAGES_DEAD_HOSTS", [])}
sidecar_name = s.get("SMOLNET_SIDECAR", ".itsybitsy.toml")
# 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"))
articles = [a for a in self.context.get("articles", [])]
pages = [p for p in self.context.get("pages", [])]
@ -274,7 +301,7 @@ class CapsuleGenerator(Generator):
os.makedirs(out, exist_ok=True)
root_titles = {}
def emit(doc, trail):
def emit(doc, trail, thread=False):
body = strip_metadata(self._read(doc))
body = body.replace(SUMMARY_MARKER, "")
body = rewrite_internal(body, by_source, self._warn)
@ -288,6 +315,11 @@ class CapsuleGenerator(Generator):
if trail:
head += "%s\n\n" % trail
text = head + body.strip() + "\n"
if thread and comments:
text += "\n%s\n" % gate(
link(labels["comments"], comments.format(slug=doc.slug)),
comments_formats,
)
name = "%s.md" % doc.slug
with open(os.path.join(out, name), "w", encoding="utf-8") as fh:
fh.write(text)
@ -308,7 +340,7 @@ class CapsuleGenerator(Generator):
bits.append(value.strftime("%d %b %Y"))
else:
bits.append(str(value))
emit(article, " · ".join(bits))
emit(article, " · ".join(bits), thread=True)
for page in pages:
emit(page, "")
@ -381,12 +413,25 @@ class CapsuleGenerator(Generator):
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.
The date is ISO 8601 and it leads the label, which is protocol surface
rather than a style choice. Gemini's subscription convention takes a
link line for a feed entry when the first ten characters of its label
are a `YYYY-MM-DD` date, reads the rest as the title, and ignores every
line where they are not, so this 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.
The separator is the one the convention's own examples use. Clients are
asked to sanitise whatever sits between the date and the title, but not
all of them do, and a `·` that survives arrives as part of the title.
"""
rows = []
for doc in docs:
label = doc.title
if getattr(doc, "date", None):
label = "%s · %s" % (doc.date.strftime("%d %b %y"), doc.title)
label = "%s - %s" % (doc.date.strftime("%Y-%m-%d"), doc.title)
rows.append(link(label, "/%s" % doc.slug))
# Hard breaks, which is two trailing spaces in CommonMark. gemtext lifts
# each link to its own => line either way, but the inline formats lay a