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:
parent
61c0b0c657
commit
987e5494a2
@ -6,6 +6,10 @@ This is not a completed-work log or a bug tracker. Never include secrets, creden
|
|||||||
|
|
||||||
## Open
|
## 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-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-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.
|
- [ ] `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.
|
||||||
|
|||||||
@ -39,7 +39,7 @@ Browse them all at `/catalog`.
|
|||||||
badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following
|
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.
|
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
|
A TanStack catch-all server route keeps the deliberate fixtures as raw
|
||||||
responses with byte-level control over status codes, redirects, headers
|
responses with byte-level control over status codes, redirects, headers
|
||||||
(`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>`
|
(`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>`.
|
**SEO-neutral**: it emits no `<h1>`–`<h6>` and no `<img>`.
|
||||||
- `src/fixtures/*.ts` — the fixtures, one file per category.
|
- `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
|
## Run it locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -64,7 +76,7 @@ npm run dev # serves on http://localhost:8787
|
|||||||
The harness drives the **real** OpenSEO crawl + issue-detection functions
|
The harness drives the **real** OpenSEO crawl + issue-detection functions
|
||||||
(imported straight from `../src`) against a running badseo.dev, then asserts every
|
(imported straight from `../src`) against a running badseo.dev, then asserts every
|
||||||
fixture triggers exactly the issues it declares — and that the homepage,
|
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
|
```bash
|
||||||
# with `npm run dev` running in another terminal:
|
# with `npm run dev` running in another terminal:
|
||||||
|
|||||||
116
badseo/public/analytics.js
Normal file
116
badseo/public/analytics.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -393,6 +393,115 @@ a:hover {
|
|||||||
.foot-links {
|
.foot-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 18px;
|
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 {
|
.openseo-badge {
|
||||||
|
|||||||
@ -46,7 +46,7 @@ interface CrawlLink {
|
|||||||
|
|
||||||
async function warmup(): Promise<void> {
|
async function warmup(): Promise<void> {
|
||||||
// Prime the dev server so healthy pages don't read as slow on a cold start.
|
// 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);
|
for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...paths].map((p) =>
|
[...paths].map((p) =>
|
||||||
@ -335,6 +335,7 @@ async function main() {
|
|||||||
// Non-fixture content pages must be clean.
|
// Non-fixture content pages must be clean.
|
||||||
check("Homepage", "/", [], false);
|
check("Homepage", "/", [], false);
|
||||||
check("Catalog", "/catalog", [], false);
|
check("Catalog", "/catalog", [], false);
|
||||||
|
check("Privacy policy", "/privacy", [], false);
|
||||||
|
|
||||||
const byCategory = new Map<string, Fixture[]>();
|
const byCategory = new Map<string, Fixture[]>();
|
||||||
for (const f of allFixtures) {
|
for (const f of allFixtures) {
|
||||||
|
|||||||
@ -22,6 +22,14 @@ export function SiteLayout({ children }: { children: ReactNode }) {
|
|||||||
<a href="/catalog">Catalog</a>
|
<a href="/catalog">Catalog</a>
|
||||||
<a href="https://github.com/every-app/open-seo">GitHub</a>
|
<a href="https://github.com/every-app/open-seo">GitHub</a>
|
||||||
<a href="https://openseo.so">OpenSEO</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
// — not accidental noise from the layout.
|
// — not accidental noise from the layout.
|
||||||
import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";
|
import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";
|
||||||
import type { Fixture, IssueId } from "./fixtures/types";
|
import type { Fixture, IssueId } from "./fixtures/types";
|
||||||
|
import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "./plausible";
|
||||||
|
|
||||||
export function escapeHtml(input: string): string {
|
export function escapeHtml(input: string): string {
|
||||||
return input
|
return input
|
||||||
@ -44,7 +45,13 @@ interface DocumentOptions {
|
|||||||
|
|
||||||
/** Build a complete HTML document string with exact <head> control. */
|
/** Build a complete HTML document string with exact <head> control. */
|
||||||
export function renderDocument(opts: DocumentOptions): string {
|
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(
|
head.push(
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||||
);
|
);
|
||||||
@ -102,6 +109,8 @@ function footerHtml(): string {
|
|||||||
<a href="/catalog">Catalog</a>
|
<a href="/catalog">Catalog</a>
|
||||||
<a href="https://github.com/every-app/open-seo">GitHub</a>
|
<a href="https://github.com/every-app/open-seo">GitHub</a>
|
||||||
<a href="https://openseo.so">OpenSEO</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>
|
</span>
|
||||||
</div></footer>`;
|
</div></footer>`;
|
||||||
}
|
}
|
||||||
|
|||||||
5
badseo/src/plausible.ts
Normal file
5
badseo/src/plausible.ts
Normal 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();";
|
||||||
@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
||||||
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
|
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 CatalogRouteImport } from './routes/catalog'
|
||||||
import { Route as SplatRouteImport } from './routes/$'
|
import { Route as SplatRouteImport } from './routes/$'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
@ -25,6 +26,11 @@ const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
|
|||||||
path: '/robots.txt',
|
path: '/robots.txt',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PrivacyRoute = PrivacyRouteImport.update({
|
||||||
|
id: '/privacy',
|
||||||
|
path: '/privacy',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const CatalogRoute = CatalogRouteImport.update({
|
const CatalogRoute = CatalogRouteImport.update({
|
||||||
id: '/catalog',
|
id: '/catalog',
|
||||||
path: '/catalog',
|
path: '/catalog',
|
||||||
@ -45,6 +51,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/catalog': typeof CatalogRoute
|
'/catalog': typeof CatalogRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
'/robots.txt': typeof RobotsDottxtRoute
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
}
|
}
|
||||||
@ -52,6 +59,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/catalog': typeof CatalogRoute
|
'/catalog': typeof CatalogRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
'/robots.txt': typeof RobotsDottxtRoute
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
}
|
}
|
||||||
@ -60,21 +68,31 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/catalog': typeof CatalogRoute
|
'/catalog': typeof CatalogRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
'/robots.txt': typeof RobotsDottxtRoute
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
|
fullPaths:
|
||||||
|
'/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
|
to: '/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml'
|
||||||
id: '__root__' | '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/$'
|
||||||
|
| '/catalog'
|
||||||
|
| '/privacy'
|
||||||
|
| '/robots.txt'
|
||||||
|
| '/sitemap.xml'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
SplatRoute: typeof SplatRoute
|
SplatRoute: typeof SplatRoute
|
||||||
CatalogRoute: typeof CatalogRoute
|
CatalogRoute: typeof CatalogRoute
|
||||||
|
PrivacyRoute: typeof PrivacyRoute
|
||||||
RobotsDottxtRoute: typeof RobotsDottxtRoute
|
RobotsDottxtRoute: typeof RobotsDottxtRoute
|
||||||
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
|
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
|
||||||
}
|
}
|
||||||
@ -95,6 +113,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof RobotsDottxtRouteImport
|
preLoaderRoute: typeof RobotsDottxtRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/privacy': {
|
||||||
|
id: '/privacy'
|
||||||
|
path: '/privacy'
|
||||||
|
fullPath: '/privacy'
|
||||||
|
preLoaderRoute: typeof PrivacyRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/catalog': {
|
'/catalog': {
|
||||||
id: '/catalog'
|
id: '/catalog'
|
||||||
path: '/catalog'
|
path: '/catalog'
|
||||||
@ -123,6 +148,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
SplatRoute: SplatRoute,
|
SplatRoute: SplatRoute,
|
||||||
CatalogRoute: CatalogRoute,
|
CatalogRoute: CatalogRoute,
|
||||||
|
PrivacyRoute: PrivacyRoute,
|
||||||
RobotsDottxtRoute: RobotsDottxtRoute,
|
RobotsDottxtRoute: RobotsDottxtRoute,
|
||||||
SitemapDotxmlRoute: SitemapDotxmlRoute,
|
SitemapDotxmlRoute: SitemapDotxmlRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
|
import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "../plausible";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@ -27,6 +28,10 @@ export const Route = createRootRoute({
|
|||||||
},
|
},
|
||||||
{ rel: "stylesheet", href: "/styles.css" },
|
{ rel: "stylesheet", href: "/styles.css" },
|
||||||
],
|
],
|
||||||
|
scripts: [
|
||||||
|
{ async: true, src: PLAUSIBLE_SCRIPT_SRC },
|
||||||
|
{ defer: true, src: "/analytics.js" },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
});
|
});
|
||||||
@ -36,6 +41,7 @@ function RootComponent() {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: PLAUSIBLE_INIT_SCRIPT }} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
120
badseo/src/routes/privacy.tsx
Normal file
120
badseo/src/routes/privacy.tsx
Normal 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 OÜ. 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_<container-id></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'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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -79,7 +79,7 @@ Sitemap: ${origin}/sitemap.xml
|
|||||||
export function sitemapResponse(request: Request): Response {
|
export function sitemapResponse(request: Request): Response {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = requestOrigin(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 fixture of sitemapFixtures) {
|
||||||
for (const path of fixturePaths(fixture)) paths.add(path);
|
for (const path of fixturePaths(fixture)) paths.add(path);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user