diff --git a/emulators/samcoupe.yml b/emulators/samcoupe.yml index 650d7f88..4d69b7ac 100644 --- a/emulators/samcoupe.yml +++ b/emulators/samcoupe.yml @@ -1,4 +1,4 @@ -emulator: SimCoupe +emulator: "SimCoupe (standalone)" type: standalone core_classification: official_port source: "https://github.com/simonowen/simcoupe" diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 42132875..05f9dae2 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1982,7 +1982,7 @@ def _render_emulator_file( lines.append("") lines.append(f"**`{fname}`** {badge_str}") if desc: - lines.append(f"
{desc}") + lines.append(f"
{_escape_tags(str(desc))}") lines.append("") details = [] @@ -2011,25 +2011,13 @@ def _render_emulator_file( bounds.append(f"max {_fmt_size(fmax)}") details.append(f"Size: {', '.join(bounds)}") if fsha1: - s = fsha1[:12] - details.append( - f'SHA1: ' - f"`{s}...`" - ) + details.append(f"SHA1: {_hash_spans(fsha1)}") if fmd5: - s = fmd5[:12] - details.append( - f'MD5: ' - f"`{s}...`" - ) + details.append(f"MD5: {_hash_spans(fmd5)}") if fcrc32: details.append(f"CRC32: `{fcrc32}`") if fsha256: - s = fsha256[:12] - details.append( - f'SHA256: ' - f"`{s}...`" - ) + details.append(f"SHA256: {_hash_spans(fsha256)}") if fadler32: details.append(f"Adler32: `{fadler32}`") if aliases: @@ -2077,7 +2065,7 @@ def _render_emulator_file( for d in details: lines.append(f"- {d}") if fnote: - lines.append(f"- {fnote}") + lines.append(f"- {_escape_tags(str(fnote))}") if contents: lines.append(f"- Contents ({len(contents)} entries):") for c in contents[:10]: @@ -2194,12 +2182,22 @@ def _render_structured_blocks(profile: dict) -> list[str]: continue lines.append(f'???+ abstract "{label}"') lines.append("") + start = len(lines) _render_yaml_value(lines, val, indent=4) + lines[start:] = [_escape_tags(line) for line in lines[start:]] lines.append("") return lines +def _hash_spans(value: str | list) -> str: + """One abbreviated hash, or each of the hashes a profile accepts.""" + values = [value] if isinstance(value, str) else [str(v) for v in value] + return ", ".join( + f'`{v[:12]}...`' for v in values + ) + + def _availability_check(db: dict, data_names): """A predicate answering whether the collection holds a file. @@ -2361,7 +2359,7 @@ def generate_emulator_page( [ "", '!!! info "Why no files"', - f" {exclusion}", + f" {_escape_tags(str(exclusion))}", ] ) else: @@ -3972,6 +3970,7 @@ from siterender import ( # noqa: E402,F401 _fmt_size, _pct, _admonition_body, + _escape_tags, _icon_name, _icon_url, system_icon_markdown, diff --git a/scripts/siterender.py b/scripts/siterender.py index 6a82ff63..41fbdb52 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -17,9 +17,33 @@ ICON_CACHE_PATH = Path(".cache") / "system_icons.json" # because a heading with a broken image reads worse than a heading without one. _icon_available: dict[str, bool] = {} +_CODE_SPAN = re.compile(r"(`+).+?\1", re.S) + + +def _escape_tags(text: str) -> str: + """Show a placeholder such as ```` as it was written. + + Profile prose names paths with angle brackets. Passed through as HTML + they are unknown tags and vanish from the page, and ```` opens + a title element that swallows everything after it. Code spans keep their + brackets, and an autolink stays a link. + """ + def outside(chunk: str) -> str: + return re.sub(r"<(?!https?://|mailto:)", "<", chunk) + + out: list[str] = [] + position = 0 + for match in _CODE_SPAN.finditer(text): + out.append(outside(text[position:match.start()])) + out.append(match.group(0)) + position = match.end() + out.append(outside(text[position:])) + return "".join(out) + + def _admonition_body(text: str) -> str: """Indent prose without turning source tokens such as ``#if`` into H1s.""" - escaped = re.sub(r"(?m)^(\s*)#", r"\1\\#", text) + escaped = re.sub(r"(?m)^(\s*)#", r"\1\\#", _escape_tags(text)) return escaped.replace("\n", "\n ") def _icon_name(manufacturer: str, console_name: str) -> str: diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index 134c0ba0..5d5b6f7e 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -33,6 +33,26 @@ def _page(title: str, description: str, body: str = "") -> str: """ +class ProsePlaceholders(unittest.TestCase): + """A path written with angle brackets reaches the reader as written.""" + + def test_a_placeholder_is_text_not_a_tag(self): + from siterender import _admonition_body, _escape_tags + + self.assertEqual( + _escape_tags("gameProfiles/<title id>.ini under <system_dir>/Cemu"), + "gameProfiles/<title id>.ini under <system_dir>/Cemu", + ) + self.assertIn("<title id>", _admonition_body("read <title id>.ini")) + + def test_code_spans_and_links_are_left_alone(self): + from siterender import _escape_tags + + self.assertEqual(_escape_tags("`<system_dir>/x`"), "`<system_dir>/x`") + self.assertEqual(_escape_tags("see <https://example.org/a>"), "see <https://example.org/a>") + self.assertEqual(_escape_tags("size < 4096"), "size < 4096") + + class RenderedSiteValidation(unittest.TestCase): def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT)