Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<link rel="canonical">` 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`
Expand Down
10 changes: 9 additions & 1 deletion site/dins.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DINS damage inspection coverage | Perimeter</title>
<meta name="description" content="Coverage and completeness counts over California's public wildfire datasets. Unofficial.">
<meta name="description" content="What California&#x27;s post-fire damage inspection records contain, counted per field and per incident. Unofficial.">
<link rel="canonical" href="https://chelseakr.github.io/perimeter/dins.html">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Perimeter">
<meta property="og:title" content="DINS damage inspection coverage | Perimeter">
<meta property="og:description" content="What California&#x27;s post-fire damage inspection records contain, counted per field and per incident. Unofficial.">
<meta property="og:url" content="https://chelseakr.github.io/perimeter/dins.html">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<style>:root {
color-scheme: light;
--surface: #fcfcfb;
Expand Down
10 changes: 9 additions & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Perimeter | Coverage measures for California wildfire datasets</title>
<meta name="description" content="Coverage and completeness counts over California's public wildfire datasets. Unofficial.">
<meta name="description" content="Two coverage measurements over California&#x27;s public wildfire datasets, published as counts. Unofficial.">
<link rel="canonical" href="https://chelseakr.github.io/perimeter/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Perimeter">
<meta property="og:title" content="Perimeter | Coverage measures for California wildfire datasets">
<meta property="og:description" content="Two coverage measurements over California&#x27;s public wildfire datasets, published as counts. Unofficial.">
<meta property="og:url" content="https://chelseakr.github.io/perimeter/">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<style>:root {
color-scheme: light;
--surface: #fcfcfb;
Expand Down
10 changes: 9 additions & 1 deletion site/perimeters.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Historical fire perimeter completeness | Perimeter</title>
<meta name="description" content="Coverage and completeness counts over California's public wildfire datasets. Unofficial.">
<meta name="description" content="How complete California&#x27;s historical fire perimeter record is, counted per fire year and per field. Unofficial.">
<link rel="canonical" href="https://chelseakr.github.io/perimeter/perimeters.html">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Perimeter">
<meta property="og:title" content="Historical fire perimeter completeness | Perimeter">
<meta property="og:description" content="How complete California&#x27;s historical fire perimeter record is, counted per fire year and per field. Unofficial.">
<meta property="og:url" content="https://chelseakr.github.io/perimeter/perimeters.html">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<style>:root {
color-scheme: light;
--surface: #fcfcfb;
Expand Down
36 changes: 35 additions & 1 deletion src/perimeter/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
from perimeter.schema import Basis
from perimeter.sources import DINS, FRAP, Source

# Where these pages are served. The path segment is load-bearing: this is one of six project
# sites on the shared `chelseakr.github.io` origin, published on PATHS rather than on domains
# of their own. `https://chelseakr.github.io/` is not a shorter spelling of this site's root;
# it is a different address, it 404s, and all six sites would claim it. A canonical naming it
# would invite a crawler to fold six unrelated projects into one document.
SITE_URL = "https://chelseakr.github.io/perimeter/"

# 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. The index
# canonicalises to the directory form, which is the URL Pages serves it at.
CANONICAL_PATH = {"index": "", "perimeters": "perimeters.html", "dins": "dins.html"}

DISCLAIMER = (
"Unofficial. Not affiliated with or endorsed by CAL FIRE, FRAP, or any California "
"state agency."
Expand Down Expand Up @@ -437,6 +449,7 @@ def field_table(
def page(
*,
title: str,
description: str,
body: str,
active: str,
is_fixture: bool,
Expand All @@ -457,13 +470,22 @@ def page(
for href, label, key in nav_items
for current in [' aria-current="page"' if key == active else ""]
)
canonical = SITE_URL + CANONICAL_PATH[active]
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)}</title>
<meta name="description" content="Coverage and completeness counts over California's public wildfire datasets. Unofficial.">
<meta name="description" content="{esc(description)}">
<link rel="canonical" href="{esc(canonical)}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Perimeter">
<meta property="og:title" content="{esc(title)}">
<meta property="og:description" content="{esc(description)}">
<meta property="og:url" content="{esc(canonical)}">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<style>{STYLESHEET}</style>
</head>
<body>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 85 additions & 1 deletion tests/test_pages_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 `<link rel="canonical">` 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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions tests/test_published_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<link rel="canonical" href="([^"]*)">', 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'<meta property="og:url" content="{expected}">' 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,
Expand Down
Loading