SEO prerender pipeline + register AI routes and tighten CSP
CI / build-and-test (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
- Static prerender + sitemap/robots for marketing, blog, and tool pages (entry-server, Seo component, prerender/sitemap scripts, NotFoundPage) - Register aiRoutes and extend CSP for Turnstile + Spaces image hosts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
304f7f30c3
commit
1f249dc126
@@ -0,0 +1,37 @@
|
||||
// Generates dist/sitemap.xml from the public, indexable routes in src/seo/routes-meta.ts.
|
||||
// Run after `vite build` (see the "build" script in package.json): npx tsx scripts/generate-sitemap.mts
|
||||
// Paths are resolved from import.meta.url so the script works regardless of cwd.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SITEMAP_ROUTES, SITE_URL } from '../src/seo/routes-meta';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const outFile = path.join(distDir, 'sitemap.xml');
|
||||
|
||||
const lastmod = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
const urlEntries = SITEMAP_ROUTES.map((route) => {
|
||||
// Root must be the origin with a trailing slash: https://elegalsoftware.com/
|
||||
const loc = route.path === '/' ? `${SITE_URL}/` : `${SITE_URL}${route.path}`;
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${loc}</loc>`,
|
||||
` <lastmod>${lastmod}</lastmod>`,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urlEntries,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(outFile, xml, 'utf8');
|
||||
|
||||
console.log(`sitemap: wrote ${SITEMAP_ROUTES.length} URLs to ${outFile}`);
|
||||
@@ -0,0 +1,103 @@
|
||||
// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers
|
||||
// receive full markup with per-route head tags without executing JS.
|
||||
// Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts
|
||||
//
|
||||
// How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX,
|
||||
// the '@/' alias, and CSS imports all resolve exactly as in the app build, so no
|
||||
// separate SSR bundle is needed. The built dist/index.html is the template: its
|
||||
// default <title>/description/og:/twitter: fallback tags are stripped (the per-route
|
||||
// tags from renderHeadTags() would otherwise duplicate them), the route's head tags
|
||||
// are injected before </head>, and the rendered app HTML is placed inside #root.
|
||||
//
|
||||
// NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which
|
||||
// replaces the prerendered DOM on load. That is intentional (no mismatch risk).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
import type { RouteMeta } from '../src/seo/routes-meta';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const distDir = path.join(webRoot, 'dist');
|
||||
const templatePath = path.join(distDir, 'index.html');
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`prerender: FAILED — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
fail(`${templatePath} not found — run \`vite build\` first`);
|
||||
}
|
||||
const rawTemplate = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Strip the template's default SEO fallback tags (title, meta description, og:*,
|
||||
// twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving
|
||||
// the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches
|
||||
// newlines, covering the multi-line <meta> formatting in index.html.
|
||||
const template = rawTemplate
|
||||
.replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '')
|
||||
.replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '')
|
||||
.replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '')
|
||||
.replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, '');
|
||||
|
||||
if (/<title>|property="og:|name="twitter:/i.test(template)) {
|
||||
fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?');
|
||||
}
|
||||
if (!template.includes('<div id="root"></div>')) {
|
||||
fail('template is missing `<div id="root"></div>` — cannot inject app HTML');
|
||||
}
|
||||
|
||||
// Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only
|
||||
// want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies.
|
||||
const vite = await createServer({
|
||||
root: webRoot,
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as {
|
||||
render: (url: string) => string;
|
||||
};
|
||||
const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule(
|
||||
'/src/seo/routes-meta.ts',
|
||||
)) as {
|
||||
PRERENDER_ROUTES: string[];
|
||||
metaForPath: (p: string) => RouteMeta | undefined;
|
||||
renderHeadTags: (m: RouteMeta) => string;
|
||||
};
|
||||
|
||||
if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty');
|
||||
|
||||
for (const route of PRERENDER_ROUTES) {
|
||||
try {
|
||||
const meta = metaForPath(route);
|
||||
if (!meta) throw new Error(`no meta registered for ${route}`);
|
||||
|
||||
const appHtml = render(route);
|
||||
if (!appHtml.trim()) throw new Error('rendered app HTML is empty');
|
||||
|
||||
const doc = template
|
||||
.replace('</head>', ` ${renderHeadTags(meta)}\n </head>`)
|
||||
.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
|
||||
|
||||
const outFile =
|
||||
route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html');
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, doc, 'utf8');
|
||||
console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`);
|
||||
} catch (err) {
|
||||
exitCode = 1;
|
||||
console.error(`prerender: ERROR rendering ${route}:`, err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await vite.close();
|
||||
}
|
||||
|
||||
if (exitCode !== 0) fail('one or more routes failed (see errors above)');
|
||||
console.log('prerender: all routes rendered');
|
||||
Reference in New Issue
Block a user