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.