From 611d9da6588d6796059526572a911da4b2bb03f0 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 15 Aug 2026 04:54:40 +0530 Subject: [PATCH 1/2] test(seo): add sitemap/canonical parity regression check Closes #47 Issue #47: the live sitemap listed /faq and /login with the homepage canonical. The runtime fixes (self-canonical for /faq, /login removed from the sitemap, noindex on /login) shipped in #48. This adds the missing regression guard so the parity cannot silently regress: - For every URL in public/sitemap.xml, resolve its landing-astro source page and assert it declares a self-canonical (canonicalPath == its own pathname). The homepage keeps the Layout default '/'. - Assert /login never re-enters the sitemap. - Reuses the existing sitemapUrls() helper and AGENT_SURFACE catalog. --- src/agent-surfaces.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) 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); + } + } + }); }); From 1f450c467c03c056a03676a7a94656a09309a123 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 15 Aug 2026 23:36:17 +0530 Subject: [PATCH 2/2] feat: add page_view event to complete 5-event taxonomy Add page_view as the 5th event alongside signup, activated, core_action, and returned. Wire trackPageView() into RootLayout on mount and route changes. Part of fleet-workspace#348 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/RootLayout.tsx | 11 ++++++++++- src/lib/analytics.ts | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) 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/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.