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:
parent
17f3a4e79e
commit
9817e7190c
3 changed files with 69 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue