From cb6346d092a793487ea5df4ea7559d1bb357823a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:13:36 +0800 Subject: [PATCH] Add generated sitemap audit --- docs/process-hardening.md | 6 + docs/site-map.md | 163 +++++++++++ package.json | 4 +- scripts/generate-site-map.ts | 350 ++++++++++++++++++++++++ src/app/mockups/favourites-hub/page.tsx | 2 +- tests/site-map.test.ts | 96 +++++++ 6 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 docs/site-map.md create mode 100644 scripts/generate-site-map.ts create mode 100644 tests/site-map.test.ts diff --git a/docs/process-hardening.md b/docs/process-hardening.md index adc7b8eeb..58fdb831b 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -73,6 +73,12 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons - `npm run check:indexing` includes local OCR prerequisites (`fitz`/PyMuPDF, `pytesseract`, and the Tesseract binary). A failure at that prerequisite step is local machine setup debt, not evidence that indexed production data or search behavior regressed. - Supabase performance-advisor `unused_index` INFO items are monitored, not automatically fixed. Do not remove search/RAG support indexes until live query evidence, local explain/verification, and rollback planning show the index is safe to drop. +## Route sitemap guard (2026-07-03) + +- Route, navigation, redirect, app-mode, registry-slug, and mockup-route changes must run `npm run sitemap:update` and `npm run sitemap:check` so `docs/site-map.md` stays aligned with `src/app`, `src/lib/app-modes.ts`, Services/Forms registry fixtures, Differentials, and medication detail routes. +- `npm run verify:cheap` now includes `npm run sitemap:check`; a stale sitemap is treated as process drift, not a documentation nicety. +- Keep `docs/site-map.md` as the human-readable route map for now. If it becomes too large for review, split into a concise `docs/site-map.md` summary plus a generated `docs/site-map.generated.md` inventory, and update `scripts/generate-site-map.ts` / `tests/site-map.test.ts` in the same change. + ## Retrieval RPC drift & indexing hygiene (2026-07-01) - The four app-path hybrid retrieval RPCs (`match_document_chunks_hybrid`, `match_document_embedding_fields_hybrid`, `match_document_index_units_hybrid`, `match_document_memory_cards_hybrid` + its `_v2` core) had live-only performance fixes applied via raw SQL that were never captured in migrations, so a `supabase db reset` / branch DB reproduced the slow pre-fix shapes. Migration `20260701140631_codify_live_retrieval_rpcs` codifies the live definitions (validated byte-equivalent to live via whitespace-stripped `pg_get_functiondef` md5 before applying — a confirmed no-op on live), and `supabase/schema.sql` was reconciled to match. A clean replay now reproduces production retrieval. diff --git a/docs/site-map.md b/docs/site-map.md new file mode 100644 index 000000000..ad1cd4c3c --- /dev/null +++ b/docs/site-map.md @@ -0,0 +1,163 @@ +# Clinical KB Site Map + +This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. + +## Main product pages + +- `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. +- `/applications` - Application and tool launcher. Source: `src/app/applications/page.tsx`. +- `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. +- `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. +- `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. +- `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. +- `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. +- `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. + +## Mode/query routes + +- `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. +- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/?mode=documents&q=lithium+monitoring&focus=1&run=1`. +- `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. +- `/forms` - Forms mode. Search kind: `documents`. Query example: `/forms?q=transport+forms&focus=1&run=1`. +- `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. +- `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. +- `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. + +## Registry-backed routes + +- `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/forms/[slug]` - Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/api/registry/records?kind=service` - Service registry collection endpoint. +- `/api/registry/records?kind=form` - Form registry collection endpoint. +- `/api/registry/records/[slug]?kind=service|form` - Registry detail endpoint. + +## Dynamic slug inventories + +### Seeded service slugs + +- `/services/[slug]` - Dynamic route family. +- `13yarn` +- `head-to-health` +- `mental-health-emergency-response-line` +- `rurallink` +- `wachs-aboriginal-mental-health` + +### Seeded form slugs + +- `/forms/[slug]` - Dynamic route family. +- `detention-examination-movement` +- `extension-transport-order` +- `transfer-order` +- `transport-crisis-form` + +### Differential diagnosis slugs + +- `/differentials/diagnoses/[slug]` - Dynamic route family. +- `delirium` +- `hepatic-encephalopathy` +- `meningitis-encephalitis` +- `pneumonia` +- `post-ictal-confusion` +- `substance-intoxication` +- `substance-withdrawal` +- `thyroid-disease` +- `wernicke-encephalopathy` + +### Medication slugs + +- `/medications/[slug]` - Dynamic route family. +- `acamprosate` + +## Document viewer route + +- `/documents/[id]` - Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap. + +## Mockup/prototype routes + +- `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. +- `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. +- `/mockups/document-search-command` - Route discovered from app directory Source: `src/app/mockups/document-search-command/page.tsx`. +- `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. +- `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. +- `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. +- `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. +- `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. +- `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. +- `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. +- `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. +- `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. +- `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. +- `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. +- `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. +- `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. + +### Non-routed mockup artifacts + +- `mockups/answer-evidence-popups/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. +- `mockups/medication-prescribing/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. + +## API routes + +- `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. +- `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`. +- `/api/documents` - Document collection operations. Source: `src/app/api/documents/route.ts`. +- `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`. +- `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`. +- `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`. +- `/api/documents/[id]/search` - Search within one document. Source: `src/app/api/documents/[id]/search/route.ts`. +- `/api/documents/[id]/signed-url` - Private document signed URL. Source: `src/app/api/documents/[id]/signed-url/route.ts`. +- `/api/documents/[id]/summarize` - Document summary operation. Source: `src/app/api/documents/[id]/summarize/route.ts`. +- `/api/documents/[id]/table-facts` - Document table facts. Source: `src/app/api/documents/[id]/table-facts/route.ts`. +- `/api/documents/bulk` - Bulk document operations. Source: `src/app/api/documents/bulk/route.ts`. +- `/api/documents/bulk/reindex` - Bulk reindex operation. Source: `src/app/api/documents/bulk/reindex/route.ts`. +- `/api/eval-cases` - Evaluation case data. Source: `src/app/api/eval-cases/route.ts`. +- `/api/health` - Health check. Source: `src/app/api/health/route.ts`. +- `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`. +- `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`. +- `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`. +- `/api/ingestion/jobs/[id]/retry` - Retry ingestion job. Source: `src/app/api/ingestion/jobs/[id]/retry/route.ts`. +- `/api/ingestion/quality` - Ingestion quality reporting. Source: `src/app/api/ingestion/quality/route.ts`. +- `/api/jobs` - Job state. Source: `src/app/api/jobs/route.ts`. +- `/api/local-project-id` - Local project identity guard. Source: `src/app/api/local-project-id/route.ts`. +- `/api/registry/records` - Registry record collection. Source: `src/app/api/registry/records/route.ts`. +- `/api/registry/records/[slug]` - Registry record detail. Source: `src/app/api/registry/records/[slug]/route.ts`. +- `/api/search` - Search endpoint. Source: `src/app/api/search/route.ts`. +- `/api/search/interaction` - Search interaction telemetry. Source: `src/app/api/search/interaction/route.ts`. +- `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. +- `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. + +## Redirects + +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. +- `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. + +## Known caveats and stale-path flags + +- No active stale internal route targets are expected in the current generated sitemap. +- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`. +- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records. +- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here. +- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data. +- Several differential records are placeholder scaffolds pending source-backed local clinical content. + +## Route ownership/source map + +| Area | Source | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| Root dashboard and query modes | `src/app/page.tsx, src/lib/app-modes.ts` | +| Global shell layouts | `src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx` | +| Services | `src/app/services, src/lib/services.ts, src/app/api/registry/records` | +| Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | +| Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx` | +| Differentials | `src/app/differentials, src/lib/differentials.ts` | +| Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | +| Documents | `src/app/documents/[id], src/app/api/documents` | +| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | +| Mockups | `src/app/mockups, mockups/` | diff --git a/package.json b/package.json index 94f3e2566..757979240 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", - "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", + "verify:cheap": "npm run check:runtime && npm run sitemap:check && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "ci:env-check": "node scripts/check-ci-env.mjs", + "sitemap:update": "tsx scripts/generate-site-map.ts", + "sitemap:check": "tsx scripts/generate-site-map.ts --check", "check:runtime": "tsx scripts/check-runtime.ts", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", "check:edge:functions": "node scripts/check-edge-functions.mjs", diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts new file mode 100644 index 000000000..6ffa12b24 --- /dev/null +++ b/scripts/generate-site-map.ts @@ -0,0 +1,350 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { format } from "prettier"; + +import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; + +const appDir = path.join(process.cwd(), "src", "app"); +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const medicationSlugs = ["acamprosate"] as const; + +type RouteKind = "page" | "api"; + +type DiscoveredRoute = { + route: string; + file: string; +}; + +type RedirectRoute = { + route: string; + file: string; + target: string; +}; + +type SiteMapData = { + pageRoutes: DiscoveredRoute[]; + apiRoutes: DiscoveredRoute[]; + redirects: RedirectRoute[]; + nonRoutedMockupArtifacts: string[]; +}; + +const routeDescriptions: Record = { + "/": "Main Clinical KB shell.", + "/applications": "Application and tool launcher.", + "/differentials": "Differentials home and search surface.", + "/differentials/diagnoses": "Diagnosis stream.", + "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", + "/differentials/presentations": "Presentation workflow stream.", + "/documents/[id]": "Document viewer/detail page.", + "/favourites": "Saved clinical items and sets.", + "/forms": "Forms home and search surface.", + "/forms/[slug]": "Registry-backed form detail.", + "/medications": "Medication index redirect.", + "/medications/[slug]": "Medication detail.", + "/services": "Services home and search surface.", + "/services/[slug]": "Registry-backed service detail.", +}; + +const apiDescriptions: Record = { + "/api/answer": "Generate answer response.", + "/api/answer/stream": "Streaming answer response.", + "/api/documents": "Document collection operations.", + "/api/documents/[id]": "Document detail operations.", + "/api/documents/[id]/labels": "Document label operations.", + "/api/documents/[id]/reindex": "Single-document reindex operation.", + "/api/documents/[id]/search": "Search within one document.", + "/api/documents/[id]/signed-url": "Private document signed URL.", + "/api/documents/[id]/summarize": "Document summary operation.", + "/api/documents/[id]/table-facts": "Document table facts.", + "/api/documents/bulk": "Bulk document operations.", + "/api/documents/bulk/reindex": "Bulk reindex operation.", + "/api/eval-cases": "Evaluation case data.", + "/api/health": "Health check.", + "/api/images/[id]/signed-url": "Private image signed URL.", + "/api/ingestion/batches": "Ingestion batch state.", + "/api/ingestion/jobs": "Ingestion job collection.", + "/api/ingestion/jobs/[id]/retry": "Retry ingestion job.", + "/api/ingestion/quality": "Ingestion quality reporting.", + "/api/jobs": "Job state.", + "/api/local-project-id": "Local project identity guard.", + "/api/registry/records": "Registry record collection.", + "/api/registry/records/[slug]": "Registry record detail.", + "/api/search": "Search endpoint.", + "/api/search/interaction": "Search interaction telemetry.", + "/api/setup-status": "Setup status.", + "/api/upload": "Upload endpoint.", +}; + +const routeOwnershipRows = [ + ["Root dashboard and query modes", "src/app/page.tsx, src/lib/app-modes.ts"], + ["Global shell layouts", "src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx"], + ["Services", "src/app/services, src/lib/services.ts, src/app/api/registry/records"], + ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], + ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx"], + ["Differentials", "src/app/differentials, src/lib/differentials.ts"], + ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], + ["Documents", "src/app/documents/[id], src/app/api/documents"], + ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], + ["Mockups", "src/app/mockups, mockups/"], +] as const; + +function toPosixPath(value: string) { + return value.split(path.sep).join("/"); +} + +function routeSegment(segment: string) { + if (segment.startsWith("(") && segment.endsWith(")")) return null; + if (segment.startsWith("@")) return null; + return segment; +} + +function fileToRoute(filePath: string, kind: RouteKind) { + const suffix = kind === "page" ? "page.tsx" : "route.ts"; + const relative = toPosixPath(path.relative(appDir, filePath)); + const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); + const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); + return segments.length ? `/${segments.join("/")}` : "/"; +} + +function collectFiles(root: string, targetFileName: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath, targetFileName)); + continue; + } + if (entry.isFile() && entry.name === targetFileName) files.push(fullPath); + } + return files; +} + +function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { + const targetFile = kind === "page" ? "page.tsx" : "route.ts"; + return collectFiles(appDir, targetFile) + .map((file) => ({ + route: fileToRoute(file, kind), + file: toPosixPath(path.relative(process.cwd(), file)), + })) + .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); +} + +function discoverRedirects(pageRoutes: DiscoveredRoute[]): RedirectRoute[] { + return pageRoutes + .map((page) => { + const source = readFileSync(path.join(process.cwd(), page.file), "utf8"); + const target = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + return target ? { ...page, target } : null; + }) + .filter((value): value is RedirectRoute => Boolean(value)) + .sort((left, right) => left.route.localeCompare(right.route)); +} + +function discoverNonRoutedMockupArtifacts() { + const mockupsDir = path.join(process.cwd(), "mockups"); + if (!existsSync(mockupsDir)) return []; + return collectFiles(mockupsDir, "page.tsx") + .map((file) => toPosixPath(path.relative(process.cwd(), file))) + .sort((left, right) => left.localeCompare(right)); +} + +export function collectSiteMapData(): SiteMapData { + const pageRoutes = discoverRoutes("page"); + return { + pageRoutes, + apiRoutes: discoverRoutes("api"), + redirects: discoverRedirects(pageRoutes), + nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), + }; +} + +function bullet(route: string, description?: string) { + return `- \`${route}\`${description ? ` - ${description}` : ""}`; +} + +function routeLine(route: DiscoveredRoute, descriptionMap: Record) { + return bullet( + route.route, + `${descriptionMap[route.route] ?? "Route discovered from app directory"} Source: \`${route.file}\`.`, + ); +} + +function sortedSlugs(slugs: readonly string[]) { + return [...slugs].sort((left, right) => left.localeCompare(right)); +} + +function renderSlugInventory(title: string, routePattern: string, slugs: readonly string[]) { + return [ + `### ${title}`, + "", + bullet(routePattern, "Dynamic route family."), + ...sortedSlugs(slugs).map((slug) => `- \`${slug}\``), + ]; +} + +function renderModeRoutes() { + const examples: Record = { + answer: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + documents: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + services: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + }; + + return appModeDefinitions.map((mode) => + bullet( + ("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id), + `${mode.label} mode. Search kind: \`${mode.search.kind}\`. Query example: \`${examples[mode.id]}\`.`, + ), + ); +} + +function section(title: string, lines: string[]) { + return [`## ${title}`, "", ...lines, ""]; +} + +function renderSiteMapRaw(data = collectSiteMapData()) { + const productRoutes = data.pageRoutes.filter( + (route) => + !route.route.startsWith("/api") && + !route.route.startsWith("/mockups") && + ![ + "/documents/[id]", + "/services/[slug]", + "/forms/[slug]", + "/differentials/diagnoses/[slug]", + "/medications/[slug]", + ].includes(route.route), + ); + const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); + + const lines = [ + "# Clinical KB Site Map", + "", + "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", + "", + ...section( + "Main product pages", + productRoutes.map((route) => routeLine(route, routeDescriptions)), + ), + ...section("Mode/query routes", renderModeRoutes()), + ...section("Registry-backed routes", [ + bullet( + "/services/[slug]", + "Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet( + "/forms/[slug]", + "Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet("/api/registry/records?kind=service", "Service registry collection endpoint."), + bullet("/api/registry/records?kind=form", "Form registry collection endpoint."), + bullet("/api/registry/records/[slug]?kind=service|form", "Registry detail endpoint."), + ]), + ...section("Dynamic slug inventories", [ + ...renderSlugInventory( + "Seeded service slugs", + "/services/[slug]", + serviceRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Seeded form slugs", + "/forms/[slug]", + formRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Differential diagnosis slugs", + "/differentials/diagnoses/[slug]", + differentialRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), + ]), + ...section("Document viewer route", [ + bullet( + "/documents/[id]", + "Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap.", + ), + ]), + ...section("Mockup/prototype routes", [ + ...mockupRoutes.map((route) => routeLine(route, routeDescriptions)), + ...(data.nonRoutedMockupArtifacts.length + ? [ + "", + "### Non-routed mockup artifacts", + "", + ...data.nonRoutedMockupArtifacts.map((file) => + bullet(file, "Root-level mockup artifact outside `src/app`; not a Next route."), + ), + ] + : []), + ]), + ...section( + "API routes", + data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), + ), + ...section( + "Redirects", + data.redirects.length + ? data.redirects.map((redirect) => + bullet(redirect.route, `Redirects to \`${redirect.target}\`. Source: \`${redirect.file}\`.`), + ) + : ["- No page-level redirects discovered."], + ), + ...section("Known caveats and stale-path flags", [ + "- No active stale internal route targets are expected in the current generated sitemap.", + "- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`.", + "- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records.", + "- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here.", + "- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data.", + "- Several differential records are placeholder scaffolds pending source-backed local clinical content.", + ]), + ...section("Route ownership/source map", [ + "| Area | Source |", + "| --- | --- |", + ...routeOwnershipRows.map(([area, source]) => `| ${area} | \`${source}\` |`), + ]), + ]; + + return `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim()}\n`; +} + +export async function renderSiteMap(data = collectSiteMapData()) { + return format(renderSiteMapRaw(data), { parser: "markdown", printWidth: 120 }); +} + +async function main() { + const expected = await renderSiteMap(); + const check = process.argv.includes("--check"); + + if (check) { + const current = existsSync(siteMapPath) ? readFileSync(siteMapPath, "utf8") : ""; + if (current !== expected) { + console.error("docs/site-map.md is stale. Run `npm run sitemap:update` and commit the result."); + process.exitCode = 1; + } + return; + } + + writeFileSync(siteMapPath, expected, "utf8"); + console.log(`Updated ${toPosixPath(path.relative(process.cwd(), siteMapPath))}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/src/app/mockups/favourites-hub/page.tsx b/src/app/mockups/favourites-hub/page.tsx index c115eb7d0..438f9733b 100644 --- a/src/app/mockups/favourites-hub/page.tsx +++ b/src/app/mockups/favourites-hub/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function FavouritesHubMockupRedirect() { - redirect("/?mode=documents"); + redirect("/favourites"); } diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts new file mode 100644 index 000000000..5fafba6de --- /dev/null +++ b/tests/site-map.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { appModeDefinitions, appModeHomeHref } from "@/lib/app-modes"; +import { tools } from "@/components/tools-page-mockups/tool-fixtures"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; +import { collectSiteMapData, renderSiteMap } from "../scripts/generate-site-map"; + +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const siteMap = readFileSync(siteMapPath, "utf8"); + +const acceptedDynamicPatterns = [ + /^\/documents\/[^/?#]+(?:[?#].*)?$/, + /^\/services\/[^/?#]+(?:[?#].*)?$/, + /^\/forms\/[^/?#]+(?:[?#].*)?$/, + /^\/differentials\/diagnoses\/[^/?#]+(?:[?#].*)?$/, + /^\/medications\/[^/?#]+(?:[?#].*)?$/, +]; + +function pathOnly(href: string) { + return href.split(/[?#]/)[0] || "/"; +} + +function routePatternForHref(href: string) { + const pathname = pathOnly(href); + if (acceptedDynamicPatterns.some((pattern) => pattern.test(href))) { + if (pathname.startsWith("/documents/")) return "/documents/[id]"; + if (pathname.startsWith("/services/")) return "/services/[slug]"; + if (pathname.startsWith("/forms/")) return "/forms/[slug]"; + if (pathname.startsWith("/differentials/diagnoses/")) return "/differentials/diagnoses/[slug]"; + if (pathname.startsWith("/medications/")) return "/medications/[slug]"; + } + return pathname; +} + +function expectDocumentedRoute(route: string) { + expect(siteMap, `Expected ${route} to be documented in docs/site-map.md`).toContain(`\`${route}\``); +} + +function expectDocumentedHref(href: string) { + expectDocumentedRoute(routePatternForHref(href)); +} + +describe("tracked sitemap", () => { + it("matches the generated sitemap output", async () => { + expect(siteMap).toBe(await renderSiteMap()); + }); + + it("documents every app page route and API route", () => { + const data = collectSiteMapData(); + + for (const pageRoute of data.pageRoutes) expectDocumentedRoute(pageRoute.route); + for (const apiRoute of data.apiRoutes) expectDocumentedRoute(apiRoute.route); + }); + + it("documents seeded dynamic slugs", () => { + for (const service of serviceRecords) expectDocumentedRoute(service.slug); + for (const form of formRecords) expectDocumentedRoute(form.slug); + for (const record of differentialRecords) expectDocumentedRoute(record.slug); + expectDocumentedRoute("acamprosate"); + }); + + it("documents core navigation href targets", () => { + for (const mode of appModeDefinitions) { + expectDocumentedHref(("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id)); + } + + for (const tool of tools) expectDocumentedHref(tool.href); + + for (const href of [ + "/?mode=answer", + "/?mode=documents", + "/?mode=prescribing", + "/?mode=tools", + "/services", + "/forms", + "/favourites", + "/differentials", + "/medications/acamprosate", + "/differentials/diagnoses/delirium", + ]) { + expectDocumentedHref(href); + } + }); + + it("documents known intentional caveats and compatibility routes", () => { + expect(siteMap).toContain("No active stale internal route targets"); + expect(siteMap).toContain("legacy compatibility route"); + expect(siteMap).toContain("Root-level mockup artifact outside `src/app`"); + expect(siteMap).toContain("Live user registries may contain additional service or form slugs"); + expect(siteMap).toContain("individual document IDs are private runtime data"); + }); +});