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:
parent
09eaf00c55
commit
07a0bfef6e
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue