diff --git a/themes/vfb-nova/assets/css/compat.css b/themes/vfb-nova/assets/css/compat.css
index ca2049e..ddbb2ad 100644
--- a/themes/vfb-nova/assets/css/compat.css
+++ b/themes/vfb-nova/assets/css/compat.css
@@ -106,3 +106,35 @@
background: var(--surface);
translate: 0 -.15em;
}
+
+/* --------------------------------------------------------------------------
+ Bootstrap `alert`, for the legacy Docsy shortcodes reimplemented in
+ layouts/shortcodes/{alert,pageinfo}.html. Superseded term pages call them,
+ and without a template the whole build fails; without these rules they
+ would render as unstyled text. Uses the theme's own tokens rather than
+ Bootstrap's palette so they sit with the rest of the page.
+ -------------------------------------------------------------------------- */
+.prose .alert {
+ display: block;
+ padding: var(--sp-4) var(--sp-5);
+ margin: var(--sp-5) 0;
+ border: 1px solid var(--line);
+ border-left-width: 3px;
+ border-radius: var(--r-md);
+ background: var(--elev-1);
+}
+.prose .alert > :first-child { margin-top: 0; }
+.prose .alert > :last-child { margin-bottom: 0; }
+.prose .alert-heading {
+ margin: 0 0 var(--sp-2);
+ font-size: 1rem;
+ font-weight: 600;
+}
+.prose .alert-primary,
+.prose .alert-info { border-left-color: rgba(76, 141, 255, .7); }
+.prose .alert-success { border-left-color: rgba(64, 192, 128, .7); }
+.prose .alert-warning { border-left-color: rgba(230, 168, 64, .7); }
+.prose .alert-danger { border-left-color: rgba(230, 96, 96, .7); }
+.prose .alert-secondary,
+.prose .alert-light,
+.prose .alert-dark { border-left-color: var(--line); }
diff --git a/themes/vfb-nova/layouts/shortcodes/alert.html b/themes/vfb-nova/layouts/shortcodes/alert.html
new file mode 100644
index 0000000..e55d15c
--- /dev/null
+++ b/themes/vfb-nova/layouts/shortcodes/alert.html
@@ -0,0 +1,18 @@
+{{/*
+ Docsy's `alert`, reimplemented so legacy generated pages still render.
+
+ Term pages written by vfbterms.py version 6 and earlier used Docsy
+ shortcodes. Those files are superseded but survive in the corpus whenever a
+ term drops out of one run's ID list, and a single one aborts the entire
+ build — twice now, at 93 and 38 minutes a time. The cost of shipping this is
+ one small template; the cost of not shipping it is a lost build per orphan.
+
+ Same signature as Docsy: {{< alert title="..." color="..." >}} ... {{< /alert >}}
+ `.Inner | markdownify` matches Docsy's own implementation, so pages written
+ against it render as they did.
+*/}}
+{{- $color := .Get "color" | default "primary" -}}
+
+{{- with .Get "title" }}
{{ . }}
{{ end }}
+{{ .Inner | markdownify }}
+
diff --git a/themes/vfb-nova/layouts/shortcodes/pageinfo.html b/themes/vfb-nova/layouts/shortcodes/pageinfo.html
new file mode 100644
index 0000000..a8c91d1
--- /dev/null
+++ b/themes/vfb-nova/layouts/shortcodes/pageinfo.html
@@ -0,0 +1,9 @@
+{{/*
+ Docsy's `pageinfo`, for the same reason as alert.html: legacy generated pages
+ call it, and a missing shortcode template is a hard build error rather than a
+ warning. Shipped pre-emptively so the next orphan does not cost another build.
+*/}}
+{{- $color := .Get "color" | default "info" -}}
+
+{{ .Inner | markdownify }}
+
diff --git a/vfbterms.py b/vfbterms.py
index 66951f1..83e047e 100644
--- a/vfbterms.py
+++ b/vfbterms.py
@@ -757,12 +757,51 @@ def fetch_ids(label, query):
print(f" Retrieved {len(ids)} IDs for {label}")
return ids
+_VERSION_SUFFIX = re.compile(r"_v(\d+)\.md$")
+
+
+def prune_old_versions(label):
+ """Delete superseded term pages in the current directory.
+
+ process_term() used to remove only version-1 after writing a page. A term
+ absent from a single run's ID list therefore kept its older file forever:
+ the corpus still held _v6 pages long after version reached 9. Those orphans
+ are live pages -- they can collide on URL with the current version, they
+ are walked and parsed on every build, and pages old enough to predate the
+ move off Docsy call shortcodes the theme no longer ships, which aborts the
+ whole build.
+
+ One directory scan rather than a stat per term per version. The per-term
+ form costs version-1 extra syscalls for every term, which at ~763k terms is
+ millions of round trips; on the NFS volume this corpus lives on, metadata
+ operations run at a few hundred per second, so that is hours. This also
+ removes one stat per term relative to the old code.
+ """
+ removed = 0
+ try:
+ with os.scandir(".") as entries:
+ for entry in entries:
+ match = _VERSION_SUFFIX.search(entry.name)
+ if match and int(match.group(1)) < version:
+ try:
+ os.remove(entry.name)
+ removed += 1
+ except OSError:
+ pass
+ except OSError as e:
+ print(f"WARNING: could not prune {label}: {e}")
+ return
+ if removed:
+ print(f" pruned {removed} superseded pages from {label}")
+
+
def process_group(base_path, relative_dir, label, query):
"""Change directory, fetch IDs, and generate pages for one ontology group."""
target_dir = os.path.normpath(join(base_path, relative_dir))
print(f"\n[{label}] {target_dir}")
chdir(target_dir)
save_terms(fetch_ids(label, query))
+ prune_old_versions(label)
def process_term(term_id):
"""Fetch, render and write one term page. Returns a status string.
@@ -786,13 +825,6 @@ def process_term(term_id):
f.write(page_content)
os.replace(tmp, filename)
- # Clean up previous version
- old_filename = term_id + "_v" + str(version - 1) + ".md"
- if os.path.isfile(old_filename):
- try:
- os.remove(old_filename)
- except OSError:
- pass
return "ok"
def save_terms(ids):