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