feat: make smolnet usable by a site that is not randogoth.com

Three changes, each found by pointing it at zonetoast, which files by tag
rather than category and writes its links by hand.

A single-bucket taxonomy is dropped. Pelican gives every uncategorised article
its DEFAULT_CATEGORY, so a tag-filed site still has one category holding
everything, and offering "misc, 14" as the only way in is worse than offering
nothing at all. Below SMOLNET_MIN_SECTIONS the whole section index goes.
randogoth.com still gets its six areas.

Links written against the HTML build are rewritten. {filename} is the portable
form, but content predating that knowledge hardcodes the output URL instead,
and zonetoast is full of ](/go-west.html). A capsule has no .html and the
daemon resolves a name carrying an extension literally, so all seventeen of
them 404'd. They now map onto the flat capsule slug, /pages/glossary.html
included, and a target matching no document is reported rather than silently
rewritten into a second broken link.

The line under an article title is a setting. Date and category is the right
default, but a site whose category is always `misc` has something better to
say: zonetoast carries `era`, which is what it is actually organised around,
so its articles read "17 May 2026 · 1996".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-10-06 12:21:46 +03:00
parent 4efe975599
commit 0d7e98a0d9

View file

@ -17,7 +17,9 @@ setting, and every setting has a default, so a site can switch it on with just
SMOLNET_OUTPUT 'smolnet' directory under OUTPUT_PATH SMOLNET_OUTPUT 'smolnet' directory under OUTPUT_PATH
SMOLNET_SECTION_DIR 'section' subdirectory holding one index per category SMOLNET_SECTION_DIR 'section' subdirectory holding one index per category
SMOLNET_LATEST 15 entries on the front page; 0 drops the section SMOLNET_LATEST 15 entries on the front page; 0 drops the section
SMOLNET_META [date, category] fields for the line under an article title
SMOLNET_SECTION_ORDER {} category name -> sort key, else alphabetical SMOLNET_SECTION_ORDER {} category name -> sort key, else alphabetical
SMOLNET_MIN_SECTIONS 2 below this, the section index is dropped
SMOLNET_LABELS {} override any heading, see LABELS SMOLNET_LABELS {} override any heading, see LABELS
SMOLNET_ASSETS None directories to copy; defaults to STATIC_PATHS SMOLNET_ASSETS None directories to copy; defaults to STATIC_PATHS
SMOLNET_SIDECAR '.itsybitsy.toml' SMOLNET_SIDECAR '.itsybitsy.toml'
@ -67,6 +69,13 @@ META_CONT = re.compile(r"^\s+\S")
# Pelican's internal link syntax. Left alone these reach the capsule verbatim. # Pelican's internal link syntax. Left alone these reach the capsule verbatim.
INTERNAL = re.compile(r"\{(filename|static|attach)\}([^)\s]+)") INTERNAL = re.compile(r"\{(filename|static|attach)\}([^)\s]+)")
# Links written against the HTML build, e.g. ](/go-west.html). Pelican's
# {filename} form is the portable one, but plenty of content predates knowing
# that and hardcodes the output URL instead. A capsule has no .html, and the
# daemon resolves a name carrying an extension literally, so these 404 unless
# they are rewritten.
HARDCODED = re.compile(r"\]\(\s*(/[^)\s]*?\.html)([^)]*)\)")
SUMMARY_MARKER = "<!-- PELICAN_END_SUMMARY -->" SUMMARY_MARKER = "<!-- PELICAN_END_SUMMARY -->"
# Markdown image syntax, for dropping the ones whose host is gone. Matched on # Markdown image syntax, for dropping the ones whose host is gone. Matched on
@ -137,6 +146,26 @@ def rewrite_internal(text, by_source, warn):
return INTERNAL.sub(sub, text) return INTERNAL.sub(sub, text)
def rewrite_html_links(text, slugs, warn):
"""Point links written against the HTML build at the capsule instead.
`/pages/glossary.html` and `/go-west.html` both become `/<slug>`, since the
capsule is flat. A target whose slug is not among the documents built is
reported and left alone rather than silently rewritten into a second broken
link.
"""
def sub(match):
target, rest = match.group(1), match.group(2)
slug = os.path.basename(target)[: -len(".html")]
if slug in slugs:
return "](/%s%s)" % (slug, rest)
warn("smolnet: link to %s matches no document", target)
return match.group(0)
return HARDCODED.sub(sub, text)
def reveal_rot13(text, attr): def reveal_rot13(text, attr):
"""Replace a block of HTML carrying a rot13 address with the address itself. """Replace a block of HTML carrying a rot13 address with the address itself.
@ -237,8 +266,10 @@ class CapsuleGenerator(Generator):
# Map source basename -> slug, so {filename} can be resolved. Built from # Map source basename -> slug, so {filename} can be resolved. Built from
# everything published, pages included, since an article may link one. # everything published, pages included, since an article may link one.
by_source = {} by_source = {}
slugs = set()
for doc in articles + pages: for doc in articles + pages:
by_source[os.path.basename(doc.source_path)] = doc.slug by_source[os.path.basename(doc.source_path)] = doc.slug
slugs.add(doc.slug)
os.makedirs(out, exist_ok=True) os.makedirs(out, exist_ok=True)
root_titles = {} root_titles = {}
@ -247,6 +278,7 @@ class CapsuleGenerator(Generator):
body = strip_metadata(self._read(doc)) body = strip_metadata(self._read(doc))
body = body.replace(SUMMARY_MARKER, "") body = body.replace(SUMMARY_MARKER, "")
body = rewrite_internal(body, by_source, self._warn) body = rewrite_internal(body, by_source, 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)
# Blank lines, not newlines: a heading and the line under it are # Blank lines, not newlines: a heading and the line under it are
@ -261,12 +293,21 @@ class CapsuleGenerator(Generator):
fh.write(text) fh.write(text)
root_titles[name] = doc.title root_titles[name] = doc.title
# Which metadata goes under the title. Default is date and category,
# but a site that files by tag has no useful category and may well have
# a field that means more: zonetoast carries `era`, which is the point
# of the site in a way that Pelican's default `misc` is not.
meta_fields = s.get("SMOLNET_META", ["date", "category"])
for article in articles: for article in articles:
bits = [] bits = []
if getattr(article, "date", None): for field in meta_fields:
bits.append(article.date.strftime("%d %b %Y")) value = getattr(article, field, None)
if getattr(article, "category", None): if not value:
bits.append(str(article.category)) continue
if field == "date":
bits.append(value.strftime("%d %b %Y"))
else:
bits.append(str(value))
emit(article, " · ".join(bits)) emit(article, " · ".join(bits))
for page in pages: for page in pages:
emit(page, "") emit(page, "")
@ -276,6 +317,12 @@ class CapsuleGenerator(Generator):
self.context.get("categories", []), self.context.get("categories", []),
key=lambda pair: (order.get(str(pair[0]), 99), str(pair[0])), key=lambda pair: (order.get(str(pair[0]), 99), str(pair[0])),
) )
# A site that files by tag rather than category still has one category,
# Pelican's DEFAULT_CATEGORY, holding everything. Offering "misc, 14" as
# the only way in is worse than offering nothing, so one bucket is not a
# taxonomy and the whole section index goes.
if len(sections) < s.get("SMOLNET_MIN_SECTIONS", 2):
sections = []
if sections: if sections:
os.makedirs(os.path.join(out, section_dir), exist_ok=True) os.makedirs(os.path.join(out, section_dir), exist_ok=True)
section_titles = {} section_titles = {}