From 60721667beee603d12e84109d7a73c8a036c9d8f Mon Sep 17 00:00:00 2001 From: deadmanoz <62584182+deadmanoz@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:47:53 +0800 Subject: [PATCH] feat(site): generate a static website from the dataset Give every record a page at block/{hash}/ so a block can be linked and found by searching its hash, as asked in #6. The index lists the records, sortable by column and filterable by text, rule or kind of evidence; notes/ renders docs/notes.md and reported/ lists the reported blocks. Pages follow the visitor's light or dark preference, with a toggle in the nav. Block pages take the rule's Core check and CI evidence from the tables in docs/schema.md, say whether the evidence on file is a full block, a P2SH spend proof, a coinbase proof or the header alone, and quote the incident note that names the block's height, or names a block with the same failing_prevout. ci/generate-website.py writes site/ using pinned Python-Markdown, with ci/website.css and ci/website.js alongside. The sanity-check workflow builds the site on every run; on main, a deploy job that needs the validation job publishes it to GitHub Pages. --- .github/workflows/sanitycheck.yml | 29 +- .gitignore | 1 + README.md | 6 + ci/generate-website.py | 496 ++++++++++++++++++++++++++++++ ci/test_generate_website.py | 71 +++++ ci/website.css | 107 +++++++ ci/website.js | 62 ++++ requirements.txt | 1 + 8 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 ci/generate-website.py create mode 100644 ci/test_generate_website.py create mode 100644 ci/website.css create mode 100644 ci/website.js diff --git a/.github/workflows/sanitycheck.yml b/.github/workflows/sanitycheck.yml index 417cbf0..04cd7bf 100644 --- a/.github/workflows/sanitycheck.yml +++ b/.github/workflows/sanitycheck.yml @@ -13,6 +13,12 @@ on: permissions: contents: read +# Runs on a branch go one at a time, so an older run on main never deploys over a newer one. +# A newer push to a pull request cancels its superseded run. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: sanity-check: runs-on: ubuntu-latest @@ -29,13 +35,14 @@ jobs: path: .cache/prevouts key: prevouts-v2-${{ hashFiles('blocks/*.bin', 'proofs/*.json') }} restore-keys: prevouts-v2- - - name: validate data and test validator + - name: validate data, test validator and generate website run: | python -m venv .venv . .venv/bin/activate python -m pip install -r requirements.txt python ci/sanity-check.py --fetch-prevouts python -m unittest discover -s ci -p 'test_*.py' + python ci/generate-website.py # Save once per block set, after validation and tests pass on the default branch. - name: save verified previous transactions if: >- @@ -46,3 +53,23 @@ jobs: with: path: .cache/prevouts key: ${{ steps.prevouts-cache.outputs.cache-primary-key }} + # Every validated run uploads the site; only the deploy job publishes it. + - name: upload website + uses: actions/upload-pages-artifact@v5 + with: + path: site + + deploy-website: + needs: sanity-check + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e8f5bc7..1d670a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .venv/ __pycache__/ .cache/prevouts/ +site/ diff --git a/README.md b/README.md index eb3466c..bccfbfa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ This dataset covers blocks that fail those rules, including failures that can be Merge-mined recoveries generally provide a header and coinbase rather than a full Bitcoin block. +## Website + +[bitcoin-data.github.io/invalid-blocks](https://bitcoin-data.github.io/invalid-blocks/) is generated from this repository and deployed once validation passes on `main`. +It has a page for each block at `block/{hash}/`, the rendered [notes](docs/notes.md) and the reported blocks. +`python ci/generate-website.py` writes it to `site/`; CI runs the same command on pull requests, so a change that breaks the site fails before merge. + ## Contributing Add one record to [`data/invalid-blocks.jsonl`](data/invalid-blocks.jsonl), sorted by height then hash. diff --git a/ci/generate-website.py b/ci/generate-website.py new file mode 100644 index 0000000..5f5ba2b --- /dev/null +++ b/ci/generate-website.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Generate the static website in site/ from the dataset, its notes and its schema. + +Each record gets a page at block/{hash}/, so a search for a block hash can find it. +The index lists every record; notes/ renders docs/notes.md and reported/ lists the reported blocks. +""" + +import html +import json +import posixpath +import re +import shutil +from collections import Counter +from datetime import datetime, timezone +from itertools import dropwhile, takewhile +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import markdown +from markdown.extensions.toc import TocExtension +from bitcoin.core import CBlockHeader, CTransaction, b2lx + +REPO_ROOT = Path(__file__).resolve().parent.parent +DATA_PATH = REPO_ROOT / "data" / "invalid-blocks.jsonl" +REPORTED_PATH = REPO_ROOT / "data" / "reported-blocks.jsonl" +NOTES_PATH = REPO_ROOT / "docs" / "notes.md" +SCHEMA_PATH = REPO_ROOT / "docs" / "schema.md" +BLOCKS_DIR = REPO_ROOT / "blocks" +PROOFS_DIR = REPO_ROOT / "proofs" +CSS_PATH = Path(__file__).with_name("website.css") +JS_PATH = Path(__file__).with_name("website.js") +OUT_DIR = REPO_ROOT / "site" + +SITE_URL = "https://bitcoin-data.github.io/invalid-blocks" +REPO_URL = "https://github.com/bitcoin-data/invalid-blocks" +BLOB_URL = f"{REPO_URL}/blob/main" +RAW_URL = f"{REPO_URL}/raw/main" +BLOCK_ROOT = "../../" # block pages sit at block/{hash}/ + +RELATIVE_HREF = re.compile(r'href="(?!https?://|mailto:|#)([^"]+)"') +BLOCK_FILE = re.compile(r"\.\./blocks/\d+-([0-9a-f]{64})\.bin") +HEADING = re.compile(r'

