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 <noreply@anthropic.com>
This commit is contained in:
parent
9817e7190c
commit
bfb54d3ffa
1 changed files with 100 additions and 0 deletions
|
|
@ -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_TAGLINE None one line at the foot of the index
|
||||||
SMOLNET_COMMENTS None thread URL per article, `{slug}` substituted
|
SMOLNET_COMMENTS None thread URL per article, `{slug}` substituted
|
||||||
SMOLNET_COMMENTS_FORMATS formats that get it, default gemtext and text
|
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
|
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
|
||||||
|
|
@ -61,6 +65,7 @@ LABELS = {
|
||||||
"pages": "Pages",
|
"pages": "Pages",
|
||||||
"back": "Back to the index",
|
"back": "Back to the index",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
|
"notes": "Notes",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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,
|
||||||
|
|
@ -87,6 +92,39 @@ SUMMARY_MARKER = "<!-- PELICAN_END_SUMMARY -->"
|
||||||
# sees the capsule.
|
# sees the capsule.
|
||||||
MD_IMAGE = re.compile(r'!\[[^\]]*\]\(\s*(https?://[^\s)]+)[^)]*\)')
|
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):
|
def rot13(text):
|
||||||
"""The obfuscation used on addresses in the HTML templates."""
|
"""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)
|
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):
|
def toml_escape(value):
|
||||||
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
@ -276,6 +373,7 @@ 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")
|
||||||
|
footnotes = s.get("SMOLNET_FOOTNOTES", False)
|
||||||
# kapara serves the same remark42 thread the HTML widget shows, over
|
# kapara serves the same remark42 thread the HTML widget shows, over
|
||||||
# Gemini, Spartan and Nex. Articles only: the widget is on articles
|
# 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
|
# 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 = rewrite_html_links(body, slugs, self._warn)
|
||||||
body = reveal_rot13(body, rot_attr)
|
body = reveal_rot13(body, rot_attr)
|
||||||
body = drop_dead_images(body, dead_hosts)
|
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
|
# Blank lines, not newlines: a heading and the line under it are
|
||||||
# separate blocks only if a blank line divides them, otherwise the
|
# separate blocks only if a blank line divides them, otherwise the
|
||||||
# date runs into the first sentence as one paragraph.
|
# date runs into the first sentence as one paragraph.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue