Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion e2e/public-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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') &&
Expand Down
22 changes: 12 additions & 10 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ const LandingPage = () => {
return (
<div className="min-h-screen overflow-x-hidden bg-background">
<Navbar />
<Hero />
<SocialProof />
<WhatsNew />
<Features />
<Pricing />
<DeepDive />
<HowItWorks />
<UseCases />
<FAQ />
<FinalCTA />
<main>
<Hero />
<SocialProof />
<WhatsNew />
<Features />
<Pricing />
<DeepDive />
<HowItWorks />
<UseCases />
<FAQ />
<FinalCTA />
</main>
<Footer />
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,7 +153,8 @@ export default function LoginPage() {
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-6 w-6 text-primary-foreground" />
</div>
<CardTitle>{t('login.title')}</CardTitle>
{/* The page heading, styled like CardTitle (which renders an h3). */}
<h1 className="text-2xl font-semibold leading-none tracking-tight">{t('login.title')}</h1>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
Expand Down
7 changes: 0 additions & 7 deletions src/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
Expand Down
3 changes: 2 additions & 1 deletion src/pages/SharedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
2 changes: 1 addition & 1 deletion src/test/vercel-deployment-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
36 changes: 35 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 `<route>/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 = `<link rel="canonical" href="${SITE_ORIGIN}/" />`;
const ogUrl = `<meta property="og:url" content="${SITE_ORIGIN}/" />`;
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, `<link rel="canonical" href="${SITE_ORIGIN}${route}" />`)
.replace(ogUrl, `<meta property="og:url" content="${SITE_ORIGIN}${route}" />`);
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
Expand Down Expand Up @@ -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 ?? ""),
Expand Down
Loading