(.*?)

') + +esc = html.escape + + +def load_jsonl(path: Path) -> list[dict[str, Any]]: + return [json.loads(line) for line in path.read_text().splitlines()] + + +def utc(timestamp: int, fmt: str = "%Y-%m-%d %H:%M:%S UTC") -> str: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(fmt) + + +def root_of(path: str) -> str: + return "../" * path.count("/") or "./" + + +def block_href(root: str, block_hash: str) -> str: + return f"{root}block/{block_hash}/" + + +def dl(rows: list[tuple[str, str]]) -> str: + return '
' + "".join(f"
{key}
{value}
" for key, value in rows) + "
" + + +def schema_rows(schema: str, heading: str) -> dict[str, list[str]]: + """Read the first Markdown table under heading, keyed by its first cell.""" + lines = schema.split(heading, 1)[1].splitlines() + table = list(takewhile(lambda line: line.startswith("|"), dropwhile(lambda line: not line.startswith("|"), lines))) + cells = [[cell.strip() for cell in line.strip("|").split("|")] for line in table[2:]] + return {row[0].strip("`"): row for row in cells} + + +def rule_text(schema: str) -> dict[str, tuple[str, str]]: + """Map each rule to its Core check and CI evidence from the schema tables, rendered for a block page.""" + checks = schema_rows(schema, "## Rules and Core reject strings") + evidence = schema_rows(schema, "## Evidence enforced by CI") + + def render(cell: str) -> str: + inline = markdown.markdown(cell).removeprefix("

").removesuffix("

") + return rewrite_links(inline, BLOCK_ROOT) + + return {rule: (render(checks[rule][2]), render(evidence[rule][1])) for rule in checks} + + +def rewrite_links(body: str, root: str) -> str: + """Resolve relative hrefs rendered from docs/*.md for a page at root: block files to block pages, notes to notes/, the rest to GitHub.""" + + def resolve(match: re.Match[str]) -> str: + path, _, anchor = match.group(1).partition("#") + fragment = f"#{anchor}" if anchor else "" + block = BLOCK_FILE.fullmatch(path) + if block: + return f'href="{block_href(root, block.group(1))}"' + if path == "notes.md": + return f'href="{root}notes/{fragment}"' + return f'href="{BLOB_URL}/{posixpath.normpath(posixpath.join("docs", path))}{fragment}"' + + return RELATIVE_HREF.sub(resolve, body) + + +def render_notes(notes: str) -> tuple[str, str, list[dict[str, Any]]]: + """Render docs/notes.md without its title, with its contents list and its incident sections' heights and block-page excerpts.""" + converter = markdown.Markdown(extensions=["tables", TocExtension(toc_depth="2-3")]) + body = converter.convert(notes) + body = re.sub(r"]*>.*?\s*", "", body, count=1) + body = body.replace("", '
').replace("
", "") + incidents = next((t for t in converter.toc_tokens if t["name"] == "Incident notes"), None) + if incidents is None: + raise ValueError(f"{NOTES_PATH} has no Incident notes section") + sections = [] + for token in incidents["children"]: + paragraph = re.search(rf'

.*?

\s*

(.*?)

', body, re.S) + sections.append({ + "id": token["id"], + "html": token["html"], + "heights": [int(h) for h in re.findall(r"\d+", token["name"].split(" - ")[0])], + "excerpt": rewrite_links(paragraph.group(1), BLOCK_ROOT) if paragraph else "", + }) + return body, converter.toc, sections + + +def link_heading_heights(body: str, root: str, records: list[dict[str, Any]]) -> str: + """Link the heights in each incident heading to their block pages, where a height names a single record.""" + heights = Counter(r["height"] for r in records) + single = {r["height"]: r["hash"] for r in records if heights[r["height"]] == 1} + + def link(height: re.Match[str]) -> str: + block_hash = single.get(int(height.group(0))) + return f'{height.group(0)}' if block_hash else height.group(0) + + def heading(match: re.Match[str]) -> str: + heights, dash, title = match.group(2).partition(" - ") + return f'

{re.sub(r"\d+", link, heights)}{dash}{title}

' + + return HEADING.sub(heading, body) + + +def note_links(records: list[dict[str, Any]], incidents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Map each record hash to the incident note naming its height, or naming a block with the same failing_prevout.""" + by_height = {height: incident for incident in incidents for height in incident["heights"]} + links = {r["hash"]: by_height[r["height"]] for r in records if r["height"] in by_height} + spends = {r["hash"]: r.get("context", {}).get("failing_prevout") for r in records} + by_spend = {spends[block_hash]: incident for block_hash, incident in links.items() if spends[block_hash]} + return {block_hash: by_spend[spend] for block_hash, spend in spends.items() if spend in by_spend} | links + + +# Evidence kinds, in index order, with their index card labels; a kind's badge reads as the name with spaces. +EVIDENCE_CARDS = { + "full-block": "with full block", + "spend-proof": "with P2SH spend proof", + "coinbase-proof": "with coinbase proof", + "header-only": "header only", +} + + +def stem(record: dict[str, Any]) -> str: + return f"{record['height']}-{record['hash']}" + + +def evidence_on_file(record: dict[str, Any]) -> tuple[str, dict[str, Any] | None]: + """Name the kind of evidence on file for a record, with its proof file when it has one.""" + if (BLOCKS_DIR / f"{stem(record)}.bin").exists(): + return "full-block", None + path = PROOFS_DIR / f"{stem(record)}.json" + if not path.exists(): + return "header-only", None + proof = json.loads(path.read_text()) + coinbase = CTransaction.deserialize(bytes.fromhex(proof["transaction"])).is_coinbase() + return ("coinbase-proof" if coinbase else "spend-proof"), proof + + +def badge(kind: str, href: str = "", label: str = "") -> str: + """Badge styled as kind; its text is label, or the kind with spaces for hyphens.""" + text = esc(label) if label else kind.replace("-", " ") + if href: + return f'{text}' + return f'{text}' + + +def site_url(path: str) -> str: + return f"{SITE_URL}/{path.removesuffix('index.html')}" + + +def page(path: str, title: str, description: str, body: str) -> str: + root = root_of(path) + + def nav(href: str, label: str) -> str: + current = ' aria-current="page"' if path == f"{href}index.html" else "" + return f'{label}' + + return f""" + + + + +{esc(title)} + + + + + + +
+ +
+{body} +
+
+Generated from bitcoin-data/invalid-blocks; data is dedicated to the public domain under CC0 1.0. +CI checks the named failure of every record; the schema says what each check covers and what it does not. +
+
+ + +""" + + +def index_page( + records: list[dict[str, Any]], + reported: list[dict[str, Any]], + on_file: dict[str, tuple[str, dict[str, Any] | None]], +) -> tuple[str, str]: + path = "index.html" + counts = Counter(kind for kind, _ in on_file.values()) + counts[""] = len(records) + cards = "\n".join( + f'' + for kind, label in {"": "invalid blocks", **EVIDENCE_CARDS}.items() + ) + chips = "".join( + f'' + for rule, count in Counter(r["rule"] for r in records).most_common() + ) + attributes = {"height": ' class="num" aria-sort="ascending"', "observations": ' class="num"'} + headers = "".join( + f'' + for name in ("height", "date", "hash", "rule", "pool", "evidence", "observations") + ) + rows = [] + for record in records: + kind, _ = on_file[record["hash"]] + pool = record.get("context", {}).get("pool", "") + date = utc(record["nTime"], "%Y-%m-%d") + search = " ".join([str(record["height"]), record["hash"], record["rule"], record["core_reject_reason"], pool, date]).lower() + rows.append(f""" +{record['height']} +{date} +{record['hash'][:12]}…{record['hash'][-8:]} +{badge("rule", label=record["rule"])} +{esc(pool) or '-'} +{badge(kind)} +{len(record.get('observations', []))} +""") + + body = f"""

Bitcoin invalid blocks

+

Headers and blocks with valid proof of work that fail a named consensus rule.

+
+

A stale block follows the rules but ends up outside the active chain; these blocks break them. +They were observed on the Bitcoin network or recovered from other sources, including chains that merge-mine with Bitcoin and archived block explorers. +The data is in data/invalid-blocks.jsonl, and contributions are welcome in the repository.

+
+ +

Blocks

+ +
+ +{headers} + +{"\n".join(rows)} + +
+
""" + description = f"{len(records)} Bitcoin blocks with valid proof of work that fail a named consensus rule, with headers, full blocks, evidence and observations." + return path, page(path, "Bitcoin invalid blocks", description, body) + + +def context_rows(context: dict[str, Any]) -> list[tuple[str, str]]: + """List a record's context for display: the pool fields share a row, and parent_kind shows with the previous block.""" + rows = [] + if "pool" in context: + source = f' (source)' if "pool_provenance" in context else "" + rows.append(("pool", f'{esc(context["pool"])} by {esc(context["pool_basis"])}{source}')) + for key, value in context.items(): + if key in ("pool", "pool_basis", "pool_provenance", "parent_kind"): + continue + shown = f'{esc(str(value))}' + if key == "parent_mtp": + shown += f' {utc(value)}' + rows.append((key, shown)) + if key == "coinbase_scriptsig_hex": + text = "".join(chr(b) if 32 <= b < 127 else "·" for b in bytes.fromhex(value)) + rows.append(("scriptSig as text", f'{esc(text)}')) + return rows + + +def observations_panel(observations: list[dict[str, Any]]) -> str: + if not observations: + return "" + rows = [] + for obs in observations: + child = esc(obs.get("child_chain", "")) + if "child_height" in obs: + child += f' {obs["child_height"]}' + seen = utc(obs["first_seen"]) if "first_seen" in obs else "" + url = esc(obs["provenance"]) + rows.append( + f'{esc(obs["channel"])}{esc(obs["source"])}{child}' + f'{seen}{url}' + ) + return f"""

Observations ({len(observations)})

+

Where the block was seen or recovered. Observations record acquisition; they do not establish the failure.

+
+{"".join(rows)}
channelsourcechild chainfirst seenprovenance
""" + + +def block_page( + position: int, + records: list[dict[str, Any]], + rules: dict[str, tuple[str, str]], + incident: dict[str, Any] | None, + evidence: tuple[str, dict[str, Any] | None], +) -> tuple[str, str]: + record = records[position] + path = f"block/{record['hash']}/index.html" + root = BLOCK_ROOT + context = record.get("context", {}) + header = CBlockHeader.deserialize(bytes.fromhex(record["header"])) + name = stem(record) + kind, proof = evidence + check, ci_checks = rules[record["rule"]] + + note_html = "" + if incident: + note_html = f"""

Incident note: {incident['html']}

+

{incident['excerpt']}

""" + rule_is_reject = record["rule"] == record["core_reject_reason"] + rule = badge("rule", f'{root}#{esc(record["rule"])}', record["rule"]) + if rule_is_reject: + rule_rows = [("rule and Core reject", rule)] + else: + rule_rows = [("rule", rule), ("Core reject", f"{esc(record['core_reject_reason'])}")] + verdict = dl(rule_rows + [("Core check", check), ("CI checks", ci_checks)]) + + files = [] + if kind == "full-block": + size = (BLOCKS_DIR / f"{name}.bin").stat().st_size + files.append(("full block", f'{name}.bin {size:,} bytes')) + elif proof: + proved = "coinbase" if kind == "coinbase-proof" else "failing transaction" + placement = "its merkle branch" if "merkle_branch" in proof else "the block's ordered txids" + files.append(("proof file", f'{name}.json {proved} and {placement}')) + files.append(("record", f'data/invalid-blocks.jsonl, line {position + 1}')) + + parent = next((r for r in records if r["hash"] == record["prev_hash"]), None) + previous = f'{record["prev_hash"]}' + if parent: + previous = f'{parent["hash"]} (invalid, in this dataset)' + elif context.get("parent_kind") == "canonical": + previous += f' (canonical, mempool.space)' + elif "parent_kind" in context: + previous += f' ({esc(context["parent_kind"])})' + header_rows = [ + ("version", f'0x{header.nVersion & 0xffffffff:08x} ({header.nVersion})'), + ("previous block", previous), + *(("child in dataset", f'{r["hash"]} height {r["height"]}') + for r in records if r["prev_hash"] == record["hash"]), + ("merkle root", f'{b2lx(header.hashMerkleRoot)}'), + ("time", f'{utc(header.nTime)} ({header.nTime})'), + ("bits", f'0x{header.nBits:08x} difficulty {header.difficulty:,.0f}'), + ("nonce", str(header.nNonce)), + ("header hex", f'{record["header"]}'), + ] + shown_context = context_rows(context) + context_html = f'

Context

{dl(shown_context)}
' if shown_context else "" + + before = records[position - 1] if position > 0 else None + after = records[position + 1] if position + 1 < len(records) else None + pager = f"""
+{f'← {before["height"]}' if before else ""} +{f'{after["height"]} →' if after else ""} +
""" + + body = f"""

Invalid block {record['height']}

+

{record['hash']}

+
+
+

Why it is invalid

{verdict}{note_html}
+

Header

{dl(header_rows)}
+
+
+

Evidence on file {badge(kind, f"{root}#{kind}")}

{dl(files)}
+{context_html} +
+
+{observations_panel(record.get("observations", []))} +{pager}""" + title = f"Invalid Bitcoin block {record['height']} {record['hash']}" + by_pool = f", mined by {context['pool']}" if "pool" in context else "" + reject = "" if rule_is_reject else f" ({record['core_reject_reason']})" + description = ( + f"Bitcoin block {record['hash']} at height {record['height']}{by_pool}, header time {utc(record['nTime'], '%Y-%m-%d')}, " + f"fails {record['rule']}{reject}. Header, evidence and observations." + ) + return path, page(path, title, description, body) + + +def notes_page(body: str, contents: str, records: list[dict[str, Any]]) -> tuple[str, str]: + path = "notes/index.html" + root = root_of(path) + body = link_heading_heights(rewrite_links(body, root), root, records) + html_body = f"""

Notes

+

Rendered from docs/notes.md.

+

Contents

{contents}
+
+{body} +
""" + description = "Notes on replaying full blocks, pool attributions, coinbase proofs, reported blocks and each invalid-block incident." + return path, page(path, "Notes · Bitcoin invalid blocks", description, html_body) + + +def reported_page(reported: list[dict[str, Any]]) -> tuple[str, str]: + path = "reported/index.html" + rows = [] + for record in reported: + recovered = badge("header-only", label="header") if "header" in record else 'hash only' + sources = " ".join(f'{esc(urlparse(url).hostname.removeprefix("www."))}' for url in record["sources"]) + rows.append(f"""{record['height']} +{record['hash']}{recovered}{esc(record['reported_failure'])}{sources}""") + body = f"""

Reported blocks

+

Blocks a contemporary source reported as invalid, not admitted because the header or block needed to establish the failure is missing.

+

Issues labelled reported record what has been searched for each case and are the place to bring the missing data. +The notes describe each group.

+
+ + +{"\n".join(rows)} +
heighthashrecoveredreported failuresources
""" + description = "Bitcoin blocks reported as invalid whose failure is not yet established, with the reports and what has been recovered." + return path, page(path, "Reported invalid blocks · Bitcoin invalid blocks", description, body) + + +def sitemap(paths: list[str]) -> str: + entries = "\n".join(f"{site_url(path)}" for path in paths) + return f'\n\n{entries}\n\n' + + +def main() -> None: + records = load_jsonl(DATA_PATH) + reported = load_jsonl(REPORTED_PATH) + rules = rule_text(SCHEMA_PATH.read_text()) + notes, contents, incidents = render_notes(NOTES_PATH.read_text()) + links = note_links(records, incidents) + on_file = {r["hash"]: evidence_on_file(r) for r in records} + pages = [ + index_page(records, reported, on_file), + notes_page(notes, contents, records), + reported_page(reported), + *(block_page(position, records, rules, links.get(r["hash"]), on_file[r["hash"]]) for position, r in enumerate(records)), + ] + + files = pages + [ + ("sitemap.xml", sitemap([path for path, _ in pages])), + ("style.css", CSS_PATH.read_text()), + ("website.js", JS_PATH.read_text()), + ] + + if OUT_DIR.exists(): + shutil.rmtree(OUT_DIR) + for path, content in files: + (OUT_DIR / path).parent.mkdir(parents=True, exist_ok=True) + (OUT_DIR / path).write_text(content) + print(f"Generated {OUT_DIR} ({len(records)} block pages)") + + +if __name__ == "__main__": + main() diff --git a/ci/test_generate_website.py b/ci/test_generate_website.py new file mode 100644 index 0000000..10d9df2 --- /dev/null +++ b/ci/test_generate_website.py @@ -0,0 +1,71 @@ +"""The website links each record to its notes and never trusts record text as markup.""" + +import copy +import importlib.util +from pathlib import Path +import unittest + +SPEC = importlib.util.spec_from_file_location("generate_website", Path(__file__).with_name("generate-website.py")) +SITE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SITE) + + +class WebsiteChecks(unittest.TestCase): + def setUp(self): + self.records = SITE.load_jsonl(SITE.DATA_PATH) + + def for_height(self, height): + return next(r for r in self.records if r["height"] == height) + + def test_note_links(self): + """A record links to the note naming its height or a block with its failing spend; sharing a rule is not enough.""" + _, _, incidents = SITE.render_notes(SITE.NOTES_PATH.read_text()) + links = SITE.note_links(self.records, incidents) + cases = [ + ("height in heading", 783426, "F2Pool sigops"), + ("same failing spend", 174012, "P2SH redeem-script failure"), + ("same rule only", 367047, None), + ("no note", 331673, None), + ] + for case, height, title in cases: + with self.subTest(case=case): + note = links.get(self.for_height(height)["hash"]) + if title: + self.assertIn(title, note["html"]) + else: + self.assertIsNone(note) + + def test_relative_links(self): + """Links rendered from docs resolve to block pages, the notes page or GitHub from the page's depth.""" + record = self.for_height(74638) + cases = [ + (f"../blocks/{record['height']}-{record['hash']}.bin", f"../../block/{record['hash']}/"), + ("schema.md#rules", f"{SITE.BLOB_URL}/docs/schema.md#rules"), + ("../data/invalid-blocks.jsonl", f"{SITE.BLOB_URL}/data/invalid-blocks.jsonl"), + ("notes.md#reported-blocks", "../../notes/#reported-blocks"), + ("https://example.com/x.md", "https://example.com/x.md"), + ("#incident-notes", "#incident-notes"), + ] + for link, expected in cases: + with self.subTest(link=link): + self.assertEqual(SITE.rewrite_links(f'x', "../../"), f'x') + + def test_pages_escape_record_text(self): + """Record strings are escaped on the block page and in the index's attributes; the hash is in the page title for search.""" + records = copy.deepcopy(self.records) + position = records.index(self.for_height(783426)) + records[position]["context"]["pool"] = "" + records[position]["observations"][0]["source"] = '">' + rules = SITE.rule_text(SITE.SCHEMA_PATH.read_text()) + evidence = {r["hash"]: SITE.evidence_on_file(r) for r in records} + _, block = SITE.block_page(position, records, rules, None, evidence[records[position]["hash"]]) + _, index = SITE.index_page(records, [], evidence) + for page in (block, index): + self.assertNotIn("