From c4422eb9f5f3ca16f7e58e8b4febd0baa9a70b53 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:50:49 -0700 Subject: [PATCH] seo: three pages shared one description and none had a canonical `render.py` hardcoded a single sentence into the head of all three pages: "Coverage and completeness counts over California's public wildfire datasets. Unofficial." A search result for the DINS page and a search result for the perimeters page were therefore indistinguishable, and neither said what its own page counts. `page()` now takes a description per page, each restating what that page already says of itself in its own standfirst. The descriptions state NO count. The committed `site/` is built from `data/raw/` and the test build is built from the fixtures, so a figure hardcoded in `render.py` would be true of one build and false of the other. CONTRIBUTING's rule is that a number is counted or it is not published, and these counts belong to CAL FIRE's files, which change underneath us. Nothing here says how many records, how many incidents or how much is covered. There was also no canonical, and no Open Graph, on any page. These pages are one of six project sites on the shared `chelseakr.github.io` origin, served from PATHS rather than domains of their own, so the canonical a single-domain habit produces ("/") is not this site's root: it is a different address, it 404s, and all six sites would claim it. A crawler that believes them folds six unrelated projects into one document. `SITE_URL` and `CANONICAL_PATH` are keyed by `active`, the value each page already uses to mark its own nav entry, so the canonical and the highlighted tab cannot disagree about which page this is. No `og:image`: this repository ships no image, and inventing one would be adding the first. `twitter:card` is `summary`, which promises none. The page parser in `tests/test_pages_html.py` could not see any of this. It recorded `name=` metas only, so every `property="og:..."` tag was invisible to it, and `` is not a meta tag at all. It now records both, and the existing title-and-description checks were extended rather than joined by a parallel SEO suite. `tests/test_published_site.py` gains the same assertion over the COMMITTED bytes, beside the rooted-link check that already holds the same subpath property for `href` and cannot catch a canonical, because a canonical is an absolute URL rather than a rooted path. Observed, by breaking it: canonical line deleted; `SITE_URL` set to the bare origin; `CANONICAL_PATH` collapsed so all three pages share one canonical; and the original defect restored, one description for all three. A fifth, hand editing the committed `site/index.html` canonical to the shared origin, fails the published-bytes check. Restored, `make verify` exits 0, including html-validate, axe, the Playwright reflow specs and the determinism check. `make site` was run from the real `data/raw/` files. Its diff is head-only: every changed line is a `canonical`, `og:*`, `twitter:card` or `description` tag, and not one published figure moved. A `make site` on the unmodified tree beforehand produced an empty diff, so the committed figures already matched the data. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 38 ++++++++++++++++ site/dins.html | 10 ++++- site/index.html | 10 ++++- site/perimeters.html | 10 ++++- src/perimeter/render.py | 36 ++++++++++++++- tests/test_pages_html.py | 86 +++++++++++++++++++++++++++++++++++- tests/test_published_site.py | 31 +++++++++++++ 7 files changed, 216 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 859a0fb..ecdd3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project a ## [Unreleased] +### Fixed, the three pages could not say which of them a search result was for + +- **One meta description was shared by all three pages, and none of them had a canonical.** + `render.py` hardcoded a single sentence, "Coverage and completeness counts over + California's public wildfire datasets. Unofficial.", into the head of the index, the + perimeters page and the DINS page alike, so a search result for one was + indistinguishable from a search result for another and none said what its own page + counts. `page()` now takes a `description` per page, and each restates what that page + already says of itself in its own standfirst. + + The descriptions state **no count**. The committed `site/` is built from `data/raw/` + and the test build is built from the fixtures, so a figure hardcoded in `render.py` + would be true of one and false of the other; CONTRIBUTING's rule is that a number is + counted or it is not published, and these counts belong to CAL FIRE's files, which + change underneath us. + +- **A canonical URL and Open Graph tags on every page.** There were none. These pages are + one of six project sites on the shared `chelseakr.github.io` origin, served from paths + rather than domains of their own, so the canonical a single-domain habit produces (`/`) + is not this site's root but a different address that 404s, and all six would claim it. + `SITE_URL` and `CANONICAL_PATH` are keyed by `active`, the value each page already uses + to mark its own nav entry, so a canonical and the highlighted tab cannot disagree about + which page this is. No `og:image`: this repository ships no image, and `twitter:card` is + `summary`, which promises none. + +- **The page parser in `tests/test_pages_html.py` could not see any of this.** It recorded + `name=` metas only, so every `property="og:..."` tag was invisible to it, and + `` is not a meta tag at all. It now records both, and the existing + title-and-description checks were extended rather than joined by a parallel SEO suite. + `tests/test_published_site.py` gains the same assertion over the committed bytes, beside + the rooted-link check that holds the same subpath property for `href`. + + Observed failing four ways: the canonical line deleted; `SITE_URL` set to the bare + origin; `CANONICAL_PATH` collapsed so all three pages share one canonical; and the + original defect restored, one description for all three pages. A fifth, hand-editing the + committed `site/index.html` canonical to the shared origin, fails the published-bytes + check. `make site` reproduces `site/` from `data/raw/` with no change outside the head. + ### Documented, the pages have been served since 2026-08-08 and the README never said so - **The README named the two measurement pages only as build outputs.** `site/perimeters.html` diff --git a/site/dins.html b/site/dins.html index 8c9dd15..caec896 100644 --- a/site/dins.html +++ b/site/dins.html @@ -4,7 +4,15 @@ DINS damage inspection coverage | Perimeter - + + + + + + + + + @@ -680,6 +702,10 @@ def perimeters_page(report: PerimeterReport, *, is_fixture: bool) -> str: """ return page( title="Historical fire perimeter completeness | Perimeter", + description=( + "How complete California's historical fire perimeter record is, counted " + "per fire year and per field. Unofficial." + ), body=body, active="perimeters", is_fixture=is_fixture, @@ -913,6 +939,10 @@ def dins_page(report: DinsReport, *, is_fixture: bool) -> str: """ return page( title="DINS damage inspection coverage | Perimeter", + description=( + "What California's post-fire damage inspection records contain, counted " + "per field and per incident. Unofficial." + ), body=body, active="dins", is_fixture=is_fixture, @@ -1005,6 +1035,10 @@ def index_page( """ return page( title="Perimeter | Coverage measures for California wildfire datasets", + description=( + "Two coverage measurements over California's public wildfire datasets, " + "published as counts. Unofficial." + ), body=body, active="index", is_fixture=is_fixture, diff --git a/tests/test_pages_html.py b/tests/test_pages_html.py index 65b1aff..4928c27 100644 --- a/tests/test_pages_html.py +++ b/tests/test_pages_html.py @@ -34,7 +34,7 @@ from perimeter.cells import present_tenths_of_percent from perimeter.cli import build_site -from perimeter.render import DARK, LIGHT +from perimeter.render import DARK, LIGHT, SITE_URL from perimeter.sources import SOURCES ROOT = Path(__file__).resolve().parents[1] @@ -75,6 +75,11 @@ def __init__(self) -> None: self.lang: str | None = None self.title: str = "" self.metas: dict[str, str] = {} + # Open Graph uses `property=`, not `name=`, so the meta collector above cannot see + # it, and `` is not a meta tag at all. Both are head facts a + # structural check should be able to reach. + self.properties: dict[str, str] = {} + self.links: dict[str, str] = {} self.prose: list[str] = [] self.cells: list[str] = [] self._heading: str | None = None @@ -106,6 +111,10 @@ def _note_head(self, tag: str, attr: dict[str, str]) -> None: key = attr.get("name") or ("charset" if "charset" in attr else "") if key: self.metas[key] = attr.get("content", attr.get("charset", "")) + if "property" in attr: + self.properties[attr["property"]] = attr.get("content", "") + elif tag == "link" and "rel" in attr: + self.links[attr["rel"]] = attr.get("href", "") def _note_table(self, tag: str, attr: dict[str, str]) -> None: if tag == "table": @@ -173,6 +182,81 @@ def test_the_page_has_a_title_and_a_description(built: Path, name: str) -> None: assert doc.metas.get("description", "").strip() +def test_each_page_describes_itself_rather_than_the_other_two(built: Path) -> None: + """One description shared by three pages describes none of them. + + All three pages carried the same sentence, so a search result for the DINS page and a + search result for the perimeters page were indistinguishable, and neither said what + its own page counts. The renderer now takes the description per page. + + The descriptions state no count on purpose. The committed ``site/`` is built from + ``data/raw/`` and this test builds from the fixtures, so a figure hardcoded in + ``render.py`` would be true of one build and false of the other, and CONTRIBUTING's + rule is that a number is counted or it is not published. The counts also belong to + CAL FIRE's files, which change underneath us. + """ + descriptions = { + name: parse(built / name).metas.get("description", "").strip() for name in PAGES + } + assert all(descriptions.values()), descriptions + assert len(set(descriptions.values())) == len(PAGES), descriptions + + +@pytest.mark.parametrize("name", PAGES) +def test_the_page_names_itself_and_not_the_shared_origin( + built: Path, name: str +) -> None: + """The canonical carries this project's path segment, not the bare origin. + + These pages are one of six project sites on ``chelseakr.github.io``, served from + PATHS rather than from domains of their own. That makes the single-domain habit + actively wrong here rather than merely untidy: a canonical of "/" resolves to + ``https://chelseakr.github.io/``, which is not this site's root but a different + address that 404s, and all six sites would claim the identical canonical. A crawler + that believes them folds six unrelated projects into one document. + + So this asserts the path segment is PRESENT, not merely that a canonical exists: an + empty or origin-rooted canonical satisfies "is there a canonical" and is the bug. + """ + doc = parse(built / name) + + canonical = doc.links.get("canonical", "") + assert canonical, f"{name} has no canonical URL" + assert canonical.startswith(SITE_URL), f"{name} canonicalises to {canonical!r}" + assert canonical.rstrip("/") != "https://chelseakr.github.io", ( + f"{name} points at the shared origin, which is a different site" + ) + assert "/perimeter/" in canonical, f"{name}: {canonical!r} omits the project path" + + # The social card repeats the page rather than describing it a second time, so the two + # cannot drift into disagreeing about what this page is. + assert doc.properties.get("og:url") == canonical, f"{name} og:url disagrees" + assert doc.properties.get("og:title") == doc.title.strip(), ( + f"{name} og:title disagrees" + ) + assert doc.properties.get("og:description") == doc.metas.get("description"), name + assert doc.properties.get("og:type") == "website", name + + # No image is published in this repository, so the card must not promise one. If a + # social image is ever committed, this fails until og:image is added with it. + card = doc.metas.get("twitter:card") + assert card in ("summary", "summary_large_image"), ( + f"{name} twitter:card is {card!r}" + ) + if card == "summary_large_image": + assert doc.properties.get("og:image"), ( + f"{name} promises an image it has none of" + ) + + +def test_the_three_pages_do_not_share_one_canonical(built: Path) -> None: + """Three pages, three canonicals. A shared one asks a crawler to drop two of them.""" + canonicals = { + name: parse(built / name).links.get("canonical", "") for name in PAGES + } + assert len(set(canonicals.values())) == len(PAGES), canonicals + + @pytest.mark.parametrize("name", PAGES) def test_there_is_exactly_one_first_level_heading(built: Path, name: str) -> None: levels = [tag for tag, _ in parse(built / name).headings] diff --git a/tests/test_published_site.py b/tests/test_published_site.py index 3ce71a5..798d11e 100644 --- a/tests/test_published_site.py +++ b/tests/test_published_site.py @@ -121,6 +121,37 @@ def test_no_published_link_is_rooted_at_the_server(name: str) -> None: ) +@pytest.mark.parametrize("name", PAGES) +def test_every_published_page_canonicalises_to_itself_under_the_subpath( + name: str, +) -> None: + """The same subpath property the test above holds for links, held for the canonical. + + A link rooted at ``/`` leaves this site. So does a canonical: ``site/`` is served + from ``/perimeter/`` on an origin shared with five other project sites, so a + canonical of ``https://chelseakr.github.io/`` names a different address, one that + 404s, and every one of the six would claim it. ``ROOTED`` above cannot catch it, + because a canonical is an absolute URL rather than a rooted path. + + This checks the COMMITTED bytes. ``tests/test_pages_html.py`` holds the same property + over a fixture build, which proves the renderer; only this proves what is deployed. + """ + expected = { + "index.html": "https://chelseakr.github.io/perimeter/", + "perimeters.html": "https://chelseakr.github.io/perimeter/perimeters.html", + "dins.html": "https://chelseakr.github.io/perimeter/dins.html", + }[name] + text = (SITE / name).read_text(encoding="utf-8") + found = re.search(r'', text) + assert found, f"{name} has no canonical URL" + assert found.group(1) == expected, ( + f"{name} canonicalises to {found.group(1)!r}, not {expected!r}" + ) + assert f'' in text, ( + f"{name} og:url disagrees with its canonical" + ) + + @pytest.mark.parametrize("name", PAGES) def test_every_published_page_says_it_is_not_affiliated_with_cal_fire( name: str,