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/) - -