Add Plausible and consent-gated GA4 to badseo.dev (#380)

* Add consent-gated GA4 to badseo.dev

* Refine analytics consent banner

* Add Plausible analytics to badseo.dev

* Tighten analytics consent prompt

* Update analytics consent copy
This commit is contained in:
Ben Senescu 2026-07-11 11:05:37 -04:00 committed by Ben Senescu
parent 61c0b0c657
commit 987e5494a2
12 changed files with 424 additions and 8 deletions

View File

@ -6,6 +6,10 @@ This is not a completed-work log or a bug tracker. Never include secrets, creden
## Open
- [ ] `2026-07-10T22:32:40Z``codex` — While running the badseo audit from the workspace, sandboxed TSX failed with `listen EPERM` when creating its IPC socket under the temporary directory. Rerunning the same audit with local IPC permission succeeded; provide a sandbox-compatible TSX invocation for validation scripts.
- [ ] `2026-07-10T22:23:50Z``codex` — While pushing the consent-banner refinement to PR #380, the SSH push hung without output and a separate remote verification hung as well; retrying the same push succeeded immediately. Surface an actionable SSH timeout or connection error instead of waiting indefinitely.
- [ ] `2026-07-10T21:36:27Z``codex` — While using the repository's `webapp-testing` skill, its required Python Playwright import was unavailable in both system and bundled Python. The bundled Node Playwright runtime completed the check; document or provide that fallback, including Cloudflare Vite's default port 8787 rather than Vite's usual 5173.
- [ ] `2026-07-10T21:32:10Z``codex` — While formatting the standalone `badseo` workspace, `pnpm exec prettier` failed because Prettier is only available from the repository root. Document the root-only formatter command or expose a workspace-local formatting script.
- [ ] `2026-07-10T21:09:27Z``codex` — While building the TanStack/Cloudflare badseo app, Wrangler reported an EPERM writing its debug log under the user preferences directory even though the build succeeded. Set `WRANGLER_LOG_PATH` to a writable temporary path in sandboxed build commands or make the logging failure non-fatal and quiet.
- [ ] `2026-07-10T17:53:20Z``codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge.
- [ ] `2026-07-10T18:12:35Z``codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops.

View File

@ -39,7 +39,7 @@ Browse them all at `/catalog`.
badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following
the same Vite and Cloudflare setup as the repository's `web/` app.
TanStack React routes render the healthy homepage and catalog.
TanStack React routes render the healthy homepage, catalog, and privacy policy.
A TanStack catch-all server route keeps the deliberate fixtures as raw
responses with byte-level control over status codes, redirects, headers
(`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>`
@ -52,6 +52,18 @@ states the audit needs to observe.
**SEO-neutral**: it emits no `<h1>``<h6>` and no `<img>`.
- `src/fixtures/*.ts` — the fixtures, one file per category.
## Analytics
Plausible Analytics loads on every page using the site-specific script supplied
for badseo.dev. It provides the cookieless aggregate baseline without changing
the Google Analytics consent choice.
Google Analytics uses measurement ID `G-7MXV9FH7SS`. The small consent script in
`public/analytics.js` is shared by TanStack pages and raw fixture documents. It
does not request Google's tag or set analytics cookies until a visitor accepts.
The visitor can reject analytics or revisit the choice from **Cookie settings**
in the footer. The choice is stored only in the visitor's browser.
## Run it locally
```bash
@ -64,7 +76,7 @@ npm run dev # serves on http://localhost:8787
The harness drives the **real** OpenSEO crawl + issue-detection functions
(imported straight from `../src`) against a running badseo.dev, then asserts every
fixture triggers exactly the issues it declares — and that the homepage,
catalog, and support pages come back clean.
catalog, privacy policy, and support pages come back clean.
```bash
# with `npm run dev` running in another terminal:

116
badseo/public/analytics.js Normal file
View File

@ -0,0 +1,116 @@
/* oxlint-disable typescript-eslint/no-unsafe-argument, typescript-eslint/no-unsafe-assignment, typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-member-access -- This standalone browser script defines Google's dynamic dataLayer globals. */
(function () {
"use strict";
var measurementId = "G-7MXV9FH7SS";
var storageKey = "badseo.analyticsConsent";
var granted = "granted";
var denied = "denied";
function readConsent() {
try {
return window.localStorage.getItem(storageKey);
} catch {
return null;
}
}
function writeConsent(value) {
try {
window.localStorage.setItem(storageKey, value);
} catch {
// The choice still applies to this page when storage is unavailable.
}
}
function loadAnalytics() {
if (document.getElementById("ga4-script")) return;
window["ga-disable-" + measurementId] = false;
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("consent", "default", {
analytics_storage: "granted",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
});
window.gtag("js", new Date());
window.gtag("config", measurementId);
var script = document.createElement("script");
script.id = "ga4-script";
script.async = true;
script.src =
"https://www.googletagmanager.com/gtag/js?id=" +
encodeURIComponent(measurementId);
document.head.appendChild(script);
}
function deleteAnalyticsCookies() {
document.cookie.split(";").forEach(function (cookie) {
var name = cookie.split("=")[0].trim();
if (name !== "_ga" && !name.startsWith("_ga_")) return;
document.cookie = name + "=; Max-Age=0; path=/; SameSite=Lax";
if (window.location.hostname.endsWith("badseo.dev")) {
document.cookie =
name + "=; Max-Age=0; path=/; domain=.badseo.dev; SameSite=Lax";
}
});
}
function createBanner() {
var banner = document.createElement("div");
banner.className = "consent-banner";
banner.setAttribute("role", "dialog");
banner.setAttribute("aria-modal", "true");
banner.setAttribute("aria-labelledby", "consent-title");
banner.hidden = true;
banner.innerHTML =
'<div class="consent-copy">' +
'<strong id="consent-title">This website uses cookies</strong>' +
"<p>We use cookies to ensure you get the best experience.</p>" +
'<a href="/privacy">Privacy policy</a>' +
"</div>" +
'<div class="consent-actions">' +
'<button type="button" data-consent="denied">Reject</button>' +
'<button type="button" class="consent-accept" data-consent="granted">Accept</button>' +
"</div>";
document.body.appendChild(banner);
banner.addEventListener("click", function (event) {
var button = event.target.closest("[data-consent]");
if (!button) return;
var previous = readConsent();
var choice = button.getAttribute("data-consent");
writeConsent(choice);
banner.hidden = true;
if (choice === granted) {
loadAnalytics();
return;
}
window["ga-disable-" + measurementId] = true;
deleteAnalyticsCookies();
if (previous === granted) window.location.reload();
});
return banner;
}
var banner = createBanner();
var consent = readConsent();
if (consent === granted) loadAnalytics();
else if (consent !== denied) banner.hidden = false;
document.addEventListener("click", function (event) {
if (!event.target.closest("[data-cookie-settings]")) return;
banner.hidden = false;
banner.querySelector("button").focus();
});
})();

View File

@ -393,6 +393,115 @@ a:hover {
.foot-links {
display: flex;
gap: 18px;
align-items: center;
flex-wrap: wrap;
}
.footer-button {
appearance: none;
border: 0;
padding: 0;
background: transparent;
color: var(--ink-muted);
font: inherit;
cursor: pointer;
}
.footer-button:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
/* ── analytics consent ── */
.consent-banner {
position: fixed;
z-index: 100;
bottom: 24px;
left: 24px;
width: min(350px, calc(100vw - 48px));
padding: 16px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 14px;
box-shadow:
0 20px 50px rgb(17 17 17 / 14%),
0 2px 8px rgb(17 17 17 / 7%);
animation: consent-in 180ms ease-out;
}
.consent-banner[hidden] {
display: none;
}
.consent-copy strong {
display: block;
margin-bottom: 4px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.consent-copy p {
margin: 0 0 7px;
color: var(--ink-muted);
font-size: 13.5px;
line-height: 1.45;
}
.consent-copy a {
color: var(--ink-muted);
font-size: 12.5px;
text-decoration: underline;
text-decoration-color: var(--hairline);
text-underline-offset: 3px;
}
.consent-copy a:hover {
color: var(--ink);
text-decoration-color: var(--ink);
}
.consent-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.consent-actions button {
border: 1px solid var(--hairline);
border-radius: 8px;
min-height: 36px;
padding: 7px 12px;
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.consent-actions button:hover {
background: var(--surface-2);
}
.consent-actions .consent-accept {
border-color: var(--ink);
background: var(--ink);
color: #ffffff;
}
.consent-actions .consent-accept:hover {
background: #000000;
}
@keyframes consent-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.consent-banner {
animation: none;
}
}
@media (max-width: 640px) {
.consent-banner {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
padding: 15px;
}
}
.openseo-badge {

View File

@ -46,7 +46,7 @@ interface CrawlLink {
async function warmup(): Promise<void> {
// Prime the dev server so healthy pages don't read as slow on a cold start.
const paths = new Set<string>(["/", "/catalog"]);
const paths = new Set<string>(["/", "/catalog", "/privacy"]);
for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p);
await Promise.all(
[...paths].map((p) =>
@ -335,6 +335,7 @@ async function main() {
// Non-fixture content pages must be clean.
check("Homepage", "/", [], false);
check("Catalog", "/catalog", [], false);
check("Privacy policy", "/privacy", [], false);
const byCategory = new Map<string, Fixture[]>();
for (const f of allFixtures) {

View File

@ -22,6 +22,14 @@ export function SiteLayout({ children }: { children: ReactNode }) {
<a href="/catalog">Catalog</a>
<a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a>
<a href="/privacy">Privacy</a>
<button
className="footer-button"
type="button"
data-cookie-settings
>
Cookie settings
</button>
</span>
</div>
</footer>

View File

@ -7,6 +7,7 @@
// — not accidental noise from the layout.
import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";
import type { Fixture, IssueId } from "./fixtures/types";
import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "./plausible";
export function escapeHtml(input: string): string {
return input
@ -44,7 +45,13 @@ interface DocumentOptions {
/** Build a complete HTML document string with exact <head> control. */
export function renderDocument(opts: DocumentOptions): string {
const head: string[] = ['<meta charset="utf-8">'];
const head: string[] = [
"<!-- Privacy-friendly analytics by Plausible -->",
`<script async src="${PLAUSIBLE_SCRIPT_SRC}"></script>`,
`<script>${PLAUSIBLE_INIT_SCRIPT}</script>`,
'<script defer src="/analytics.js"></script>',
'<meta charset="utf-8">',
];
head.push(
'<meta name="viewport" content="width=device-width, initial-scale=1">',
);
@ -102,6 +109,8 @@ function footerHtml(): string {
<a href="/catalog">Catalog</a>
<a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a>
<a href="/privacy">Privacy</a>
<button class="footer-button" type="button" data-cookie-settings>Cookie settings</button>
</span>
</div></footer>`;
}

5
badseo/src/plausible.ts Normal file
View File

@ -0,0 +1,5 @@
export const PLAUSIBLE_SCRIPT_SRC =
"https://plausible.io/js/pa-Cr442m-9lp6sEF3_GhWNS.js";
export const PLAUSIBLE_INIT_SCRIPT =
"window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init();";

View File

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as CatalogRouteImport } from './routes/catalog'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IndexRouteImport } from './routes/index'
@ -25,6 +26,11 @@ const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
path: '/robots.txt',
getParentRoute: () => rootRouteImport,
} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
const CatalogRoute = CatalogRouteImport.update({
id: '/catalog',
path: '/catalog',
@ -45,6 +51,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/privacy': typeof PrivacyRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
@ -52,6 +59,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/privacy': typeof PrivacyRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
@ -60,21 +68,31 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/privacy': typeof PrivacyRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
fullPaths:
'/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
id: '__root__' | '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
to: '/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml'
id:
| '__root__'
| '/'
| '/$'
| '/catalog'
| '/privacy'
| '/robots.txt'
| '/sitemap.xml'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SplatRoute: typeof SplatRoute
CatalogRoute: typeof CatalogRoute
PrivacyRoute: typeof PrivacyRoute
RobotsDottxtRoute: typeof RobotsDottxtRoute
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
}
@ -95,6 +113,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RobotsDottxtRouteImport
parentRoute: typeof rootRouteImport
}
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/catalog': {
id: '/catalog'
path: '/catalog'
@ -123,6 +148,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SplatRoute: SplatRoute,
CatalogRoute: CatalogRoute,
PrivacyRoute: PrivacyRoute,
RobotsDottxtRoute: RobotsDottxtRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
}

View File

@ -4,6 +4,7 @@ import {
Outlet,
Scripts,
} from "@tanstack/react-router";
import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "../plausible";
export const Route = createRootRoute({
head: () => ({
@ -27,6 +28,10 @@ export const Route = createRootRoute({
},
{ rel: "stylesheet", href: "/styles.css" },
],
scripts: [
{ async: true, src: PLAUSIBLE_SCRIPT_SRC },
{ defer: true, src: "/analytics.js" },
],
}),
component: RootComponent,
});
@ -36,6 +41,7 @@ function RootComponent() {
<html lang="en">
<head>
<HeadContent />
<script dangerouslySetInnerHTML={{ __html: PLAUSIBLE_INIT_SCRIPT }} />
</head>
<body>
<Outlet />

View File

@ -0,0 +1,120 @@
import { createFileRoute } from "@tanstack/react-router";
import { SiteLayout } from "../components/site-layout";
export const Route = createFileRoute("/privacy")({
head: () => ({
meta: [
{ title: "Privacy policy | badseo.dev" },
{
name: "description",
content:
"How badseo.dev uses Plausible, Google Analytics, and Cloudflare and how visitors control analytics cookies.",
},
],
}),
component: PrivacyPage,
});
function PrivacyPage() {
return (
<SiteLayout>
<main className="main">
<h1>Privacy policy</h1>
<p className="lede">
This policy explains the limited information processed when you visit
badseo.dev. Last updated July 10, 2026.
</p>
<h2>Who operates this site</h2>
<p>
badseo.dev is operated by Every App, Inc. as a public test site for
OpenSEO. The site has no accounts, forms, purchases, or user-submitted
content. Privacy questions and requests can be sent to{" "}
<a href="mailto:ben@openseo.so">ben@openseo.so</a>.
</p>
<h2>Plausible Analytics</h2>
<p>
We use Plausible Analytics on every page to understand aggregate
traffic and which technical SEO examples people use. Plausible does
not set cookies or create a persistent identifier for you. It provides
aggregate measurements such as page views, referring sites, browser
and device categories, and country-level location.
</p>
<p>
Plausible is provided by Plausible Analytics . Learn more in the{" "}
<a href="https://plausible.io/data-policy">
Plausible Analytics data policy
</a>
.
</p>
<h2>Google Analytics</h2>
<p>
Separately, with your permission, we use Google Analytics 4 to
understand traffic in the analytics product many OpenSEO users use.
The Google tag does not load until you select <strong>Accept</strong>
in the analytics banner.
</p>
<p>
Google Analytics may process the page address and title, referring
page, interactions such as page views, scrolls, and outbound clicks,
browser and device information, approximate location derived from your
IP address, and randomly generated identifiers. It may set first-party
cookies including <code>_ga</code> and{" "}
<code>_ga_&lt;container-id&gt;</code> to distinguish visitors and
sessions.
</p>
<p>
If you reject analytics, no Google Analytics request is made. Your
choice is stored in your browser&apos;s local storage so the site can
remember it. You can change your choice at any time using{" "}
<strong>Cookie settings</strong> in the footer. Rejecting after a
previous acceptance disables analytics and removes accessible Google
Analytics cookies from this site. This choice controls Google
Analytics; the cookieless Plausible measurement described above
remains active.
</p>
<p>
Learn more about{" "}
<a href="https://policies.google.com/technologies/partner-sites">
how Google uses information from sites that use its services
</a>{" "}
and{" "}
<a href="https://policies.google.com/privacy">
Google&apos;s privacy practices
</a>
.
</p>
<h2>Cloudflare</h2>
<p>
Cloudflare hosts, delivers, and protects badseo.dev. It receives
ordinary request information such as your IP address, request headers,
requested URL, and time of access to provide the site, prevent abuse,
and diagnose failures. We do not create a separate visitor access-log
database. Learn more in{" "}
<a href="https://www.cloudflare.com/privacypolicy/">
Cloudflare&apos;s privacy policy
</a>
.
</p>
<h2>International processing and your rights</h2>
<p>
Google, Plausible, and Cloudflare may process information in the
United States, the European Economic Area, and other countries.
Depending on where you live, you may have rights to ask about, access,
correct, delete, restrict, or object to certain processing of your
information. Contact us to make a request. You may also complain to
the privacy or data-protection authority where you live.
</p>
<h2>Changes</h2>
<p>
We will update the date above when this policy changes materially.
</p>
</main>
</SiteLayout>
);
}

View File

@ -79,7 +79,7 @@ Sitemap: ${origin}/sitemap.xml
export function sitemapResponse(request: Request): Response {
const url = new URL(request.url);
const origin = requestOrigin(request, url);
const paths = new Set<string>(["/"]);
const paths = new Set<string>(["/", "/privacy"]);
for (const fixture of sitemapFixtures) {
for (const path of fixturePaths(fixture)) paths.add(path);
}