seo: emit trailing-slash canonical + sitemap URLs

Static/docs/example routes are served as <route>/index.html and nginx
301-redirects the slash-less form to add the trailing slash. The sitemap
generator, the prerender canonical/og:url, and the client useSEO canonical
all emitted the slash-LESS form, so every sitemap URL was fetched as a
redirect (filed under 'Page with redirect' in Search Console) and each
canonical pointed at a redirecting URL.

Align all three to the trailing-slash form so sitemap URL == canonical ==
served URL == 200, with no redirect hop.

- generate-sitemap.mjs: append '/' to every <loc> (root stays '/')
- prerender-seo.mjs: withSlash() on canonical + og:url (routes + examples)
- useSEO.ts: withTrailingSlash() on canonical + og:url (covers dynamic
  project pages too)
This commit is contained in:
David Montero 2026-06-15 06:10:32 +02:00
parent 09eaf00c55
commit 07a0bfef6e
3 changed files with 36 additions and 8 deletions

View File

@ -64,6 +64,11 @@ const exampleUrls = exampleIds.map((id) => ({
priority: 0.6,
}));
// Every <loc> carries a trailing slash. nginx serves the prerendered route
// files as `<route>/index.html` and 301-redirects the slash-less form to add
// the slash, so a slash-less sitemap URL is fetched as a redirect and filed
// under "Page with redirect" in Search Console. Matching the served (slash)
// form keeps the sitemap URL == canonical == 200, with no redirect hop.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
@ -71,7 +76,7 @@ ${indexable
.map(
(r) => `
<url>
<loc>${DOMAIN}${r.path}</loc>
<loc>${DOMAIN}${r.path}${r.path.endsWith('/') ? '' : '/'}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${r.changefreq ?? 'monthly'}</changefreq>
<priority>${r.priority ?? 0.5}</priority>
@ -82,7 +87,7 @@ ${exampleUrls
.map(
(u) => `
<url>
<loc>${u.loc}</loc>
<loc>${u.loc}/</loc>
<lastmod>${u.lastmod}</lastmod>
<changefreq>${u.changefreq}</changefreq>
<priority>${u.priority}</priority>

View File

@ -33,6 +33,12 @@ if (!existsSync(join(distDir, 'index.html'))) {
const baseHtml = readFileSync(join(distDir, 'index.html'), 'utf-8');
const DOMAIN = 'https://velxio.dev';
// nginx serves each prerendered route as `<route>/index.html` and
// 301-redirects the slash-less URL to add the trailing slash. Canonical +
// og:url must therefore use the slash form (== the served URL == the sitemap
// entry), or Google sees a canonical that points to a redirecting URL.
const withSlash = (u) => (u.endsWith('/') ? u : `${u}/`);
// ── Mock browser globals for SSR ────────────────────────────────────────────
// Zustand's persist middleware and some components access these at import time.
if (typeof globalThis.localStorage === 'undefined') {
@ -96,7 +102,7 @@ try {
);
// Add/replace canonical URL
const canonicalTag = `<link rel="canonical" href="${seoMeta.url}" />`;
const canonicalTag = `<link rel="canonical" href="${withSlash(seoMeta.url)}" />`;
if (html.includes('<link rel="canonical"')) {
html = html.replace(/<link rel="canonical"[^>]*\/>/, canonicalTag);
} else {
@ -114,7 +120,7 @@ try {
);
html = html.replace(
/<meta property="og:url" content="[^"]*"/,
`<meta property="og:url" content="${seoMeta.url}"`
`<meta property="og:url" content="${withSlash(seoMeta.url)}"`
);
// Replace Twitter tags
@ -169,7 +175,7 @@ try {
`<meta name="description" content="${exRoute.description}"`
);
const canonicalTag = `<link rel="canonical" href="${exRoute.url}" />`;
const canonicalTag = `<link rel="canonical" href="${withSlash(exRoute.url)}" />`;
if (html.includes('<link rel="canonical"')) {
html = html.replace(/<link rel="canonical"[^>]*\/>/, canonicalTag);
} else {
@ -178,7 +184,7 @@ try {
html = html.replace(/<meta property="og:title" content="[^"]*"/, `<meta property="og:title" content="${exRoute.title}"`);
html = html.replace(/<meta property="og:description" content="[^"]*"/, `<meta property="og:description" content="${exRoute.description}"`);
html = html.replace(/<meta property="og:url" content="[^"]*"/, `<meta property="og:url" content="${exRoute.url}"`);
html = html.replace(/<meta property="og:url" content="[^"]*"/, `<meta property="og:url" content="${withSlash(exRoute.url)}"`);
html = html.replace(/<meta name="twitter:title" content="[^"]*"/, `<meta name="twitter:title" content="${exRoute.title}"`);
html = html.replace(/<meta name="twitter:description" content="[^"]*"/, `<meta name="twitter:description" content="${exRoute.description}"`);

View File

@ -15,6 +15,22 @@ function qs(selector: string): HTMLMetaElement | null {
return document.querySelector(selector) as HTMLMetaElement | null;
}
/**
* Canonical + og:url must carry a trailing slash to match the URL the server
* actually serves (nginx 301-redirects the slash-less form), so the canonical
* never points at a redirecting URL and project pages agree with their
* sitemap entry. Query/hash are preserved.
*/
function withTrailingSlash(u: string): string {
try {
const parsed = new URL(u, 'https://velxio.dev');
if (!parsed.pathname.endsWith('/')) parsed.pathname += '/';
return parsed.toString();
} catch {
return u.endsWith('/') ? u : `${u}/`;
}
}
/**
* Updates document.title, meta description, OG/Twitter tags, and canonical
* to reflect the current page. Restores originals on unmount.
@ -63,6 +79,7 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
}
// Apply
const canonicalUrl = withTrailingSlash(url);
document.title = title;
set(descEl, description);
if (noindex) {
@ -70,11 +87,11 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
}
set(ogTitleEl, title);
set(ogDescEl, description);
set(ogUrlEl, url);
set(ogUrlEl, canonicalUrl);
if (ogImage) set(ogImgEl, ogImage);
set(twTitleEl, title);
set(twDescEl, description);
activeCanonical.setAttribute('href', url);
activeCanonical.setAttribute('href', canonicalUrl);
// Inject JSON-LD once (module-level constants don't change)
if (jsonLd && !scriptRef.current) {