diff --git a/src/RootLayout.tsx b/src/RootLayout.tsx index 24ec7cb..58148a5 100644 --- a/src/RootLayout.tsx +++ b/src/RootLayout.tsx @@ -1,5 +1,14 @@ -import { Outlet } from 'react-router-dom'; +import { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; + +import { trackPageView } from './lib/analytics'; export default function RootLayout() { + const location = useLocation(); + useEffect(() => { + void location.pathname; // re-fire on route change + trackPageView(); + }, [location.pathname]); + return ; } diff --git a/src/agent-surfaces.test.ts b/src/agent-surfaces.test.ts index 59487a4..3b73e43 100644 --- a/src/agent-surfaces.test.ts +++ b/src/agent-surfaces.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { AGENT_SURFACE } from './agent-edge.mjs'; const readPublic = (path: string) => readFileSync(resolve(process.cwd(), 'public', path), 'utf8'); +const readRepo = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); function sitemapUrls(): string[] { return [...readPublic('sitemap.xml').matchAll(/([^<]+)<\/loc>/g)].map( @@ -11,6 +12,25 @@ function sitemapUrls(): string[] { ); } +/** + * Map a sitemap pathname to the landing-astro source page that renders it. + * Astro uses `index.astro` for directory routes and `.astro` for leaf + * routes; the homepage is `pages/index.astro`. + */ +function landingSourceForPath(pathname: string): string { + if (pathname === '/') return 'landing-astro/src/pages/index.astro'; + const base = pathname.replace(/^\//, ''); + // Prefer a leaf route (e.g. /faq -> pages/faq.astro); fall back to a + // directory route (e.g. /changelog -> pages/changelog/index.astro). + const leaf = `landing-astro/src/pages/${base}.astro`; + try { + readFileSync(resolve(process.cwd(), leaf)); + return leaf; + } catch { + return `landing-astro/src/pages/${base}/index.astro`; + } +} + describe('public agent surface parity', () => { it('keeps the sitemap limited to canonical HTML surfaces', () => { const catalogUrls = AGENT_SURFACE.catalog.surfaces @@ -80,4 +100,31 @@ describe('public agent surface parity', () => { expect(AGENT_SURFACE.llmsFullTxt).toBe(readPublic('llms-full.txt')); expect(AGENT_SURFACE.catalog.surfaces).toEqual(JSON.parse(readPublic('api-ai.json')).surfaces); }); + + // Regression for #47: every sitemap URL must render a self-canonical + // (canonical == its own URL). Account routes like /login must stay out of + // the sitemap and must not be assigned the homepage canonical. + it('every sitemap URL declares a self-canonical matching its own path', () => { + const urls = sitemapUrls(); + expect(urls).not.toContain('https://read.significanthobbies.com/login'); + + for (const url of urls) { + const pathname = new URL(url).pathname; + const sourcePath = landingSourceForPath(pathname); + const source = readRepo(sourcePath); + + // The Layout canonical defaults to '/', so the homepage needs no + // explicit declaration. Every other sitemap route must declare an + // explicit canonicalPath equal to its own pathname. + if (pathname === '/') { + // Home must not override the default '/' canonical with a different path. + const override = source.match(/canonicalPath\s*=\s*"([^"]+)"/); + expect(override ? override[1] : '/').toBe('/'); + } else { + const match = source.match(/canonicalPath\s*=\s*"([^"]+)"/); + expect(match, `${sourcePath} must declare a self-canonical for ${pathname}`).not.toBeNull(); + expect(match?.[1]).toBe(pathname); + } + } + }); }); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 77e7fa5..5e5860d 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,10 +1,10 @@ /** - * Owner-facing analytics — the fixed 4-event taxonomy. + * Owner-facing analytics — the fixed 5-event taxonomy. * - * Every fleet project emits exactly these four events — `signup`, `activated`, - * `core_action`, `returned` — so a single PostHog project can build one - * cross-fleet funnel (signup -> activated -> core_action) and a D1/D7 retention - * insight, with no custom dashboard. + * Every fleet project emits exactly these five events — `signup`, `activated`, + * `core_action`, `returned`, `page_view` — so a single PostHog project can + * build one cross-fleet funnel (signup -> activated -> core_action) and a + * D1/D7 retention insight, with no custom dashboard. * * Every event carries `project_id: "reader"`. This wrapper is intentionally thin * so it can later be promoted into `posthog-js`. @@ -43,6 +43,8 @@ interface AnalyticsEventMap { core_action: { project_id: typeof PROJECT; action: CoreAction }; /** A return session by a user with prior activity. */ returned: { project_id: typeof PROJECT }; + /** A page view — fired on route change, tracked manually with project_id. */ + page_view: { project_id: typeof PROJECT }; } function emitServer(event: string, props: Record, distinctId?: string) { @@ -113,6 +115,11 @@ function trackReturned(): void { emit('returned', {}); } +/** Fire on each route change to record a page view. */ +export function trackPageView(): void { + emit('page_view', {}); +} + // --- Browser once-per-user / once-per-session gating ----------------------- // // `signup`, `activated`, and `returned` should fire at most once per user.