feat: hold the site to page and index budgets

validate_site.py fails a page over 1 MB of HTML, a search index over
8.5 MB (1.7 MB gzip), a skipped heading level, a table header without
text, a script from another host and a diagram drawn at runtime. The
limits sit above what the site now measures (0.88 MB, 7.2 MB, 1.43 MB),
each with its reason, and each has a test.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 13:13:55 +02:00
1 parent 36ac7b0cd6
commit be5be996db
5 files changed
+166 -12

No files matched your search

+76 -2
View File
@@ -34,7 +34,25 @@ class Page:
jsonld: list[str] = field(default_factory=list)
unrendered: list[str] = field(default_factory=list)
remote_images: list[str] = field(default_factory=list)
remote_scripts: list[str] = field(default_factory=list)
placeholders: list[str] = field(default_factory=list)
headings: list[int] = field(default_factory=list)
empty_headers: int = 0
runtime_diagrams: int = 0
size: int = 0
# Budgets the site was brought under on 2026-10-22, with room to grow. A
# change that passes one is a regression of what a visitor downloads, not a
# matter of taste: raise the number in the same change that explains why.
#
# The largest page was 10.5 MB (systems/game-engines/) and took 21 s of
# main-thread work on a throttled phone. Split pages now top out at 0.9 MB.
MAX_PAGE_BYTES = 1_000_000
# The search index is downloaded by every visitor who opens the search:
# 21.0 MB raw and 4.88 MB gzip with hashes in it, 7.3 MB and 1.44 MB without.
MAX_SEARCH_INDEX_BYTES = 8_500_000
MAX_SEARCH_INDEX_GZIP_BYTES = 1_700_000
# Text inside these is shown as written, so markup there is not a leftover.
@@ -50,11 +68,21 @@ class _PageParser(HTMLParser):
self._jsonld_depth = 0
self._jsonld_parts: list[str] = []
self._literal_depth = 0
self._header_parts: list[str] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
data = dict(attrs)
data = {key: value or "" for key, value in attrs}
if tag in _LITERAL_TAGS:
self._literal_depth += 1
if re.fullmatch(r"h[1-6]", tag):
self.page.headings.append(int(tag[1]))
if tag == "th":
self._header_parts = []
if "mermaid" in data.get("class", "").split():
# Material renders it in the browser with a script from a CDN.
self.page.runtime_diagrams += 1
if tag == "script" and re.match(r"\s*(?:https?:)?//", data.get("src", "")):
self.page.remote_scripts.append(data["src"])
if tag == "html":
self.page.lang = data.get("lang", "")
elif tag == "title":
@@ -97,6 +125,10 @@ class _PageParser(HTMLParser):
def handle_endtag(self, tag: str) -> None:
if tag in _LITERAL_TAGS and self._literal_depth:
self._literal_depth -= 1
if tag == "th" and self._header_parts is not None:
if not "".join(self._header_parts).strip():
self.page.empty_headers += 1
self._header_parts = None
if tag == "title":
self._title_depth -= 1
elif tag == "script" and self._jsonld_depth:
@@ -105,6 +137,8 @@ class _PageParser(HTMLParser):
self._jsonld_parts = []
def handle_data(self, data: str) -> None:
if self._header_parts is not None:
self._header_parts.append(data)
if self._title_depth:
self._title_parts.append(data)
if self._jsonld_depth:
@@ -119,11 +153,39 @@ class _PageParser(HTMLParser):
def _parse_page(path: Path) -> Page:
parser = _PageParser(path)
parser.feed(path.read_text(encoding="utf-8", errors="replace"))
text = path.read_text(encoding="utf-8", errors="replace")
parser.feed(text)
parser.close()
parser.page.size = len(text.encode("utf-8"))
return parser.page
def _heading_jumps(levels: list[int]) -> list[str]:
"""Each heading more than one level below the one before it: a screen
reader user moving by level finds nothing at the level skipped."""
return [f"h{before} to h{after}" for before, after in zip(levels, levels[1:])
if after > before + 1]
def _check_search_index(site: Path) -> list[str]:
"""The index every visitor who opens the search downloads, against its budget."""
import gzip
path = site / "search" / "search_index.json"
if not path.is_file():
return []
raw = path.read_bytes()
packed = len(gzip.compress(raw, 6))
issues = []
if len(raw) > MAX_SEARCH_INDEX_BYTES:
issues.append(f"search/search_index.json: {len(raw):,} bytes, "
f"over the {MAX_SEARCH_INDEX_BYTES:,} budget")
if packed > MAX_SEARCH_INDEX_GZIP_BYTES:
issues.append(f"search/search_index.json: {packed:,} bytes gzip, "
f"over the {MAX_SEARCH_INDEX_GZIP_BYTES:,} budget")
return issues
def _base_path(config_path: Path) -> str:
"""Read site_url without constructing executable YAML extension tags."""
config = config_path.read_text(encoding="utf-8")
@@ -220,6 +282,17 @@ def _check_pages(
report(path, f"images missing alt: {page.missing_alt[:5]}")
if page.remote_images:
report(path, f"images loaded from another host: {page.remote_images[:3]}")
if page.remote_scripts:
report(path, f"scripts loaded from another host: {page.remote_scripts[:3]}")
if page.runtime_diagrams:
report(path, f"{page.runtime_diagrams} diagram(s) drawn by a script at runtime")
if page.size > MAX_PAGE_BYTES:
report(path, f"{page.size:,} bytes of HTML, over the {MAX_PAGE_BYTES:,} budget")
jumps = _heading_jumps(page.headings)
if jumps:
report(path, f"heading levels skipped: {sorted(set(jumps))}")
if page.empty_headers:
report(path, f"{page.empty_headers} table header cell(s) without text")
if page.placeholders:
# scripts/sitediagrams.py knows no diagram of this name.
report(path, f"image placeholders left unresolved: {page.placeholders[:3]}")
@@ -294,6 +367,7 @@ def validate_site(site: Path, config_path: Path) -> list[str]:
issues.append(f"duplicate {label} {value!r}: {rendered}")
issues.extend(_check_links(pages, site, base_path))
issues.extend(_check_search_index(site))
return issues
+61
View File
@@ -166,6 +166,67 @@ class RenderedSiteValidation(unittest.TestCase):
class TheSiteStaysWithinItsBudgets(unittest.TestCase):
"""The 10.5 MB page and the 21 MB search index passed every check:
validate_site now holds the gains, each limit with its reason."""
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT)
self.site = Path(self.temp.name) / "site"
self.site.mkdir()
self.config = Path(self.temp.name) / "mkdocs.yml"
self.config.write_text("site_url: https://example.test/retrobios/\n", encoding="utf-8")
def tearDown(self) -> None:
self.temp.cleanup()
def _issues(self, body: str) -> list[str]:
(self.site / "index.html").write_text(_page("Home", "Home page.", body), encoding="utf-8")
return validate_site(self.site, self.config)
def test_a_clean_page_passes(self):
(self.site / "javascripts").mkdir()
(self.site / "javascripts" / "site.js").write_text("", encoding="utf-8")
self.assertEqual(self._issues(
"<h2 id='a'>A</h2><h3 id='b'>B</h3><h2 id='c'>C</h2>"
"<table><thead><tr><th>Detail</th><th>Value</th></tr></thead></table>"
'<script src="javascripts/site.js"></script>'), [])
def test_a_page_over_its_size(self):
import validate_site as module
issues = self._issues("<p>" + "x" * module.MAX_PAGE_BYTES + "</p>")
self.assertTrue(any("over the" in issue and "HTML" in issue for issue in issues), issues)
def test_a_skipped_heading_level(self):
issues = self._issues("<h2 id='a'>A</h2><h4 id='b'>B</h4>")
self.assertTrue(any("h2 to h4" in issue for issue in issues), issues)
def test_an_empty_table_header(self):
issues = self._issues("<table><thead><tr><th> </th><th></th></tr></thead></table>")
self.assertTrue(any("2 table header cell(s) without text" in issue for issue in issues))
def test_a_script_or_a_diagram_from_another_host(self):
issues = self._issues(
'<script src="https://unpkg.com/mermaid@11/dist/mermaid.min.js"></script>'
'<pre class="mermaid">graph LR</pre>')
self.assertTrue(any("scripts loaded from another host" in issue for issue in issues))
self.assertTrue(any("drawn by a script at runtime" in issue for issue in issues))
def test_a_search_index_over_its_budget(self):
import random
import validate_site as module
(self.site / "search").mkdir()
rng = random.Random(1)
noise = "".join(rng.choice("0123456789abcdef") for _ in range(module.MAX_SEARCH_INDEX_BYTES + 1))
(self.site / "search" / "search_index.json").write_text(noise, encoding="utf-8")
issues = self._issues("")
self.assertTrue(any("search_index.json" in issue and "gzip" not in issue for issue in issues))
self.assertTrue(any("search_index.json" in issue and "gzip" in issue for issue in issues))
class UnrenderedMarkdown(unittest.TestCase):
"""The clownmdemu analysis block reached readers as "- **bios_hle:**
True": a list indented one level too deep, read as code."""
+26 -8
View File
@@ -71,14 +71,32 @@ dist/ generated packs (gitignored)
Documentation changes belong in `wiki/` or in the generator.
The site search splits each page at its headings and sends a result to the
heading above the match. Every file card is therefore a level 4 heading with
the card's anchor, and every platform system a heading of its own: a hash
pasted in the search opens that card or that system, not the top of the page.
Level 4 stays out of the table of contents (`toc_depth: 3`). The lines that
list other pages (platforms, packs, source citations, collection paths) sit
in a `data-search-exclude` block, since the browser downloads and indexes the
whole search index before it shows a first result. File names, hashes, the
paths an emulator reads and the notes stay searchable.
heading above the match. Every file card is therefore a level 3 heading with
the card's anchor, under its console or a Files heading, and every platform
system a heading of its own: a file name typed in the search opens that card,
not the top of the page. On pages of cards (systems, emulators, platforms) the
table of contents stops at level 2 (`scripts/mkdocs_hooks.py`).
The browser downloads and indexes the whole search index before it shows a
first result, so the index holds names and prose only. Hashes, lists of other
pages, install commands, member tables and long reference tables sit in
`data-search-exclude` blocks. Those blocks are `section` elements: the search
plugin compares elements by tag, and a `div` stopped excluding at the first
`div` closed inside it. A hash goes to [Find a file](../files/index.md), which
reads one shard of `api/v1/lookup/` named by the first two characters of the
value.
A page has a budget. A manufacturer page over 500 KB of Markdown becomes a
summary: its largest consoles move to pages of their own, cut into parts when
one console alone is over the budget, and every console keeps its heading on
the summary. An emulator with more than 400 entries (MAME) lists them in a
table on its page, each row holding the old anchor of the card, and the cards
move to pages of 400. A platform system whose list is over 200 KB moves to a
page of its own. A card anchor (`#sha1-...`) that a page no longer holds is
looked up by content, so a link cited before a split still opens the card.
`validate_site.py` fails a page over 1 MB of HTML, a search index over 8.5 MB
(1.7 MB compressed), a skipped heading level, a table header without text, a
script from another host and a diagram drawn at runtime.
## Data flow
+2 -1
View File
@@ -54,7 +54,8 @@ is missing.
6. Run `generate_readme.py` (rebuilds README.md and CONTRIBUTING.md)
7. `mkdocs build --strict` to produce the static site
8. Run `validate_site.py` on the rendered HTML (metadata, headings, image
alternatives, duplicate ids, local links and fragments)
alternatives, duplicate ids, local links and fragments, page and search
index budgets)
9. Require the committed README and CONTRIBUTING to match what the generator
just produced. `write_if_changed()` compares content with the timestamp line
stripped, so a run that only moves the clock leaves the files untouched and
+1 -1
View File
@@ -692,7 +692,7 @@ python scripts/refresh_stale.py --only platforms,targets --jobs 6
| `find_pinned_revision.py` | Walk the upstream commits that touched a file and return the revision whose bytes match a platform's pinned md5 (`--write` saves them) |
| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |
| `generate_site.py` | Generate all MkDocs site pages (this documentation) |
| `validate_site.py` | Validate rendered metadata, headings, image alternatives, JSON-LD, local resources, links and fragments |
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held |
| `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit |
| `deterministic_zip.py` | Rebuild MAME BIOS ZIPs deterministically (same ROMs = same hash) |