mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
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:
1 parent
36ac7b0cd6
commit
be5be996db
5 files changed
+166
-12
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user