From 5131f1f53fec23a2a2f38c32d22f461d369b0e37 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 6 Oct 2026 08:24:18 +0300 Subject: [PATCH] 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 --- plugins/smolnet.py | 350 ++++++++++++++++++ .../cranky-frames-per-second-mayhem.md | 5 - randogoth.com/pelicanconf.py | 18 +- 3 files changed, 366 insertions(+), 7 deletions(-) create mode 100644 plugins/smolnet.py diff --git a/plugins/smolnet.py b/plugins/smolnet.py new file mode 100644 index 0000000..470cf79 --- /dev/null +++ b/plugins/smolnet.py @@ -0,0 +1,350 @@ +"""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 + SMOLNET_SECTION_ORDER {} category name -> sort key, else alphabetical + 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 + +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 +gemtext renderer emits a bare `=>` line for a paragraph that holds nothing but +links, and tolerates soft breaks inside it, so a run of links on consecutive +lines comes out as a clean run of link lines. A Markdown list would instead +print a bullet *and* a link line for every entry, listing everything twice. + +Gemini clients number link lines themselves, which means a menu of areas is +dialled by number without this plugin writing a single bracket. + +The trade-off, measured rather than assumed: that shape is exact in gemtext and +in XHTML-MP, where each link keeps its own line, but the `text` renderer used +for Nex and Gopher reflows the run into a paragraph, so an index comes out as +`RANDOM · 4 (/area/random) PLACE · 7 (/area/place) …`. A Markdown list would be +the better answer for those two and the worse answer for the other three. Links +win, because gemtext is what a capsule is mostly read over; a site serving Nex +or Gopher as its main face should flip this. +""" + +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", +} + +# 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]+)") + +SUMMARY_MARKER = "" + + +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) + + +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) + + +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) + + +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") + sidecar_name = s.get("SMOLNET_SIDECAR", ".itsybitsy.toml") + + 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 = {} + for doc in articles + pages: + by_source[os.path.basename(doc.source_path)] = doc.slug + + os.makedirs(out, exist_ok=True) + root_titles = {} + + def emit(doc, trail): + body = strip_metadata(self._read(doc)) + body = body.replace(SUMMARY_MARKER, "") + body = rewrite_internal(body, by_source, self._warn) + body = reveal_rot13(body, rot_attr) + # 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" + 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 + + for article in articles: + bits = [] + if getattr(article, "date", None): + bits.append(article.date.strftime("%d %b %Y")) + if getattr(article, "category", None): + bits.append(str(article.category)) + emit(article, " · ".join(bits)) + 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])), + ) + 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"], ""] + index.append("\n".join( + 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"], "", + "\n".join(link(p.title, "/%s" % p.slug) for p in pages), ""] + 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): + """One link per line in a single paragraph. + + Deliberately not a Markdown list: a list item renders as a bullet *and* + a link line, so every entry would appear twice. + + 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. + """ + rows = [] + for doc in docs: + label = doc.title + if getattr(doc, "date", None): + label = "%s · %s" % (doc.date.strftime("%d %b %y"), doc.title) + rows.append(link(label, "/%s" % doc.slug)) + return "\n".join(rows) + + 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) diff --git a/randogoth.com/content/cranky-frames-per-second-mayhem.md b/randogoth.com/content/cranky-frames-per-second-mayhem.md index dcbe2a8..dba1319 100755 --- a/randogoth.com/content/cranky-frames-per-second-mayhem.md +++ b/randogoth.com/content/cranky-frames-per-second-mayhem.md @@ -50,8 +50,3 @@ you will definetly enjoy this flick as much as i did. [trailer at apple.com ](http://www.apple.com/trailers/lions_gate/crank2/) - -
-![](http://img.zemanta.com/pixy.gif?x-id=d10af9a5-e15c-8697-ab3c-4d0281d41cd3) - -
diff --git a/randogoth.com/pelicanconf.py b/randogoth.com/pelicanconf.py index 9d65332..5d7914f 100644 --- a/randogoth.com/pelicanconf.py +++ b/randogoth.com/pelicanconf.py @@ -9,8 +9,8 @@ DEFAULT_LANG = 'en' # *application*: grey chrome, dialogs, a menu bar. A board is a different thing, # and the one actually wanted here. mc-pelican stays in the tree as the fallback. THEME = 'bbs' -PLUGIN_PATHS = ['plugins'] -PLUGINS = ['crtimages'] +PLUGIN_PATHS = ['plugins', '../plugins'] +PLUGINS = ['crtimages', 'smolnet'] # The sections are message areas, numbered so they can be dialled by key the way # a board's main menu works. No descriptions: the names are the whole listing, @@ -20,6 +20,20 @@ BBS_AREAS = { 'MACHINES': 4, 'GONZO': 5, 'ENDTIMES': 6, } +# The capsule mirrors the board: same areas, same order, same words. Gemini +# clients number link lines themselves, so an area is still dialled by number +# over there without the plugin writing a single bracket. +SMOLNET_SECTION_DIR = 'area' +SMOLNET_SECTION_ORDER = BBS_AREAS +SMOLNET_LABELS = { + 'sections': 'Message areas', 'latest': 'Latest messages', + 'archive': 'All messages', 'back': 'Main menu', 'pages': 'System', +} +# 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' + # Articles live flat in content/; assets are carved out of that. ARTICLE_PATHS = [''] ARTICLE_EXCLUDES = ['pages', 'library', 'images', 'files']