From bfb54d3ffa188dab69ddf190d1e7e4096f175a0d Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 10 Oct 2026 15:24:39 +0300 Subject: [PATCH] Add footnote support to smolnet itsybitsy's Markdown parser has no footnote extension enabled, so a [^key] reference and its [^key]: definition were passing through as literal bracket text in every capsule. SMOLNET_FOOTNOTES, off by default, rewrites references into numbered [N] markers in order of first appearance and relocates definitions to a trailing "## Notes" section. Verified against sublunar.space's real content (the first site to turn this on), which surfaced three cases synthetic test data would not have: - A definition whose citation link lazy-wraps onto an unindented second physical line (ordinary CommonMark paragraph continuation) was truncated at the first newline, leaving the wrapped half as orphaned, bracket-broken text in the body. FOOTNOTE_DEF now consumes lazy-continuation lines up to the next blank line or next definition, and joins them back into one line. - A reference immediately followed by an ordinary colon in the author's own prose ("Gnosis[^gnosis]: collective critique...") was being skipped by a now-redundant negative lookahead that existed to avoid confusing a reference with a definition's own opening bracket -- a confusion FOOTNOTE_DEF already resolves upstream by stripping every genuine definition line before FOOTNOTE_REF ever runs. - An assigned footnote number could collide with a pre-existing bare- integer reference-link id already used elsewhere in the same article (sixnames.md cites sources that way); CommonMark would silently resolve the collision into an unrelated hyperlink. Numbers are now assigned skipping any integer a "[N]: url" definition already owns in the same document. randogoth.com has no footnotes in its content (grep confirmed), so it is unaffected either way; zonetoast.com's content could not be checked from here, which is why this stays opt-in rather than on by default. Co-Authored-By: Claude Sonnet 5 --- plugins/smolnet.py | 100 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/plugins/smolnet.py b/plugins/smolnet.py index 761bcb1..cc4460d 100644 --- a/plugins/smolnet.py +++ b/plugins/smolnet.py @@ -27,6 +27,10 @@ setting, and every setting has a default, so a site can switch it on with just 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 + SMOLNET_FOOTNOTES False rewrite [^key] footnotes into a numbered + Notes section; off by default since not + every site using this plugin has been + checked for footnotes 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 @@ -61,6 +65,7 @@ LABELS = { "pages": "Pages", "back": "Back to the index", "comments": "Comments", + "notes": "Notes", } # Pelican's metadata block: bare `Key: value` lines from the first line on, @@ -87,6 +92,39 @@ SUMMARY_MARKER = "" # sees the capsule. MD_IMAGE = re.compile(r'!\[[^\]]*\]\(\s*(https?://[^\s)]+)[^)]*\)') +# A footnote definition, Markdown-Extra style: "[^key]: text". The text runs +# to the next blank line or the next definition, not to the next newline: a +# definition's own citation link is often hand-wrapped onto an unindented +# second physical line, which CommonMark treats as a lazy continuation of the +# same paragraph (rendered with no line break at all). An earlier version of +# this regex stopped at the first `$` and left that second line behind as an +# orphaned, bracket-broken paragraph in the body -- caught by building a real +# site and reading the output, not by the narrower check (indented +# continuations only) done beforehand. +FOOTNOTE_DEF = re.compile( + r"^\[\^([^\]]+)\]:[ \t]*(.*(?:\n(?![ \t]*\n|\[\^)[^\n]*)*)", + re.MULTILINE, +) + +# An inline reference, "[^key]". No exclusion for a trailing ":" here: by the +# time this runs, FOOTNOTE_DEF.sub has already stripped every genuine +# definition line out of the text, so a "[^key]" that is still present can +# only be an inline reference -- including the fairly common case of one +# followed by an ordinary colon in the author's own prose ("Gnosis[^gnosis]: +# collective critique and experimentation..."), which an earlier version of +# this regex wrongly left unrewritten by trying to guard against a confusion +# that FOOTNOTE_DEF already resolves upstream. +FOOTNOTE_REF = re.compile(r"\[\^([^\]]+)\]") + +# A CommonMark reference-link definition using a bare integer as its id, e.g. +# "[18]: https://...". At least one article here (sixnames.md) cites sources +# this way. A renderer resolves a later bare "[18]" in running text as a +# shortcut reference link to that target -- so a footnote number assigned +# without checking this would, if it ever landed on the same integer, turn an +# inserted footnote marker into a silent, wrong hyperlink instead of the +# citation it was meant to carry. +LINKREF_DEF = re.compile(r"^\[(\d+)\]:", re.MULTILINE) + def rot13(text): """The obfuscation used on addresses in the HTML templates.""" @@ -220,6 +258,65 @@ def drop_dead_images(text, dead): return re.sub(r"\n[ \t]*\n[ \t]*\n+", "\n\n", text) +def rewrite_footnotes(text, labels, warn): + """Turn Markdown-Extra footnotes into something a footnote-less renderer + can show: inline refs become "[1]", "[2]", ... in order of first + appearance, and the definitions move to a "## Notes" section at the end. + + itsybitsy's Markdown parser has no footnote extension enabled, so left + alone, both the inline "[^key]" and the "[^key]: text" definition line + print as literal bracket text in the capsule. Off by default (see + SMOLNET_FOOTNOTES) since not every site using this plugin has had its + content checked for footnotes. + """ + defs = {} + + def strip_def(match): + # Collapse any lazy-continuation line breaks back into the single + # flowing line Markdown would have rendered them as, the way a line + # break folds to nothing right after an opening "[": the citation + # link itself can start on the wrapped line with nothing between the + # bracket and the break. + joined = re.sub(r"\s*\n\s*", " ", match.group(2)).strip() + joined = re.sub(r"\[\s+", "[", joined) + defs[match.group(1)] = joined + return "" + + body = FOOTNOTE_DEF.sub(strip_def, text) + # The def lines are gone; squeeze the blank run they leave, same as + # drop_dead_images does for a dropped image's own empty line. + body = re.sub(r"\n[ \t]*\n[ \t]*\n+", "\n\n", body) + + # Numbers assigned skip any integer a reference-link definition already + # owns elsewhere in this same document, so an inserted marker can never + # collide with one. + reserved = {int(n) for n in LINKREF_DEF.findall(body)} + numbers = {} + next_number = [1] + + def number_ref(match): + key = match.group(1) + if key not in defs: + warn("smolnet: footnote ref [^%s] has no definition", key) + return match.group(0) + if key not in numbers: + while next_number[0] in reserved: + next_number[0] += 1 + numbers[key] = next_number[0] + next_number[0] += 1 + return "[%d]" % numbers[key] + + body = FOOTNOTE_REF.sub(number_ref, body) + + if not numbers: + return body + + notes = ["## %s" % labels["notes"], ""] + for key, n in sorted(numbers.items(), key=lambda kv: kv[1]): + notes.append("%d. %s" % (n, defs[key])) + return body.rstrip() + "\n\n" + "\n".join(notes) + "\n" + + def toml_escape(value): return str(value).replace("\\", "\\\\").replace('"', '\\"') @@ -276,6 +373,7 @@ class CapsuleGenerator(Generator): dead_hosts = {h.lower().lstrip(".") for h in s.get("LOSTIMAGES_DEAD_HOSTS", [])} sidecar_name = s.get("SMOLNET_SIDECAR", ".itsybitsy.toml") + footnotes = s.get("SMOLNET_FOOTNOTES", False) # 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 @@ -308,6 +406,8 @@ class CapsuleGenerator(Generator): body = rewrite_html_links(body, slugs, self._warn) body = reveal_rot13(body, rot_attr) body = drop_dead_images(body, dead_hosts) + if footnotes: + body = rewrite_footnotes(body, labels, self._warn) # 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.