From 56302f7497f5e4e8a839fc713dd7085f7d4cdec3 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:29:41 +0000 Subject: [PATCH 1/2] Measure documentation-site visits with consent-gated Google Analytics The site loads Google Analytics 4 through Material's integration only after a visitor accepts the new cookie consent dialog. The site audit rejects any page whose analytics loader is not gated by that consent, and docs/SITE.md now describes Google Analytics as the single third-party request. --- CHANGES.md | 3 +++ dkc/site.py | 16 ++++++++++++++++ docs/SITE.md | 20 +++++++++++++++++--- site/mkdocs.yml | 17 ++++++++++++++++- tests/test_site.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e176826..e96c774 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,9 @@ cutting a version are in [Maintaining this file](#maintaining-this-file). and query-bearing site requests, and `/index.html`, redirect to the canonical HTTPS URL. Operators add them after the new site build is published. See [docs/CLOUDFLARE_CACHE.md](docs/CLOUDFLARE_CACHE.md#8-redirect-site-aliases). +- The documentation site measures visits with Google Analytics, which loads + only after a visitor accepts the new cookie consent dialog. See + [docs/SITE.md](docs/SITE.md#analytics-and-consent). ### Changed diff --git a/dkc/site.py b/dkc/site.py index c48ba4d..d4c9885 100644 --- a/dkc/site.py +++ b/dkc/site.py @@ -791,6 +791,13 @@ class _Document: title: list[str] = field(default_factory=list) text: list[str] = field(default_factory=list) source_component: bool = False + analytics: bool = False + consent_dialog: bool = False + + +# Material calls the analytics loader only behind this test of the stored +# consent. A theme change that drops it must fail the audit, not leak requests. +_ANALYTICS_CONSENT_GATE = "consent.analytics&&__md_analytics()" class _PageParser(HTMLParser): @@ -809,6 +816,10 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._in_title = True if values.get("data-md-component") == "source": self.document.source_component = True + if values.get("data-md-component") == "consent": + self.document.consent_dialog = True + if tag == "script" and values.get("id") == "__analytics": + self.document.analytics = True if tag == "meta" and values.get("name", "").lower() == "description": self.document.descriptions.append(values.get("content", "")) if tag == "link" and "canonical" in values.get("rel", "").split(): @@ -887,6 +898,11 @@ def audit_site( raise SiteError(f"{key}: empty title") if document.source_component: raise SiteError(f"{key}: repository widget would contact a third party") + if document.analytics and not ( + document.consent_dialog + and _ANALYTICS_CONSENT_GATE in "".join(document.text) + ): + raise SiteError(f"{key}: analytics would load without visitor consent") for tag, reference in document.references: checked += 1 _check_reference( diff --git a/docs/SITE.md b/docs/SITE.md index 4ca21e7..667569d 100644 --- a/docs/SITE.md +++ b/docs/SITE.md @@ -103,10 +103,24 @@ The build fails unless every check passes: - `sitemap.xml` lists exactly the pages, each once with a UTC `lastmod`; `robots.txt` matches the generated policy; `llms.txt` advertises only existing pages; and the IndexNow key file holds the tracked key; -- the home page shows the tracked archive primary fingerprint. +- the home page shows the tracked archive primary fingerprint; +- every page that carries the analytics loader also carries the consent dialog + and calls the loader only behind the stored analytics consent. -The site carries no analytics, web fonts, or other third-party requests. It is -not a trust anchor: users verify the archive key against the fingerprint +## Analytics and consent + +The site uses Google Analytics 4 through Material's built-in integration, +configured under `extra.analytics` in `site/mkdocs.yml`. Every visitor first +sees a cookie consent dialog with accept, reject, and manage actions; the +footer link reopens it. Until the visitor accepts analytics, the page loads +nothing from Google and sets no analytics cookie. The dialog appears for every +visitor, because the static site cannot tell where a visitor is. + +Google Analytics is the only third-party request. The site loads no web fonts, +repository widget, or other external resource, and the audit rejects any +external script, stylesheet, image, or frame in the generated HTML. + +The site is not a trust anchor: users verify the archive key against the fingerprint procedure in [USER_INSTALL.md](USER_INSTALL.md). ## Pull requests diff --git a/site/mkdocs.yml b/site/mkdocs.yml index eaebed8..a2052da 100644 --- a/site/mkdocs.yml +++ b/site/mkdocs.yml @@ -15,7 +15,7 @@ site_url: https://dkc-linux.romancello.net/ copyright: >- Project automation is MIT-licensed; Linux and Debian-derived packaging keep their own licenses. DKC is not sponsored, endorsed, or affiliated with the - Debian Project. + Debian Project. Change cookie settings docs_dir: pages use_directory_urls: false @@ -85,6 +85,21 @@ extra: - icon: fontawesome/brands/github link: https://github.com/kogeler/dkc-linux name: Source on GitHub + # Google Analytics loads only after the visitor accepts the consent dialog; + # the site audit rejects analytics without it. + analytics: + provider: google + property: G-LZ1273E7W8 + consent: + title: Cookie consent + description: >- + With your consent, this site uses Google Analytics cookies to count + visits and see which pages are read. Nothing is loaded from Google + unless you accept. + actions: + - accept + - reject + - manage nav: - Home: README.md diff --git a/tests/test_site.py b/tests/test_site.py index b43dfe1..546b26a 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -463,6 +463,36 @@ def test_the_audit_rejects_each_contract_violation( _audit(site, pages) +_ANALYTICS = '' +_CONSENT_DIALOG = '
' +_CONSENT_GATE = ( + '" +) + + +@pytest.mark.parametrize( + ("addition", "accepted"), + [ + (_ANALYTICS + _CONSENT_DIALOG + _CONSENT_GATE, True), + (_ANALYTICS + _CONSENT_GATE, False), + (_ANALYTICS + _CONSENT_DIALOG + "", False), + ], +) +def test_analytics_must_wait_for_visitor_consent( + tmp_path: Path, addition: str, accepted: bool +) -> None: + site, pages = _site(tmp_path) + target = site / "docs/A.html" + text = target.read_text(encoding="utf-8") + target.write_text(text.replace("