From 9817e7190c00522b2d796fa2d88b61b2e8b6df06 Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 9 Oct 2026 17:19:50 +0300 Subject: [PATCH] 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 --- plugins/smolnet.py | 51 ++++++++++++++++++++++++-- randogoth.com/content/pages/connect.md | 7 +++- randogoth.com/pelicanconf.py | 15 ++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/plugins/smolnet.py b/plugins/smolnet.py index 0608b7b..761bcb1 100644 --- a/plugins/smolnet.py +++ b/plugins/smolnet.py @@ -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 "\n\n%s\n\n" % (" ".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 diff --git a/randogoth.com/content/pages/connect.md b/randogoth.com/content/pages/connect.md index 30d9cab..446ea9d 100644 --- a/randogoth.com/content/pages/connect.md +++ b/randogoth.com/content/pages/connect.md @@ -19,5 +19,10 @@ Slug: connect ## Feeds + 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. + +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. diff --git a/randogoth.com/pelicanconf.py b/randogoth.com/pelicanconf.py index a8c8bd7..91c3bcf 100644 --- a/randogoth.com/pelicanconf.py +++ b/randogoth.com/pelicanconf.py @@ -37,12 +37,27 @@ SMOLNET_SECTION_ORDER = BBS_AREAS SMOLNET_LABELS = { 'sections': 'Message areas', 'latest': 'Latest messages', 'archive': 'All messages', 'back': 'Main menu', 'pages': 'System', + 'comments': 'Replies', } # 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 # without this the one thing the Connect page exists to carry would vanish. 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. ARTICLE_PATHS = [''] ARTICLE_EXCLUDES = ['pages', 'library', 'images', 'files']