diff --git a/docs/operations.md b/docs/operations.md index 143711fbc..2db27e8d8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,10 +42,12 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main` - the Vite build command and `dist` output; - SPA fallback to `index.html`; -- no-cache headers for `index.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; +- no-cache headers for `/`, `index.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; - `noindex` headers for `/app`, `/login`, and `/share` routes; - Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. +The build also writes `dist/terms/index.html` and `dist/privacy/index.html`: copies of the app shell whose canonical URL names their own route instead of the landing page (`selfCanonicalShells` in [`vite.config.ts`](../vite.config.ts)). Vercel serves them before the SPA fallback. Add a route there when a new SPA page joins `public/sitemap.xml`. + [`src/test/vercel-deployment-config.test.ts`](../src/test/vercel-deployment-config.test.ts) guards these rules. Update it together with `vercel.json`, and do not paste the full configuration into another guide. A Vercel deploy ships only the frontend. Supabase migrations and Edge Functions are separate operations with their own authorization, so a frontend that depends on new schema must wait until that schema is applied to production. diff --git a/e2e/public-pages.spec.ts b/e2e/public-pages.spec.ts index 63f602f68..b27d3d5c3 100644 --- a/e2e/public-pages.spec.ts +++ b/e2e/public-pages.spec.ts @@ -7,6 +7,8 @@ const PUBLIC_ROUTES = [ { path: '/terms', name: 'terms' }, { path: '/privacy', name: 'privacy' }, { path: '/login', name: 'login' }, + // An unknown route renders the 404 page, which must not log an error. + { path: '/no-such-page', name: 'not-found' }, ] as const; for (const locale of LOCALES) { @@ -25,7 +27,9 @@ for (const locale of LOCALES) { const response = await page.goto(route.path, { waitUntil: 'domcontentloaded' }); expect(response?.ok()).toBeTruthy(); - await expect(page.locator('body')).toBeVisible(); + // Pages are lazy chunks: wait until the page itself has rendered, so + // errors it logs while mounting are in the list below. + await expect(page.locator('h1').first()).toBeVisible(); const fatalErrors = consoleErrors.filter((msg) => !msg.includes('Failed to load resource') && diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index f3e826807..9c2afa324 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -15,16 +15,18 @@ const LandingPage = () => { return (
- - - - - - - - - - +
+ + + + + + + + + + +
); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3f1c02a6e..827b969f1 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -153,7 +153,8 @@ export default function LoginPage() {
- {t('login.title')} + {/* The page heading, styled like CardTitle (which renders an h3). */} +

{t('login.title')}

{t('login.subtitle')} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index edba0062b..89d46bd1e 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,15 +1,8 @@ -import { useLocation } from "react-router-dom"; -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; const NotFound = () => { - const location = useLocation(); const { t } = useTranslation(); - useEffect(() => { - console.error("404 Error: User attempted to access non-existent route:", location.pathname); - }, [location.pathname]); - return (
diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx index 5efd8a393..ca5f83f8b 100644 --- a/src/pages/SharedPage.tsx +++ b/src/pages/SharedPage.tsx @@ -40,7 +40,8 @@ export default function SharedPage() { try { const { data, error: fetchError } = await supabase .rpc('get_shared_item', { p_share_code: code }) - .single(); + // A missing or expired code returns no row; .single() would turn that into an HTTP 406. + .maybeSingle(); if (fetchError || !data) { setError(t('sharedPage.contentNotFound')); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 2da68064f..1247cdddc 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -124,7 +124,7 @@ describe("Vercel frontend deployment configuration", () => { }, ); - it.each(["/index.html", "/theme-init.js", "/sw.js"])( + it.each(["/", "/index.html", "/theme-init.js", "/sw.js"])( "prevents stale caching for %s", (source) => { const config = loadConfig(); diff --git a/vercel.json b/vercel.json index 77295bebc..a9ca1e0b7 100644 --- a/vercel.json +++ b/vercel.json @@ -75,6 +75,15 @@ } ] }, + { + "source": "/", + "headers": [ + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + } + ] + }, { "source": "/index.html", "headers": [ diff --git a/vite.config.ts b/vite.config.ts index 3715316bb..add917c8d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig, loadEnv, type Plugin, type PluginOption } from "vite"; import react from "@vitejs/plugin-react-swc"; import { sentryVitePlugin } from "@sentry/vite-plugin"; import { execSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "path"; import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./src/lib/supabase/projects"; @@ -54,6 +55,39 @@ function bundleGraph(): Plugin { }; } +/** + * The built index.html names the landing page as its canonical URL, and Vercel + * serves it for every route. Public routes listed in sitemap.xml get their own + * copy of the shell at `/index.html` naming themselves instead; Vercel + * serves a real file before the SPA rewrite. Google advises against changing a + * canonical from JavaScript, so this happens at build time. + */ +const SELF_CANONICAL_ROUTES = ["/terms", "/privacy"]; +const SITE_ORIGIN = "https://debate.pi-stack.org"; + +function selfCanonicalShells(): Plugin { + return { + name: "self-canonical-shells", + apply: (_config, env) => env.command === "build" && env.mode !== "capacitor", + writeBundle(options) { + const outDir = options.dir ?? "dist"; + const shell = readFileSync(path.join(outDir, "index.html"), "utf8"); + const canonical = ``; + const ogUrl = ``; + if (!shell.includes(canonical) || !shell.includes(ogUrl)) { + throw new Error("index.html no longer carries the landing canonical and og:url tags this plugin rewrites"); + } + for (const route of SELF_CANONICAL_ROUTES) { + const html = shell + .replace(canonical, ``) + .replace(ogUrl, ``); + mkdirSync(path.join(outDir, route), { recursive: true }); + writeFileSync(path.join(outDir, route, "index.html"), html); + } + }, + }; +} + /** * `npm run dev:preview` sets VITE_STAGING_PREVIEW=true, which makes the app call * Supabase at /__staging-supabase on its own origin (see src/lib/supabase/env.ts). Forward @@ -104,7 +138,7 @@ export default defineConfig(({ mode }) => { proxy, allowedHosts, }, - plugins: [react(), bundleGraph(), ...uploadSourceMaps], + plugins: [react(), bundleGraph(), selfCanonicalShells(), ...uploadSourceMaps], define: { "import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(release ?? ""), "import.meta.env.VITE_SENTRY_ENVIRONMENT": JSON.stringify(process.env.VERCEL_ENV ?? ""),