mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-26 13:47:20 -05:00
feat: build the site with Astro Starlight
generate_site.py writes the Starlight project in website/ and nothing else: mkdocs.yml, the MkDocs hooks, theme overrides and stylesheet go, and the wiki is written in Starlight's syntax. Both workflows set up Node with the official actions/setup-node pinned by commit, run npm ci from the committed lockfile, build with the strict link validator and check the result with validate_site.py. The search weighs a page by what it answers: Start here pages first, a system page's cards and file names before a guide citing them, deep guides, launchers, aliases and frozen copies after. Status badges, hash blocks and the export change lists leave the index. The ranking sits at the centre of a range where the 13 queries of search_gate.py all answer, in the search box itself. 839 pages, every URL and 45,611 ids match the MkDocs build. The tests read the Starlight content and syntax.
This commit is contained in:
1 parent
abe240073c
commit
123d698e43
64 files changed
+742
-3118
No files matched your search
@@ -0,0 +1,46 @@
|
||||
"""The generated site pages, for the tests that read them.
|
||||
|
||||
generate_site.py writes each page into the Starlight project's content
|
||||
collection, as .md or as .mdx when it uses a component. A test names a
|
||||
page by its docs path (``which-pack.md``) and gets whichever file holds it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
|
||||
|
||||
def require() -> Path:
|
||||
"""The content folder, or a skip when the site was not generated (a
|
||||
failure in CI, which generates it before the suite)."""
|
||||
if not (CONTENT / "index.md").is_file():
|
||||
if os.environ.get("CI"):
|
||||
raise AssertionError("CI generates the site before the suite")
|
||||
raise unittest.SkipTest("site not generated (python scripts/generate_site.py)")
|
||||
return CONTENT
|
||||
|
||||
|
||||
def page(name: str) -> Path:
|
||||
"""The file of a page named by its docs path."""
|
||||
stem = name[:-3] if name.endswith(".md") else name
|
||||
for suffix in (".md", ".mdx"):
|
||||
candidate = CONTENT / f"{stem}{suffix}"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return CONTENT / f"{stem}.md"
|
||||
|
||||
|
||||
def pages() -> list[Path]:
|
||||
"""Every page file, sorted."""
|
||||
root = require()
|
||||
return sorted(p for p in root.rglob("*") if p.suffix in (".md", ".mdx"))
|
||||
|
||||
|
||||
def name(path: Path) -> str:
|
||||
"""A page file's docs path, the name tests use for it."""
|
||||
return path.relative_to(CONTENT).with_suffix(".md").as_posix()
|
||||
@@ -258,7 +258,7 @@ class WorkflowRegressions(unittest.TestCase):
|
||||
and acted on afterwards rather than swallowed. A best-effort side
|
||||
action such as adding a label is not a check and keeps its `|| true`.
|
||||
"""
|
||||
checks = ("python scripts/", "unittest", "mkdocs build")
|
||||
checks = ("python scripts/", "unittest", "npm run build")
|
||||
for name in ("validate.yml", "deploy-site.yml"):
|
||||
workflow = self._workflow(name)
|
||||
for job_name, job in workflow["jobs"].items():
|
||||
@@ -4311,7 +4311,7 @@ class AFileCardSaysAllItKnows(unittest.TestCase):
|
||||
))
|
||||
# The card is a heading with this id, linked by the heading's own
|
||||
# permalink.
|
||||
self.assertIn("{ #file-neogeo-zip .rb-file-title }", text)
|
||||
self.assertIn('<h3 id="file-neogeo-zip" class="rb-file-title"', text)
|
||||
self.assertIn("Standalone path: `Firmware/neogeo.zip`", text)
|
||||
self.assertIn("`m11.bin`", text)
|
||||
self.assertIn("CRC32 `0000000b`", text)
|
||||
@@ -4954,7 +4954,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
|
||||
"crc32": "504086c1", "sha256": "b" * 64}
|
||||
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
|
||||
for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]):
|
||||
self.assertIn(f"`{value}`", text)
|
||||
self.assertIn(f">{value}</code>", text)
|
||||
|
||||
|
||||
|
||||
@@ -4975,17 +4975,11 @@ class NullReadsAsNone(unittest.TestCase):
|
||||
self.assertIn("none: the source is the project itself", page)
|
||||
|
||||
def test_the_rendered_site_has_no_python_none(self):
|
||||
docs = ROOT / "docs"
|
||||
if not docs.is_dir():
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("docs/ not generated")
|
||||
pattern = re.compile(r"\| None \||: None$|: None \|", re.M)
|
||||
from scripts.generate_site import LOCAL_NOTE_DIRS
|
||||
from tests import sitepages
|
||||
|
||||
found = [p.relative_to(docs).as_posix() for p in sorted(docs.rglob("*.md"))
|
||||
if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS)
|
||||
and pattern.search(p.read_text(encoding="utf-8"))]
|
||||
pattern = re.compile(r"\| None \||: None$|: None \|", re.M)
|
||||
found = [sitepages.name(p) for p in sitepages.pages()
|
||||
if pattern.search(p.read_text(encoding="utf-8"))]
|
||||
self.assertEqual(found, [])
|
||||
|
||||
|
||||
@@ -5029,7 +5023,7 @@ class VersionAnchorsFollowContent(unittest.TestCase):
|
||||
"raze", {"emulator": "Raze", "type": "standalone", "files": files},
|
||||
{"files": {}, "indexes": {}},
|
||||
)
|
||||
return re.findall(r"\{ #(file-[^ ]+) \.rb-file-title \}", page)
|
||||
return re.findall(r'<h3 id="(file-[^"]+)" class="rb-file-title"', page)
|
||||
|
||||
def test_inserting_a_version_keeps_the_others(self):
|
||||
a = {"name": "DUKE3D.GRP", "path": "DUKE3D.GRP", "crc32": "bbc9ce44"}
|
||||
@@ -5051,18 +5045,26 @@ class SitemapDatesComeFromThePage(unittest.TestCase):
|
||||
"""All 664 sitemap URLs carried the build date: eduke32, re-read on
|
||||
2026-10-04, was announced as changed on the day of every build."""
|
||||
|
||||
def test_the_hook_takes_the_page_date_or_none(self):
|
||||
from types import SimpleNamespace
|
||||
def test_the_page_date_or_none_reaches_the_sitemap(self):
|
||||
import tempfile
|
||||
|
||||
from scripts.mkdocs_hooks import on_page_markdown
|
||||
from scripts import generate_site
|
||||
|
||||
dated = SimpleNamespace(meta={"date_modified": "2026-10-04"}, update_date="today")
|
||||
undated = SimpleNamespace(meta={}, update_date="today")
|
||||
on_page_markdown("", dated, None, None)
|
||||
on_page_markdown("", undated, None, None)
|
||||
self.assertEqual((dated.update_date, undated.update_date), ("2026-10-04", ""))
|
||||
config = (ROOT / "mkdocs.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("hooks:\n - scripts/mkdocs_hooks.py", config)
|
||||
saved = dict(generate_site.PAGE_DATA)
|
||||
self.addCleanup(lambda: (generate_site.PAGE_DATA.clear(),
|
||||
generate_site.PAGE_DATA.update(saved)))
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as tmp:
|
||||
docs = Path(tmp)
|
||||
(docs / "emulators").mkdir()
|
||||
(docs / "emulators" / "dated.md").write_text("# Dated\n\nA page with its own date.\n")
|
||||
(docs / "emulators" / "undated.md").write_text("# Undated\n\nA page with no date.\n")
|
||||
generate_site.PAGE_DATA["emulators/dated.md"] = {"dateModified": "2026-10-04"}
|
||||
lastmod = generate_site.decorate_markdown_pages(docs)
|
||||
self.assertEqual(lastmod, {"/retrobios/emulators/dated/": "2026-10-04"})
|
||||
# The sitemap reads that map, and leaves every other page undated.
|
||||
config = (ROOT / "website" / "astro.config.mjs").read_text(encoding="utf-8")
|
||||
self.assertIn("lastmod.json", config)
|
||||
self.assertIn("serialize(item)", config)
|
||||
|
||||
|
||||
class SiteCodeSpansHoldTheirValue(unittest.TestCase):
|
||||
@@ -6082,8 +6084,8 @@ class ThePipelineRunsTheSchemaCheckCIRuns(unittest.TestCase):
|
||||
duplicate ids passed it and failed the deploy."""
|
||||
workflow = (ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(encoding="utf-8")
|
||||
source = (ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8")
|
||||
self.assertIn("mkdocs build --strict", workflow)
|
||||
self.assertIn('["mkdocs", "build", "--strict"]', source)
|
||||
self.assertIn("npm run build --prefix website", workflow)
|
||||
self.assertIn('["npm", "run", "build", "--prefix", "website"]', source)
|
||||
self.assertIn("scripts/validate_site.py", workflow)
|
||||
self.assertIn('"scripts/validate_site.py"', source)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import generate_site # noqa: E402
|
||||
def _normalized(text: str) -> str:
|
||||
# The site ends each page with its next step in the reading order. That
|
||||
# is navigation, not contributing text, and a repository file has none.
|
||||
text = text.split('\n\n!!! tip "Next"\n', 1)[0].rstrip("\n")
|
||||
text = text.split("\n\n:::tip[Next]\n", 1)[0].rstrip("\n")
|
||||
text = re.sub(r"\]\([^)]*\)", "](link)", text)
|
||||
return "\n".join(text.splitlines()[1:]).replace("on this site", "on the documentation site")
|
||||
|
||||
@@ -73,17 +73,14 @@ class BeforeOpeningAPullRequest(unittest.TestCase):
|
||||
workflow = (REPO_ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("validate_schemas.py --source-only", workflow)
|
||||
|
||||
def test_it_installs_the_packages_the_site_render_needs(self):
|
||||
"""Without mkdocs-material and pymdown-extensions the pipeline's
|
||||
render step (8b) is skipped for every contributor."""
|
||||
deploy = (REPO_ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
specs = re.findall(r'"((?:mkdocs-material|pymdown-extensions)[^"]*)"', deploy)
|
||||
self.assertEqual(len(specs), 2)
|
||||
def test_it_installs_what_the_checks_need_and_no_site_package(self):
|
||||
"""The site is built by Node from website/package-lock.json: a pip
|
||||
line naming the old theme installed packages nothing reads."""
|
||||
text = generate_readme.generate_contributing()
|
||||
for spec in specs:
|
||||
self.assertIn(f'"{spec}"', text)
|
||||
section = text.split("## Before opening a PR", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn('pip install pyyaml "jsonschema[format-nongpl]" markdown', section)
|
||||
self.assertNotIn("mkdocs", section)
|
||||
self.assertTrue((REPO_ROOT / "website" / "package-lock.json").is_file())
|
||||
|
||||
|
||||
class OneFileThroughTheBrowser(unittest.TestCase):
|
||||
|
||||
+10
-10
@@ -85,22 +85,22 @@ class CorrectedFilesPage(unittest.TestCase):
|
||||
|
||||
def test_single_file_is_downloadable_raw(self):
|
||||
self.assertIn(
|
||||
"(https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml)",
|
||||
'href="https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml"',
|
||||
self.page,
|
||||
)
|
||||
|
||||
def test_several_files_link_to_their_directory(self):
|
||||
self.assertIn("tree/main/exports/retropie", self.page)
|
||||
self.assertIn('??? note "The 2 original files it is based on"', self.page)
|
||||
self.assertIn("<summary>The 2 original files it is based on</summary>", self.page)
|
||||
|
||||
def test_every_change_is_listed(self):
|
||||
self.assertIn('??? note "Hashes updated to the emulator source (12)"', self.page)
|
||||
self.assertIn("<summary>Hashes updated to the emulator source (12)</summary>", self.page)
|
||||
for i in range(12):
|
||||
self.assertIn(f" - `sys/file{i}.bin md5`", self.page)
|
||||
self.assertIn(f"\n- `sys/file{i}.bin md5`", self.page)
|
||||
|
||||
def test_a_failing_file_is_said_to_fail(self):
|
||||
self.assertIn(" - bash -n failed on a.sh", self.page)
|
||||
self.assertIn(" - a size the format has no field for", self.page)
|
||||
self.assertIn("\n- bash -n failed on a.sh", self.page)
|
||||
self.assertIn("\n- a size the format has no field for", self.page)
|
||||
|
||||
def test_nothing_published_says_how_to_build(self):
|
||||
self.assertIn("export_native.py --all --fetch", generate_exports_page([], {}))
|
||||
@@ -412,16 +412,16 @@ class ThePageShowsEachValueBeforeAndAfter(unittest.TestCase):
|
||||
|
||||
def test_a_corrected_hash_shows_both_values(self):
|
||||
self.assertIn(
|
||||
f" - `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`",
|
||||
f"\n- `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`",
|
||||
self.page,
|
||||
)
|
||||
|
||||
def test_a_requirement_names_the_entry_and_its_value(self):
|
||||
self.assertIn('??? note "Required flags aligned with the code (1)"', self.page)
|
||||
self.assertIn(" - `psx/scph5501.bin` becomes optional", self.page)
|
||||
self.assertIn("<summary>Required flags aligned with the code (1)</summary>", self.page)
|
||||
self.assertIn("\n- `psx/scph5501.bin` becomes optional", self.page)
|
||||
|
||||
def test_a_filled_hash_shows_its_value(self):
|
||||
self.assertIn(f" - `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page)
|
||||
self.assertIn(f"\n- `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page)
|
||||
|
||||
def test_no_arrow_glyph(self):
|
||||
for glyph in (chr(0x2192), "->", "=>"):
|
||||
|
||||
+12
-10
@@ -56,6 +56,9 @@ def _tracked() -> list[Path]:
|
||||
|
||||
|
||||
def _semicolons(text: str) -> list[str]:
|
||||
# An HTML character reference (& {) ends in a semicolon that
|
||||
# joins no clauses.
|
||||
text = re.sub(r"&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", "&", text)
|
||||
return [
|
||||
line.strip()
|
||||
for line in BACKTICKS.sub("``", text).splitlines()
|
||||
@@ -356,18 +359,17 @@ class GeneratedSiteProse(unittest.TestCase):
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from generate_site import LOCAL_NOTE_DIRS # noqa: PLC0415
|
||||
|
||||
docs = ROOT / "docs"
|
||||
pages = [
|
||||
p for p in docs.rglob("*.md")
|
||||
if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS)
|
||||
] if docs.is_dir() else []
|
||||
if not pages:
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("docs/ not generated (python scripts/generate_site.py)")
|
||||
from tests import sitepages # noqa: PLC0415
|
||||
|
||||
pages = [p for p in sitepages.pages()
|
||||
if not set(p.relative_to(sitepages.CONTENT).parts[:-1]) & set(LOCAL_NOTE_DIRS)]
|
||||
offenders = []
|
||||
for page in pages:
|
||||
text = re.sub(r"<script.*?</script>", "", page.read_text(encoding="utf-8"), flags=re.S)
|
||||
text = page.read_text(encoding="utf-8")
|
||||
# The front matter carries the page's structured data, not prose.
|
||||
if text.startswith("---\n"):
|
||||
text = text[text.index("\n---\n", 4) + 5:]
|
||||
text = re.sub(r"<script.*?</script>", "", text, flags=re.S)
|
||||
text = FENCED.sub("", text)
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if GLYPHS.search(line) or _semicolons(line) or ONE_PLURAL.search(line):
|
||||
|
||||
@@ -42,7 +42,7 @@ class SemanticManifestValidation(unittest.TestCase):
|
||||
(install / "synthetic.json").write_text(
|
||||
json.dumps({"files": None}), encoding="utf-8"
|
||||
)
|
||||
api = root / "docs" / "api" / "v1"
|
||||
api = root / "website" / "public" / "api" / "v1"
|
||||
api.mkdir(parents=True)
|
||||
(api / "catalog.json").write_text(
|
||||
json.dumps({"count": 1, "items": None}), encoding="utf-8"
|
||||
@@ -58,7 +58,7 @@ class SemanticManifestValidation(unittest.TestCase):
|
||||
text = output.getvalue().replace("\\", "/")
|
||||
self.assertIn("database.json", text)
|
||||
self.assertIn("install/synthetic.json", text)
|
||||
self.assertIn("docs/api/v1/catalog.json", text)
|
||||
self.assertIn("website/public/api/v1/catalog.json", text)
|
||||
self.assertIn("files", text)
|
||||
self.assertIn("items", text)
|
||||
self.assertNotIn("Traceback", text)
|
||||
|
||||
@@ -148,7 +148,7 @@ def _wiki_copy(tmp: str) -> Path:
|
||||
class NavFromFrontMatter(unittest.TestCase):
|
||||
|
||||
def _nav(self, wiki: Path) -> dict:
|
||||
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}, wiki)
|
||||
nav = generate_site.generate_nav({}, {}, {}, {}, wiki)
|
||||
return {next(iter(tab)): next(iter(tab.values())) for tab in nav}
|
||||
|
||||
def test_the_repository_nav_matches_the_pages(self):
|
||||
|
||||
+54
-41
@@ -21,7 +21,10 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DOCS = REPO_ROOT / "docs"
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
DOCS = sitepages.CONTENT
|
||||
|
||||
# Level 0: the Start here pages, with their own ceiling on words.
|
||||
START_PAGES = {
|
||||
@@ -142,15 +145,19 @@ def guide_part(markdown: str) -> str:
|
||||
|
||||
|
||||
def parse(markdown: str) -> Page:
|
||||
"""Blocks of prose a reader sees, code and HTML left out."""
|
||||
"""Blocks of prose a reader sees, code and HTML left out.
|
||||
|
||||
A collapsed block (<details>) counts, but not before the first action:
|
||||
the reader opens it on purpose. Of a group of tabs the first is read and
|
||||
the others only stand in for it.
|
||||
"""
|
||||
text = guide_part(markdown)
|
||||
text = re.sub(r"<script.*?</script>", "", text, flags=re.S)
|
||||
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
||||
page = Page()
|
||||
seen_words = 0
|
||||
collapsed_indent: int | None = None
|
||||
tab_indent: int | None = None # the tabs of a group a reader picks one of
|
||||
hidden_indent: int | None = None
|
||||
details = 0 # depth of collapsed blocks
|
||||
tab = 0 # the tab of a group being read, 0 outside a group
|
||||
fence: str | None = None
|
||||
current: list[str] = []
|
||||
kind = "paragraph"
|
||||
@@ -162,8 +169,7 @@ def parse(markdown: str) -> Page:
|
||||
body = strip_inline(" ".join(current))
|
||||
if WORD.search(body):
|
||||
term = page.blocks[-1].text.lower() if kind == "definition" and page.blocks else ""
|
||||
block = Block(kind, body, collapsed_indent is not None, term,
|
||||
hidden_indent is not None)
|
||||
block = Block(kind, body, details > 0, term, tab > 1)
|
||||
page.blocks.append(block)
|
||||
if (page.before_action is None and not block.collapsed
|
||||
and not block.hidden and kind not in ("table", "heading")):
|
||||
@@ -185,45 +191,52 @@ def parse(markdown: str) -> Page:
|
||||
if stripped.startswith(fence):
|
||||
fence = None
|
||||
continue
|
||||
if stripped and tab_indent is not None and indent <= tab_indent and not stripped.startswith("=== "):
|
||||
flush()
|
||||
tab_indent = hidden_indent = None
|
||||
if stripped.startswith(("```", "~~~")):
|
||||
fence = stripped[:3]
|
||||
if hidden_indent is None:
|
||||
if tab < 2:
|
||||
act()
|
||||
continue
|
||||
if collapsed_indent is not None and stripped and indent <= collapsed_indent:
|
||||
flush()
|
||||
collapsed_indent = None
|
||||
if not stripped:
|
||||
flush()
|
||||
continue
|
||||
if re.match(r"(\?\?\?\+?|!!!) ", stripped):
|
||||
flush()
|
||||
if stripped.startswith("??? "):
|
||||
collapsed_indent = indent
|
||||
if stripped.startswith("import ") and stripped.endswith(";"):
|
||||
continue
|
||||
if stripped.startswith("=== "):
|
||||
flush()
|
||||
if tab_indent == indent:
|
||||
hidden_indent = indent
|
||||
else:
|
||||
tab_indent, hidden_indent = indent, None
|
||||
continue
|
||||
if stripped.startswith("<") or stripped.startswith("</"):
|
||||
if stripped.startswith(":::"):
|
||||
flush()
|
||||
continue
|
||||
if ".md-button" in stripped:
|
||||
if stripped.startswith("<details"):
|
||||
flush()
|
||||
details += 1
|
||||
continue
|
||||
if stripped.startswith("</details>"):
|
||||
flush()
|
||||
details = max(0, details - 1)
|
||||
continue
|
||||
if stripped.startswith("<Tabs"):
|
||||
flush()
|
||||
tab = 0
|
||||
continue
|
||||
if stripped.startswith("<TabItem"):
|
||||
flush()
|
||||
tab += 1
|
||||
continue
|
||||
if stripped.startswith("</Tabs>"):
|
||||
flush()
|
||||
tab = 0
|
||||
continue
|
||||
if "rb-button" in stripped or stripped.startswith("<FilePicker"):
|
||||
act()
|
||||
continue
|
||||
if re.match(r"</?(?!abbr\b|a\b|code\b|strong\b|em\b)[A-Za-z]", stripped):
|
||||
flush()
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
flush()
|
||||
if run:
|
||||
page.list_runs.append(run)
|
||||
run = 0
|
||||
kind = "heading"
|
||||
current = [stripped.lstrip("#").strip()]
|
||||
current = [re.sub(r"\s*\{[^}]*\}\s*$", "", stripped.lstrip("#").strip())]
|
||||
flush()
|
||||
kind = "paragraph"
|
||||
continue
|
||||
@@ -311,25 +324,23 @@ def held_pages(docs: Path) -> list[tuple[str, str, int]]:
|
||||
"""(relative path, level, ceiling) for every page the budgets hold."""
|
||||
pages = [(name, "start", ceiling) for name, ceiling in START_PAGES.items()]
|
||||
pages += [
|
||||
(str(path.relative_to(docs)), "setup", SETUP_CEILING)
|
||||
for path in sorted((docs / "platforms").glob("*.md"))
|
||||
if path.name != "index.md"
|
||||
(sitepages.name(path), "setup", SETUP_CEILING)
|
||||
for path in sorted((docs / "platforms").glob("*.md*"))
|
||||
if path.stem != "index"
|
||||
and "\n## Where the files go" in path.read_text(encoding="utf-8")
|
||||
]
|
||||
pages.append(("platforms/index.md", "setup", SETUP_INDEX_CEILING))
|
||||
return [(name, level, ceiling) for name, level, ceiling in pages if (docs / name).is_file()]
|
||||
return [(name, level, ceiling) for name, level, ceiling in pages
|
||||
if sitepages.page(name).is_file()]
|
||||
|
||||
|
||||
def _docs() -> Path:
|
||||
if not (DOCS / "index.md").is_file():
|
||||
if os.environ.get("CI"):
|
||||
raise AssertionError("CI generates docs/ before the suite")
|
||||
raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)")
|
||||
return DOCS
|
||||
return sitepages.require()
|
||||
|
||||
|
||||
def _load(name: str) -> Page:
|
||||
return parse((_docs() / name).read_text(encoding="utf-8"))
|
||||
_docs()
|
||||
return parse(sitepages.page(name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class CopyBudgets(unittest.TestCase):
|
||||
@@ -400,8 +411,10 @@ class TheMeasureItself(unittest.TestCase):
|
||||
SAMPLE = (
|
||||
"---\nlevel: start\n---\n\n# Title\n\nOne two three. Four five.\n\n"
|
||||
"- Item **one**.\n- Item [two](x.md).\n\n```sh\nnot counted at all\n```\n\n"
|
||||
"??? note \"Hidden\"\n\n Collapsed words here.\n\n"
|
||||
"=== \"A\"\n\n First tab.\n\n=== \"B\"\n\n Second tab words.\n\n"
|
||||
'<details class="rb-details rb-details-note">\n<summary>Hidden</summary>\n\n'
|
||||
"Collapsed words here.\n\n</details>\n\n"
|
||||
'<Tabs syncKey="os">\n<TabItem label="A">\n\nFirst tab.\n\n</TabItem>\n'
|
||||
'<TabItem label="B">\n\nSecond tab words.\n\n</TabItem>\n</Tabs>\n\n'
|
||||
"| A | B |\n|---|---|\n| cell | cell |\n\n## Reference: deep\n\nNot read.\n"
|
||||
)
|
||||
|
||||
@@ -429,7 +442,7 @@ def report(docs: Path) -> str:
|
||||
rows = ["| Page | Words | Before action | Max block | Median / max sentence |",
|
||||
"|---|---:|---:|---:|---|"]
|
||||
for name, _level, _ceiling in held_pages(docs):
|
||||
m = measure(parse((docs / name).read_text(encoding="utf-8")))
|
||||
m = measure(parse(sitepages.page(name).read_text(encoding="utf-8")))
|
||||
before = "none" if m["before_action"] is None else m["before_action"]
|
||||
rows.append(f"| {name} | {m['words']} | {before} | {m['max_block']} | "
|
||||
f"{m['median_sentence']:g} / {m['max_sentence']} |")
|
||||
|
||||
@@ -36,7 +36,8 @@ class SiteCoreCounts(unittest.TestCase):
|
||||
page = generate_site.generate_cross_reference(coverages, profiles)
|
||||
shown = {
|
||||
m.group(1): int(m.group(2))
|
||||
for m in re.finditer(r'\?\?\? abstract "([^"]+)"\n\n.*\n\n {4}\*\*(\d+) cores', page)
|
||||
for m in re.finditer(
|
||||
r'<summary>([^<]+)</summary>\n\n.*\n\n\*\*(\d+) cores', page)
|
||||
}
|
||||
for name, cov in coverages.items():
|
||||
with self.subTest(platform=name):
|
||||
|
||||
@@ -86,7 +86,7 @@ class ACardKeepsItsAddress(unittest.TestCase):
|
||||
files = [{"name": "DUKE3D.GRP", "md5": "aaaa1111bbbb"}]
|
||||
page = _page("raze", {"type": "standalone", "files": files})
|
||||
self.assertIn('<span id="file-duke3d-grp"></span>', page)
|
||||
self.assertIn("{ #file-duke3d-grp-aaaa1111 .rb-file-title }", page)
|
||||
self.assertIn('<h3 id="file-duke3d-grp-aaaa1111" class="rb-file-title"', page)
|
||||
# A card already holding the plain id gives it to no one else.
|
||||
files = [{"name": "x.bin"}, {"name": "x.bin", "sha1": "ab" * 20}]
|
||||
anchors = gs.emulator_file_anchors(files)
|
||||
@@ -121,8 +121,8 @@ class WhatASearchSnippetSays(unittest.TestCase):
|
||||
"only validate_schemas.py and this")
|
||||
|
||||
def test_structured_url_is_encoded_like_the_canonical(self):
|
||||
"""mkdocs encodes idtech4a++ as idtech4a%2B%2B in the canonical link
|
||||
and the sitemap, and the JSON-LD named the unencoded URL."""
|
||||
"""The canonical link and the sitemap encode idtech4a++ as
|
||||
idtech4a%2B%2B, and the JSON-LD named the unencoded URL."""
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
@@ -132,8 +132,11 @@ class WhatASearchSnippetSays(unittest.TestCase):
|
||||
page.write_text("# idTech4A++\n\nSome text long enough to be a description.\n")
|
||||
gs.decorate_markdown_pages(Path(tmp))
|
||||
text = page.read_text()
|
||||
data = json.loads(text.split('<script type="application/ld+json">')[1]
|
||||
.split("</script>")[0].replace("<\\/", "</"))
|
||||
import yaml
|
||||
|
||||
front = yaml.safe_load(text.split("---\n")[1])
|
||||
script = next(entry for entry in front["head"] if entry["tag"] == "script")
|
||||
data = json.loads(script["content"].replace("<\\/", "</"))
|
||||
self.assertEqual(data["url"],
|
||||
"https://abdess.github.io/retrobios/emulators/idtech4a%2B%2B/")
|
||||
|
||||
|
||||
+17
-14
@@ -236,7 +236,7 @@ class SiteReferenceContracts(unittest.TestCase):
|
||||
decorate_markdown_pages(self.docs)
|
||||
decorate_markdown_pages(self.docs)
|
||||
content = page.read_text()
|
||||
self.assertEqual(content.count('type="application/ld+json"'), 1)
|
||||
self.assertEqual(content.count("type: application/ld+json"), 1)
|
||||
self.assertIn("Current RetroBIOS verification gaps", content)
|
||||
self.assertIn('"@type":"TechArticle"', content)
|
||||
|
||||
@@ -357,11 +357,11 @@ class AVariantShowsWhoPinsIt(unittest.TestCase):
|
||||
platforms={"c" * 40: {"batocera", "retrobat"}}, emulators={"a" * 40: {"mame"}})
|
||||
page = generate_site.generate_system_page(
|
||||
"Entex", {"Adventure Vision": [primary, variant]}, consumers)
|
||||
block = page[page.index('??? note "Variants (1)"'):]
|
||||
self.assertIn(" - Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block)
|
||||
block = page[page.index("<summary>Variants (1)</summary>"):]
|
||||
self.assertIn("- Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block)
|
||||
self.assertIn("[batocera](../platforms/batocera.md)", block)
|
||||
self.assertIn("[retrobat](../platforms/retrobat.md)", block)
|
||||
self.assertIn('`' + "c" * 40 + '`', block)
|
||||
self.assertIn('<code class="rb-hash">' + "c" * 40 + "</code>", block)
|
||||
self.assertIn("Catalogued", block)
|
||||
|
||||
|
||||
@@ -402,10 +402,10 @@ class EmulatorPagesPrintWholeHashes(unittest.TestCase):
|
||||
from generate_site import _hash_spans
|
||||
|
||||
sha1 = "0c3de403b524107809fa9308b730d60e8e41ba93"
|
||||
self.assertIn(f"`{sha1}`", _hash_spans(sha1))
|
||||
self.assertIn(f">{sha1}</code>", _hash_spans(sha1))
|
||||
both = _hash_spans(f"{'a' * 64}, {'b' * 64}")
|
||||
self.assertIn("`" + "a" * 64 + "`", both)
|
||||
self.assertIn("`" + "b" * 64 + "`", both)
|
||||
self.assertIn(">" + "a" * 64 + "</code>", both)
|
||||
self.assertIn(">" + "b" * 64 + "</code>", both)
|
||||
self.assertNotIn("...", both)
|
||||
|
||||
|
||||
@@ -724,15 +724,16 @@ class AFileHasAnAddressByContent(unittest.TestCase):
|
||||
[{"sha1": "ab" * 20, "url": "systems/roland/#sha1-" + "ab" * 6}],
|
||||
)
|
||||
self.assertIn('id="rb-file-lookup-status"', generate_site.generate_files_page())
|
||||
script = (Path(__file__).resolve().parents[1] / "docs_assets" / "site.js").read_text()
|
||||
script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts"
|
||||
/ "site.ts").read_text()
|
||||
self.assertIn("api/v1/lookup/", script)
|
||||
self.assertIn('params.has("sha1")', script)
|
||||
self.assertIn("params.has('sha1')", script)
|
||||
from cross_reference import FileConsumers # noqa: PLC0415
|
||||
|
||||
card = "\n".join(generate_site._system_file_card(
|
||||
{"name": "x.rom", "sha1": "ab" * 20, "size": 1}, FileConsumers({"files": {}}),
|
||||
))
|
||||
self.assertIn("../../files/?sha1=" + "ab" * 20, card)
|
||||
self.assertIn("/retrobios/files/?sha1=" + "ab" * 20, card)
|
||||
|
||||
|
||||
class ACardNamesThePacksThatInstallIt(unittest.TestCase):
|
||||
@@ -825,7 +826,7 @@ class EveryAcceptedHashIsItsOwnWord(unittest.TestCase):
|
||||
md5s = ["96cf" + "0" * 28, "d5af" + "0" * 28]
|
||||
text = _hash_spans(",".join(md5s))
|
||||
for md5 in md5s:
|
||||
self.assertIn(f"`{md5}`</span>", text)
|
||||
self.assertIn(f'<code class="rb-hash">{md5}</code>', text)
|
||||
|
||||
|
||||
class AMismatchNamesTheDumpTheCollectionHolds(unittest.TestCase):
|
||||
@@ -924,12 +925,14 @@ class NestedProfileBlocksRenderAsLists(unittest.TestCase):
|
||||
lines = generate_site._render_platform_details(
|
||||
{"platform_details": {"megacd": {"root": "x"}, "note": "y"}}
|
||||
)
|
||||
lines[0] = lines[0].replace("???+", "!!!")
|
||||
try:
|
||||
import markdown
|
||||
except ImportError:
|
||||
self.skipTest("markdown is not installed")
|
||||
html = markdown.markdown("\n".join(lines), extensions=["admonition"])
|
||||
# The block's body, read as the engine reads Markdown inside HTML.
|
||||
body = lines[lines.index("<summary>Platform details</summary>") + 1:
|
||||
lines.index("</details>")]
|
||||
html = markdown.markdown("\n".join(body))
|
||||
self.assertIn("<li>root: x</li>", html)
|
||||
self.assertNotIn("**", html)
|
||||
|
||||
@@ -959,7 +962,7 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase):
|
||||
"verified": 0, "missing": 0, "details": [], "config": {"systems": {}}}
|
||||
page = generate_site.generate_platform_page("batocera", cov)
|
||||
self.assertIn("install.sh | sh -s -- --platform batocera", page)
|
||||
self.assertLess(page.index("--platform batocera"), page.index("md-button"))
|
||||
self.assertLess(page.index("--platform batocera"), page.index("rb-button"))
|
||||
# Batocera runs the command on the device itself. A platform detected
|
||||
# on Windows gets the PowerShell form.
|
||||
cov = dict(cov, platform="RetroArch", mode="existence")
|
||||
|
||||
+43
-27
@@ -19,12 +19,14 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
from common import list_registered_platforms, load_platform_config, load_platform_registry # noqa: E402
|
||||
from siteguides import DEVICE_GROUPS, GROUP_ANCHORS, GUIDES # noqa: E402
|
||||
|
||||
DOCS = REPO_ROOT / "docs"
|
||||
DOCS = sitepages.CONTENT
|
||||
ORDER = list(generate_site.LEVELS)
|
||||
LINK = re.compile(r"\[[^\]]*\]\(([^)\s#]+\.md)(?:#[^)]*)?\)")
|
||||
# Pages link to site addresses once written for the site.
|
||||
LINK = re.compile(r"\[[^\]]*\]\((/retrobios/[^)\s#?]*)(?:[#?][^)]*)?\)")
|
||||
FENCED = re.compile(r"^\s*```.*?^\s*```", re.S | re.M)
|
||||
|
||||
|
||||
@@ -38,13 +40,7 @@ def _which_pack() -> str:
|
||||
|
||||
|
||||
def _docs_pages() -> list[Path]:
|
||||
if not DOCS.is_dir() or not (DOCS / "index.md").is_file():
|
||||
if os.environ.get("CI"):
|
||||
raise AssertionError("CI generates docs/ before the suite")
|
||||
raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)")
|
||||
skipped = set(generate_site.LOCAL_NOTE_DIRS)
|
||||
return [p for p in sorted(DOCS.rglob("*.md"))
|
||||
if not set(p.relative_to(DOCS).parts[:-1]) & skipped]
|
||||
return [p for p in sitepages.pages() if p.name not in ("404.md",)]
|
||||
|
||||
|
||||
def _level(page: Path) -> str:
|
||||
@@ -55,14 +51,14 @@ class FiveTabs(unittest.TestCase):
|
||||
"""Twelve tabs put the walkthrough at position eleven, under "Wiki"."""
|
||||
|
||||
def test_the_tabs_in_reading_order(self):
|
||||
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {})
|
||||
nav = generate_site.generate_nav({}, {}, {}, {})
|
||||
self.assertEqual(
|
||||
[next(iter(tab)) for tab in nav],
|
||||
["Start here", "Your setup", "Explore", "Reference", "Contribute"],
|
||||
)
|
||||
|
||||
def test_each_tab_opens_on_an_index_or_a_first_page(self):
|
||||
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {})
|
||||
nav = generate_site.generate_nav({}, {}, {}, {})
|
||||
tabs = {next(iter(tab)): next(iter(tab.values())) for tab in nav}
|
||||
self.assertEqual(tabs["Start here"][0], "index.md")
|
||||
self.assertEqual(tabs["Your setup"][0], "platforms/index.md")
|
||||
@@ -70,8 +66,10 @@ class FiveTabs(unittest.TestCase):
|
||||
self.assertEqual(tabs["Contribute"][0], "contributing.md")
|
||||
|
||||
def test_the_breadcrumbs_are_on(self):
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn(" - navigation.path\n", source)
|
||||
title = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text(encoding="utf-8")
|
||||
route = (REPO_ROOT / "website" / "src" / "routeData.ts").read_text(encoding="utf-8")
|
||||
self.assertIn('aria-label="Breadcrumb"', title)
|
||||
self.assertIn("rbBreadcrumbs", route)
|
||||
|
||||
|
||||
class EveryPlatformOnThePicker(unittest.TestCase):
|
||||
@@ -100,8 +98,17 @@ class EveryPlatformOnThePicker(unittest.TestCase):
|
||||
of the nav is reachable only by a link and MkDocs says so at info
|
||||
level. getting-started.md is kept out on purpose: it only links the
|
||||
pages that took its content, for the old links that point to it."""
|
||||
# mkdocs.yml is what generate_site writes, and CI checks it is current.
|
||||
nav = (Path(__file__).resolve().parents[1] / "mkdocs.yml").read_text(encoding="utf-8")
|
||||
def targets(entries) -> list[str]:
|
||||
found = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
found.append(entry)
|
||||
else:
|
||||
for value in entry.values():
|
||||
found += targets(value) if isinstance(value, list) else [value]
|
||||
return found
|
||||
|
||||
nav = " ".join(targets(generate_site.generate_nav({}, {}, {}, {})))
|
||||
kept_out = {"getting-started.md"}
|
||||
wiki = Path(__file__).resolve().parents[1] / "wiki"
|
||||
missing = sorted(
|
||||
@@ -116,7 +123,7 @@ class EveryPlatformOnThePicker(unittest.TestCase):
|
||||
key: {"platform": load_platform_config(key, str(REPO_ROOT / "platforms"))["platform"]}
|
||||
for key in _registered()
|
||||
}
|
||||
nav = generate_site.generate_mkdocs_nav(coverages, {}, {}, registry)
|
||||
nav = generate_site.generate_nav(coverages, {}, {}, registry)
|
||||
setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
|
||||
groups = {next(iter(item)): next(iter(item.values()))
|
||||
for item in setup if isinstance(item, dict)}
|
||||
@@ -166,7 +173,12 @@ class EveryPageHasALevel(unittest.TestCase):
|
||||
level = _level(page)
|
||||
with self.subTest(page=str(page.relative_to(DOCS))):
|
||||
self.assertIn(level, generate_site.LEVELS)
|
||||
self.assertIn(generate_site.level_label(level), text)
|
||||
self.assertIn(f"\nlevel: {level}\n", text)
|
||||
# The label above each title comes from the page's level.
|
||||
component = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text()
|
||||
for level, label in generate_site.LEVELS.items():
|
||||
with self.subTest(level=level):
|
||||
self.assertIn(f"{level}: '{label}'", component)
|
||||
|
||||
def test_the_decoration_keeps_an_authored_level(self):
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
@@ -177,9 +189,8 @@ class EveryPageHasALevel(unittest.TestCase):
|
||||
generate_site.decorate_markdown_pages(Path(tmp))
|
||||
text = page.read_text()
|
||||
self.assertEqual(text.count("level: start"), 1)
|
||||
self.assertEqual(text.count(generate_site.level_label("start")), 1)
|
||||
self.assertIn(" boost: 5\n", text)
|
||||
self.assertIn("# FAQ\n", text)
|
||||
self.assertIn("searchWeight: 10\n", text)
|
||||
self.assertIn("title: FAQ\n", text)
|
||||
|
||||
|
||||
class DeepLinksCarryALabel(unittest.TestCase):
|
||||
@@ -189,8 +200,11 @@ class DeepLinksCarryALabel(unittest.TestCase):
|
||||
its line, or in the heading of its section ("## Reference: ...")."""
|
||||
|
||||
def test_start_and_setup_pages(self):
|
||||
import sitestarlight # noqa: PLC0415
|
||||
|
||||
pages = _docs_pages()
|
||||
levels = {page: _level(page) for page in pages}
|
||||
by_url = {sitestarlight.page_url(sitepages.name(page)): page for page in pages}
|
||||
offenders = []
|
||||
for page, level in levels.items():
|
||||
if level not in ("start", "setup"):
|
||||
@@ -201,7 +215,7 @@ class DeepLinksCarryALabel(unittest.TestCase):
|
||||
if re.match(r"#{1,3} ", line):
|
||||
heading = line.lstrip("#").strip()
|
||||
for match in LINK.finditer(line):
|
||||
target = (page.parent / match.group(1)).resolve()
|
||||
target = by_url.get(match.group(1))
|
||||
if target not in levels:
|
||||
continue
|
||||
deeper = levels[target]
|
||||
@@ -218,18 +232,20 @@ class GuidePagesEndOnANextStep(unittest.TestCase):
|
||||
for page in ("index.md", "which-pack.md", "platforms/batocera.md",
|
||||
"wiki/first-install.md", "wiki/troubleshooting.md",
|
||||
"wiki/what-are-bios-files.md", "wiki/faq.md"):
|
||||
path = DOCS / page if page.startswith(("index", "which", "platforms")) else REPO_ROOT / page
|
||||
if not path.is_file():
|
||||
_docs_pages()
|
||||
if page.startswith(("index", "which", "platforms")):
|
||||
sitepages.require()
|
||||
path = sitepages.page(page)
|
||||
else:
|
||||
path = REPO_ROOT / page
|
||||
with self.subTest(page=page):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
self.assertTrue('!!! tip "Next"' in text or "## What comes next" in text)
|
||||
self.assertTrue(":::tip[Next]" in text or "## What comes next" in text)
|
||||
|
||||
def test_the_platform_page_opens_on_its_guide(self):
|
||||
"""The folder came after four counters and a metadata table, and was
|
||||
the pack-relative `bios/` where Batocera reads /userdata/bios/."""
|
||||
text = (DOCS / "platforms" / "batocera.md").read_text(encoding="utf-8") \
|
||||
if (DOCS / "platforms" / "batocera.md").is_file() else _docs_pages()
|
||||
sitepages.require()
|
||||
text = sitepages.page("platforms/batocera.md").read_text(encoding="utf-8")
|
||||
order = [text.index(h) for h in (
|
||||
"## Where the files go", "## Install", "## Download", "## How to check",
|
||||
"## Reference: every file by system",
|
||||
|
||||
@@ -200,9 +200,15 @@ class SharingCards(unittest.TestCase):
|
||||
ET.fromstring(sitediagrams.og_card_svg(section, b"png"))
|
||||
|
||||
def test_the_template_names_every_section(self):
|
||||
template = (ROOT / "docs_assets" / "overrides" / "main.html").read_text(encoding="utf-8")
|
||||
named = set(re.findall(r"section = '([a-z]+)'", template))
|
||||
import sitestarlight # noqa: PLC0415
|
||||
|
||||
named = {sitestarlight.og_section("index.md", "")} | {
|
||||
sitestarlight.og_section("wiki/page.md", level)
|
||||
for level in ("start", "setup", "explore", "reference", "contribute")}
|
||||
self.assertEqual(named, set(sitediagrams.OG_SECTIONS))
|
||||
for section in named:
|
||||
with self.subTest(section=section):
|
||||
self.assertTrue((ROOT / "docs_assets" / "og" / f"{section}.png").is_file())
|
||||
|
||||
|
||||
class ImagesStillNeeded(unittest.TestCase):
|
||||
|
||||
+24
-18
@@ -126,16 +126,18 @@ class StructuredBlocks(unittest.TestCase):
|
||||
page = self.render(self.profile("firmware_titles", [
|
||||
{"id": "0005001b-10042400", "name": "Shared fonts",
|
||||
"content": ["CafeCn.ttf", "CafeKr.ttf"]}]))
|
||||
self.assertIn(" - id: 0005001b-10042400, name: Shared fonts\n", page)
|
||||
self.assertIn(" - **content:**", page)
|
||||
self.assertIn(" - CafeKr.ttf", page)
|
||||
self.assertIn("\n- id: 0005001b-10042400, name: Shared fonts\n", page)
|
||||
self.assertIn("\n - **content:**", page)
|
||||
self.assertIn("\n - CafeKr.ttf", page)
|
||||
|
||||
def test_a_folded_note_stays_inside_its_block(self):
|
||||
page = self.render(self.profile("analysis", {"tmss": {
|
||||
"note": "The core points\nsystem/tmss_path at the system directory."}}))
|
||||
self.assertIn("- **note:** The core points system/tmss_path at the", page)
|
||||
body = page.split("\n")[1:]
|
||||
self.assertEqual([line for line in body if line and not line.startswith(" ")], [])
|
||||
# Nothing of the note is left after the block closes.
|
||||
lines = page.split("\n")
|
||||
end = lines.index("</details>")
|
||||
self.assertEqual([line for line in lines[end + 1:] if line.strip()], [])
|
||||
|
||||
def test_a_long_note_is_not_cut(self):
|
||||
note = "word " * 80 + "end."
|
||||
@@ -315,7 +317,7 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
|
||||
self.assertEqual(len(headings), 11)
|
||||
self.assertTrue(headings[0].endswith("{ #system-00 }"))
|
||||
self.assertNotIn('<a id="system-00"></a>', lines)
|
||||
self.assertIn('??? note "1 file (OK)"', lines)
|
||||
self.assertIn("<summary>1 file (OK)</summary>", lines)
|
||||
|
||||
def test_an_emulator_card_is_a_heading_with_its_anchor(self):
|
||||
import generate_site
|
||||
@@ -323,8 +325,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
|
||||
entry = {"name": "scph5501.bin", "required": True}
|
||||
lines = generate_site._render_emulator_file(
|
||||
entry, {"files": [entry]}, None, [entry], lambda f: True, "file-scph5501-bin")
|
||||
self.assertTrue(lines[2].startswith("### `scph5501.bin` "))
|
||||
self.assertTrue(lines[2].endswith("{ #file-scph5501-bin .rb-file-title }"))
|
||||
self.assertTrue(lines[2].startswith('<h3 id="file-scph5501-bin" class="rb-file-title"'))
|
||||
self.assertIn("<code>scph5501.bin</code>", lines[2])
|
||||
|
||||
def test_a_system_card_is_a_heading_with_its_anchor(self):
|
||||
import generate_site
|
||||
@@ -332,8 +334,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
|
||||
|
||||
entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3}
|
||||
lines = generate_site._system_file_card(entry, FileConsumers({"files": {}}))
|
||||
self.assertTrue(lines[2].startswith("### `a.bin` "))
|
||||
self.assertTrue(lines[2].endswith("{ #sha1-aaaaaaaaaaaa .rb-file-title }"))
|
||||
self.assertTrue(lines[2].startswith('<h3 id="sha1-aaaaaaaaaaaa" class="rb-file-title"'))
|
||||
self.assertIn("<code>a.bin</code>", lines[2])
|
||||
|
||||
|
||||
class TheSearchSkipsLinkLists(unittest.TestCase):
|
||||
@@ -353,11 +355,14 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
|
||||
consumers.emulators = {sha1: {"duckstation"}}
|
||||
entry = {"name": "a.bin", "sha1": sha1, "md5": "b" * 32, "size": 3}
|
||||
text = "\n".join(generate_site._system_file_card(entry, consumers))
|
||||
searched, skipped = text.split("<section data-search-exclude markdown>")
|
||||
self.assertIn("`a.bin`", searched)
|
||||
searched, _sep, skipped = text.partition("<section data-pagefind-ignore>")
|
||||
self.assertIn("<code>a.bin</code>", searched)
|
||||
self.assertNotIn("- MD5:", searched)
|
||||
for line in ("- MD5:", "- Emulators:", "- Platforms:", "- Also installed by:"):
|
||||
for line in ("- MD5:", "- Emulators:", "- Also installed by:"):
|
||||
self.assertIn(line, skipped)
|
||||
# The platforms line names the file as each setup does: the site
|
||||
# search reads it, so a file name finds its card.
|
||||
self.assertIn("- Platforms:", searched + skipped.split("</section>", 1)[1])
|
||||
|
||||
def test_a_game_data_card_skips_its_name_too(self):
|
||||
import generate_site
|
||||
@@ -366,7 +371,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
|
||||
entry = {"name": "0.ogg", "sha1": "a" * 40, "size": 3}
|
||||
lines = generate_site._system_file_card(
|
||||
entry, FileConsumers({"files": {}}), searchable=False)
|
||||
self.assertTrue(lines[2].endswith("data-search-exclude }"))
|
||||
self.assertIn("data-pagefind-ignore", lines[2])
|
||||
self.assertNotIn("data-pagefind-weight", lines[2])
|
||||
|
||||
def test_runs_alternate_in_card_order(self):
|
||||
import generate_site
|
||||
@@ -377,10 +383,10 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
|
||||
out = [line for line in generate_site._search_skipped_apart(lines) if line]
|
||||
self.assertEqual(out, [
|
||||
"- Path: `p`",
|
||||
'<div class="rb-search-skip" data-search-exclude markdown>',
|
||||
'<div class="rb-search-skip" data-pagefind-ignore>',
|
||||
"- SHA1: x", "- Source: `a.c:1`", "- Platforms: p", "</div>",
|
||||
"- Destination: `d`",
|
||||
'<div class="rb-search-skip" data-search-exclude markdown>',
|
||||
'<div class="rb-search-skip" data-pagefind-ignore>',
|
||||
"- Contents (1 members):", " - `m`", "</div>",
|
||||
"- Note",
|
||||
])
|
||||
@@ -391,8 +397,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
|
||||
short = "| a |\n|---|\n| 1 |"
|
||||
long = " | a |\n |---|\n" + "\n".join(f" | {'x' * 50} |" for _ in range(80))
|
||||
text = generate_site.tables_out_of_search(f"# T\n\n{short}\n\n{long}\n")
|
||||
self.assertEqual(text.count("data-search-exclude"), 1)
|
||||
self.assertIn(" <section data-search-exclude markdown>\n\n | a |", text)
|
||||
self.assertEqual(text.count("data-pagefind-ignore"), 1)
|
||||
self.assertIn(" <section data-pagefind-ignore>\n\n | a |", text)
|
||||
self.assertIn(short, text)
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ class AnIdNamesOneCardPerPage(unittest.TestCase):
|
||||
"path": "bios/Nintendo/DS/a.bin"}
|
||||
page = gs.generate_system_page(
|
||||
"Nintendo", {"DS": [entry], "DSi": [entry]}, FileConsumers({"files": {}}))
|
||||
self.assertEqual(page.count("#sha1-aaaaaaaaaaaa .rb-file-title"), 1)
|
||||
self.assertEqual(page.count('id="sha1-aaaaaaaaaaaa" class="rb-file-title"'), 1)
|
||||
self.assertEqual(page.count("rb-sys-file"), 2)
|
||||
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ class PlatformDetails(unittest.TestCase):
|
||||
"root": "<system_dir>/x",
|
||||
}}}
|
||||
)
|
||||
self.assertIn(" - extensions_tried: .bin, .zip", lines)
|
||||
self.assertIn(" - hle_available: no", lines)
|
||||
self.assertIn(" - root: <system_dir>/x", lines)
|
||||
self.assertIn(" - extensions_tried: .bin, .zip", lines)
|
||||
self.assertIn(" - hle_available: no", lines)
|
||||
self.assertIn(" - root: <system_dir>/x", lines)
|
||||
|
||||
|
||||
class RenderedSiteValidation(unittest.TestCase):
|
||||
@@ -112,10 +112,8 @@ class RenderedSiteValidation(unittest.TestCase):
|
||||
self.root = Path(self.temp.name)
|
||||
self.site = self.root / "site"
|
||||
self.site.mkdir()
|
||||
self.config = self.root / "mkdocs.yml"
|
||||
self.config.write_text(
|
||||
"site_url: https://example.test/retrobios/\n", encoding="utf-8"
|
||||
)
|
||||
# The path the site is served under.
|
||||
self.config = "/retrobios/"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
@@ -174,8 +172,7 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase):
|
||||
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")
|
||||
self.config = "/retrobios/"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
@@ -214,17 +211,16 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase):
|
||||
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")
|
||||
index = self.site / "pagefind" / "index"
|
||||
index.mkdir(parents=True)
|
||||
(index / "en_big.pf_index").write_bytes(b"x" * (module.MAX_PAGEFIND_CHUNK_BYTES + 1))
|
||||
(self.site / "pagefind" / "pagefind.en_x.pf_meta").write_bytes(
|
||||
b"x" * (module.MAX_PAGEFIND_META_BYTES + 1))
|
||||
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))
|
||||
self.assertTrue(any("en_big.pf_index" in issue for issue in issues), issues)
|
||||
self.assertTrue(any(".pf_meta" in issue for issue in issues), issues)
|
||||
|
||||
|
||||
class UnrenderedMarkdown(unittest.TestCase):
|
||||
@@ -327,9 +323,12 @@ class TheSitemapIsAnnouncedWhereCrawlersLook(unittest.TestCase):
|
||||
never found that way."""
|
||||
|
||||
def test_the_head_names_it_and_no_robots_is_written(self):
|
||||
# Starlight links the sitemap from every page's head when the site
|
||||
# has an address, and the config keeps sitemap.xml at its old path.
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
override = (root / "docs_assets" / "overrides" / "main.html").read_text()
|
||||
self.assertIn('rel="sitemap"', override)
|
||||
config = (root / "website" / "astro.config.mjs").read_text()
|
||||
self.assertIn("site: SITE", config)
|
||||
self.assertIn("sitemap.xml", config)
|
||||
source = (root / "scripts" / "generate_site.py").read_text()
|
||||
self.assertNotIn('"robots.txt"),\n "User-agent', source)
|
||||
self.assertNotIn("Sitemap: https://", source)
|
||||
@@ -351,11 +350,10 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
|
||||
only its sha1 and md5, so the crc a clrmamepro user pastes found nothing."""
|
||||
|
||||
def test_the_rendered_page(self):
|
||||
page = ROOT / "docs" / "platforms" / "retroarch.md"
|
||||
if not page.is_file():
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("site not generated")
|
||||
from tests import sitepages # noqa: PLC0415
|
||||
|
||||
sitepages.require()
|
||||
page = sitepages.page("platforms/retroarch.md")
|
||||
self.assertIn("CRC32: `91e93107`", page.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@@ -378,7 +376,7 @@ class PlainHouseStyle(unittest.TestCase):
|
||||
}
|
||||
|
||||
def test_the_stylesheet_has_none_of_the_patterns(self):
|
||||
css = (ROOT / "docs_assets" / "extra.css").read_text(encoding="utf-8")
|
||||
css = (ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
|
||||
for label, pattern in self.PATTERNS.items():
|
||||
with self.subTest(pattern=label):
|
||||
self.assertIsNone(pattern.search(css), label)
|
||||
@@ -392,21 +390,3 @@ class PlainHouseStyle(unittest.TestCase):
|
||||
first = page.read_text(encoding="utf-8").splitlines()[0]
|
||||
with self.subTest(page=page.name):
|
||||
self.assertFalse(first.endswith(" - RetroBIOS"), first)
|
||||
|
||||
|
||||
class APreviewLeavesTheTrackedConfig(unittest.TestCase):
|
||||
"""--docs-dir moved the pages but still rewrote the tracked mkdocs.yml."""
|
||||
|
||||
def test_another_folder_gets_its_own_config(self):
|
||||
from generate_site import _mkdocs_config
|
||||
|
||||
path, text = _mkdocs_config(Path("docs"), "hooks:\n - scripts/mkdocs_hooks.py\n")
|
||||
self.assertEqual(path, Path("mkdocs.yml"))
|
||||
path, text = _mkdocs_config(Path("tmp/preview"), "hooks:\n - scripts/mkdocs_hooks.py\n")
|
||||
self.assertEqual(path, Path("tmp/preview.mkdocs.yml"))
|
||||
self.assertTrue(text.startswith("docs_dir: preview\n"))
|
||||
self.assertIn("../scripts/mkdocs_hooks.py", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+27
-18
@@ -18,8 +18,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import mkdocs_hooks # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
import sitemarkup as markup # noqa: E402
|
||||
from cross_reference import FileConsumers # noqa: E402
|
||||
|
||||
|
||||
@@ -54,7 +55,7 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase):
|
||||
self.assertIn("## Big", summary)
|
||||
self.assertIn("## Small", summary)
|
||||
# The small section keeps its cards in place.
|
||||
self.assertIn("{ #sha1-" + f"{900:012x}", summary)
|
||||
self.assertIn('id="sha1-' + f"{900:012x}", summary)
|
||||
parts = [stem for stem in pages if stem.startswith("maker-big")]
|
||||
self.assertGreater(len(parts), 1)
|
||||
self.assertEqual(parts[0], "maker-big")
|
||||
@@ -73,7 +74,9 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase):
|
||||
|
||||
def test_a_console_on_one_page_is_linked_directly(self):
|
||||
self.consoles["Big"] = self.consoles["Big"][:12]
|
||||
whole = self._pages(10**9)["maker"][0]
|
||||
# The budget is measured in the Markdown it was set on.
|
||||
with markup.using("mkdocs"):
|
||||
whole = self._pages(10**9)["maker"][0]
|
||||
pages = self._pages(len(whole) - 1)
|
||||
self.assertIn("maker-big", pages)
|
||||
self.assertNotIn("maker-big-2", pages)
|
||||
@@ -99,8 +102,8 @@ class AnEmulatorWithTooManyCardsIsSplit(unittest.TestCase):
|
||||
for anchor in anchors:
|
||||
self.assertIn(f'<span id="{anchor}"></span>', main)
|
||||
self.assertIn('class="rb-card-index"', main)
|
||||
self.assertNotIn(".rb-file-title", main)
|
||||
self.assertIn(f"{{ #{anchors[0]} .rb-file-title }}", pages["big-files-1"])
|
||||
self.assertNotIn("rb-file-title", main)
|
||||
self.assertIn(f'id="{anchors[0]}" class="rb-file-title"', pages["big-files-1"])
|
||||
|
||||
def test_cards_are_linked_on_the_page_that_holds_them(self):
|
||||
files = [{"name": f"set{i:04d}.zip"} for i in range(generate_site.EMULATOR_CARDS_PER_PAGE + 1)]
|
||||
@@ -147,21 +150,27 @@ class TheNavigationOpensOneInitial(unittest.TestCase):
|
||||
|
||||
class TheTableOfContentsOfACardPageStopsAtLevelTwo(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _item(level: int, children: list | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(level=level, children=children or [])
|
||||
@staticmethod
|
||||
def _contents(rel: str, sections: int):
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
body = "# T\n\n" + "".join(f"## S{i}\n\n### card\n\n" for i in range(sections))
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
rel, body, level="", weight=None, browser_title="T", description="d",
|
||||
structured={}, terms={})
|
||||
return yaml.safe_load(text.split("---\n")[1]).get("tableOfContents", "default")
|
||||
|
||||
def test_cards_leave_the_toc_and_wiki_pages_keep_level_three(self):
|
||||
def page(uri: str) -> SimpleNamespace:
|
||||
h2 = self._item(2, [self._item(3)])
|
||||
return SimpleNamespace(file=SimpleNamespace(src_uri=uri),
|
||||
toc=SimpleNamespace(items=[self._item(1, [h2])]))
|
||||
|
||||
for uri, kept in (("systems/sony.md", 0), ("platforms/retroarch.md", 0),
|
||||
("emulators/mame.md", 0), ("wiki/faq.md", 1)):
|
||||
current = page(uri)
|
||||
mkdocs_hooks.on_page_content("", current, None, None)
|
||||
with self.subTest(uri=uri):
|
||||
self.assertEqual(len(current.toc.items[0].children[0].children), kept)
|
||||
level_two = {"minHeadingLevel": 2, "maxHeadingLevel": 2}
|
||||
for rel, sections, expected in (
|
||||
("systems/sony.md", 3, level_two), ("platforms/retroarch.md", 5, level_two),
|
||||
("emulators/mame.md", 3, level_two), ("wiki/faq.md", 3, "default"),
|
||||
# One or two headings over hundreds of cards: no table at all,
|
||||
# whose script watched every block of the page.
|
||||
("emulators/mame-files-2.md", 1, False), ("systems/arcade-arcade.md", 2, False),
|
||||
):
|
||||
with self.subTest(rel=rel):
|
||||
self.assertEqual(self._contents(rel, sections), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -79,15 +79,8 @@ class DeploySiteTriggers(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
self.assertIn("generate_site.py", entry_points)
|
||||
# mkdocs build runs the hooks mkdocs.yml names: a script of the build.
|
||||
# The file carries Python tags safe_load refuses, but the list is plain.
|
||||
mkdocs = (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8")
|
||||
hooks = re.search(r"^hooks:\n((?: - .+\n)+)", mkdocs, re.M)
|
||||
entry_points += [
|
||||
line.strip()[2:].removeprefix("scripts/")
|
||||
for line in (hooks.group(1).splitlines() if hooks else [])
|
||||
if line.strip()[2:].startswith("scripts/")
|
||||
]
|
||||
# The Starlight project is an input of its own build.
|
||||
self.assertIn("website/**", patterns)
|
||||
uncovered = sorted(
|
||||
path
|
||||
for path in closure(entry_points)
|
||||
@@ -109,9 +102,9 @@ class DeploySiteTriggers(unittest.TestCase):
|
||||
|
||||
|
||||
class ThePullRequestBuildsTheSite(unittest.TestCase):
|
||||
"""mkdocs was installed by deploy-site.yml alone, which runs after the
|
||||
merge, and validate.yml did not watch wiki/: a dead link or a line read as
|
||||
a second heading passed every check and failed the deploy on main."""
|
||||
"""The site build ran in deploy-site.yml alone, after the merge, and
|
||||
validate.yml did not watch wiki/: a dead link or a line read as a second
|
||||
heading passed every check and failed the deploy on main."""
|
||||
|
||||
@staticmethod
|
||||
def _workflow(name: str) -> dict:
|
||||
@@ -124,17 +117,23 @@ class ThePullRequestBuildsTheSite(unittest.TestCase):
|
||||
runs = "\n".join(str(step.get("run", "")) for step in steps)
|
||||
for command in (
|
||||
"python scripts/generate_site.py",
|
||||
"mkdocs build --strict",
|
||||
"python scripts/validate_site.py",
|
||||
"npm ci --prefix website",
|
||||
"npm run build --prefix website",
|
||||
"python scripts/validate_site.py --site-dir website/dist",
|
||||
):
|
||||
with self.subTest(command=command):
|
||||
self.assertIn(command, runs)
|
||||
for package in ("mkdocs-material", "pymdown-extensions"):
|
||||
with self.subTest(package=package):
|
||||
self.assertIn(package, runs)
|
||||
# The same Node, from the official action pinned by commit, in both.
|
||||
uses = {str(step.get("uses", "")) for step in steps}
|
||||
deploy = {str(step.get("uses", ""))
|
||||
for step in self._workflow("deploy-site.yml")["jobs"]["build"]["steps"]}
|
||||
node = {use for use in uses if use.startswith("actions/setup-node@")}
|
||||
self.assertEqual(len(node), 1)
|
||||
self.assertTrue(node <= deploy)
|
||||
self.assertRegex(next(iter(node)), r"@[0-9a-f]{40}$")
|
||||
|
||||
def test_the_pages_exist_before_the_suite_reads_them(self):
|
||||
"""docs/ is gitignored. Built after the suite, the tests that read
|
||||
"""The pages are gitignored. Built after the suite, the tests that read
|
||||
the rendered pages found none and skipped on every CI run."""
|
||||
steps = self._workflow("validate.yml")["jobs"]["run-tests"]["steps"]
|
||||
runs = [str(step.get("run", "")) for step in steps]
|
||||
|
||||
Reference in new issue
Block a user