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_SIDECAR '.itsybitsy.toml'
SMOLNET_ROT13_ATTR None attribute holding a rot13 address to reveal SMOLNET_ROT13_ATTR None attribute holding a rot13 address to reveal
SMOLNET_TAGLINE None one line at the foot of the index 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 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 beginning `=>`, so a renderer has to lift links out of the prose. itsybitsy's
@ -58,6 +60,7 @@ LABELS = {
"archive": "All entries", "archive": "All entries",
"pages": "Pages", "pages": "Pages",
"back": "Back to the index", "back": "Back to the index",
"comments": "Comments",
} }
# Pelican's metadata block: bare `Key: value` lines from the first line on, # 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) 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): class CapsuleGenerator(Generator):
"""Writes the capsule once the content generators have read everything.""" """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 dead_hosts = {h.lower().lstrip(".") for h in
s.get("LOSTIMAGES_DEAD_HOSTS", [])} s.get("LOSTIMAGES_DEAD_HOSTS", [])}
sidecar_name = s.get("SMOLNET_SIDECAR", ".itsybitsy.toml") 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", [])] articles = [a for a in self.context.get("articles", [])]
pages = [p for p in self.context.get("pages", [])] pages = [p for p in self.context.get("pages", [])]
@ -274,7 +301,7 @@ class CapsuleGenerator(Generator):
os.makedirs(out, exist_ok=True) os.makedirs(out, exist_ok=True)
root_titles = {} root_titles = {}
def emit(doc, trail): def emit(doc, trail, thread=False):
body = strip_metadata(self._read(doc)) body = strip_metadata(self._read(doc))
body = body.replace(SUMMARY_MARKER, "") body = body.replace(SUMMARY_MARKER, "")
body = rewrite_internal(body, by_source, self._warn) body = rewrite_internal(body, by_source, self._warn)
@ -288,6 +315,11 @@ class CapsuleGenerator(Generator):
if trail: if trail:
head += "%s\n\n" % trail head += "%s\n\n" % trail
text = head + body.strip() + "\n" 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 name = "%s.md" % doc.slug
with open(os.path.join(out, name), "w", encoding="utf-8") as fh: with open(os.path.join(out, name), "w", encoding="utf-8") as fh:
fh.write(text) fh.write(text)
@ -308,7 +340,7 @@ class CapsuleGenerator(Generator):
bits.append(value.strftime("%d %b %Y")) bits.append(value.strftime("%d %b %Y"))
else: else:
bits.append(str(value)) bits.append(str(value))
emit(article, " · ".join(bits)) emit(article, " · ".join(bits), thread=True)
for page in pages: for page in pages:
emit(page, "") emit(page, "")
@ -381,12 +413,25 @@ class CapsuleGenerator(Generator):
relative targets would need `../` and would break again at any further relative targets would need `../` and would break again at any further
depth; `/slug` is correct from anywhere and the daemon resolves it depth; `/slug` is correct from anywhere and the daemon resolves it
against the site root. 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 = [] rows = []
for doc in docs: for doc in docs:
label = doc.title label = doc.title
if getattr(doc, "date", None): 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)) rows.append(link(label, "/%s" % doc.slug))
# Hard breaks, which is two trailing spaces in CommonMark. gemtext lifts # 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 # each link to its own => line either way, but the inline formats lay a

View file

@ -19,5 +19,10 @@ Slug: connect
## Feeds ## Feeds
<!-- only html -->
This site has [RSS](/feeds/all.rss.xml) and [Atom](/feeds/all.atom.xml), and so This site has [RSS](/feeds/all.rss.xml) and [Atom](/feeds/all.atom.xml), and so
does every area. Readers are better than algorithms. does every area.
<!-- end -->
Over Gemini there is no XML and none is needed: every listing is ISO dated, so a
client can subscribe to the main menu, the archive or a single area as it
stands. Readers are better than algorithms.

View file

@ -37,12 +37,27 @@ SMOLNET_SECTION_ORDER = BBS_AREAS
SMOLNET_LABELS = { SMOLNET_LABELS = {
'sections': 'Message areas', 'latest': 'Latest messages', 'sections': 'Message areas', 'latest': 'Latest messages',
'archive': 'All messages', 'back': 'Main menu', 'pages': 'System', 'archive': 'All messages', 'back': 'Main menu', 'pages': 'System',
'comments': 'Replies',
} }
# The address is rot13 in the HTML because of web crawlers. That defence needs # The address is rot13 in the HTML because of web crawlers. That defence needs
# JavaScript, which Gemini does not have, and the renderer drops raw HTML, so # JavaScript, which Gemini does not have, and the renderer drops raw HTML, so
# without this the one thing the Connect page exists to carry would vanish. # without this the one thing the Connect page exists to carry would vanish.
SMOLNET_ROT13_ATTR = 'data-r' SMOLNET_ROT13_ATTR = 'data-r'
# The capsule's half of the replies box, off until kapara is deployed. Setting
# it puts a Replies link under every article in the capsule, so it must not be
# switched on before something answers Gemini on the other end.
#
# When it lands: kapara gateways this same remark42 instance, and remark42 keys
# a thread by the article's canonical web URL, which bbs.js builds as origin +
# pathname. So kapara has to run with `-page-url 'https://randogoth.com/%s.html'`
# and `-site randogoth`, or the capsule opens a second, empty thread under every
# article instead of joining the one that is there. Port 1966, not the default:
# stunnel owns 1965 in front of itsybitsy, and kapara has to terminate its own
# TLS because Gemini client certificates are its identity mechanism.
#
# SMOLNET_COMMENTS = 'gemini://comments.randogoth.com:1966/c/{slug}'
# Articles live flat in content/; assets are carved out of that. # Articles live flat in content/; assets are carved out of that.
ARTICLE_PATHS = [''] ARTICLE_PATHS = ['']
ARTICLE_EXCLUDES = ['pages', 'library', 'images', 'files'] ARTICLE_EXCLUDES = ['pages', 'library', 'images', 'files']