From be5be996dbd7b3af6db1578def99b622eb080f8f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:55 +0200 Subject: [PATCH] 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. --- scripts/validate_site.py | 78 ++++++++++++++++++++++++++++++++++- tests/test_site_validation.py | 61 +++++++++++++++++++++++++++ wiki/architecture.md | 34 +++++++++++---- wiki/release-process.md | 3 +- wiki/tools.md | 2 +- 5 files changed, 166 insertions(+), 12 deletions(-) diff --git a/scripts/validate_site.py b/scripts/validate_site.py index d7f95b7a..b39bf9ee 100644 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -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 diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index 7ebe6ca5..4c897af3 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -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( + "

A

B

C

" + "
DetailValue
" + ''), []) + + def test_a_page_over_its_size(self): + import validate_site as module + + issues = self._issues("

" + "x" * module.MAX_PAGE_BYTES + "

") + 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("

A

B

") + self.assertTrue(any("h2 to h4" in issue for issue in issues), issues) + + def test_an_empty_table_header(self): + issues = self._issues("
") + 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( + '' + '
graph LR
') + 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.""" diff --git a/wiki/architecture.md b/wiki/architecture.md index c88d6554..3ab42b66 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -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 diff --git a/wiki/release-process.md b/wiki/release-process.md index 68c81fb6..cfe47134 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -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 diff --git a/wiki/tools.md b/wiki/tools.md index 7b9fab1d..793a6f2e 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -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 ` reads FBNeo at that tag, branch or commit | | `deterministic_zip.py` | Rebuild MAME BIOS ZIPs deterministically (same ROMs = same hash) |