diff --git a/docs/site/middleware.js b/docs/site/middleware.js index 1909df280..3200a180f 100644 --- a/docs/site/middleware.js +++ b/docs/site/middleware.js @@ -3,6 +3,23 @@ const AI_AGENT_PATTERN = /claude[-_]?code|anthropic|cursor|copilot|chatgpt|openai|gptbot|perplexity|cohere|codeium|windsurf|tabnine|sourcegraph|cody/i; +// Trailing-slash duplicates split pageview counts and dilute SEO signals across +// two URLs for the same page. Redirect the trailing-slash variants to their +// canonical (non-trailing-slash) paths so analytics and search engines see one. +const TRAILING_SLASH_REDIRECTS = new Map([ + ['/Pricing/', '/Pricing'], + ['/UsingSeal/', '/UsingSeal'], + ['/GettingStarted/', '/GettingStarted'], +]); + +function resolveRedirect(request) { + const url = new URL(request.url); + const canonical = TRAILING_SLASH_REDIRECTS.get(url.pathname); + if (!canonical) return null; + url.pathname = canonical; + return url.toString(); +} + function detectServerVisitorType(request) { const ua = request.headers.get('user-agent') || ''; const accept = request.headers.get('accept') || ''; @@ -40,6 +57,13 @@ export const config = { }; export default async function middleware(request) { + // Consolidate trailing-slash duplicates before any tracking so the pageview + // is attributed to the canonical URL rather than the redirected one. + const redirectTo = resolveRedirect(request); + if (redirectTo) { + return Response.redirect(redirectTo, 301); + } + const visitorType = detectServerVisitorType(request); if (visitorType === 'agent') { trackPlausibleEvent(request, visitorType); diff --git a/docs/site/src/shared/js/serve-with-rewrites.js b/docs/site/src/shared/js/serve-with-rewrites.js index d23918140..913cd1fe2 100644 --- a/docs/site/src/shared/js/serve-with-rewrites.js +++ b/docs/site/src/shared/js/serve-with-rewrites.js @@ -33,6 +33,15 @@ const MIME_TYPES = { '.pdf': 'application/pdf', }; +// Trailing-slash duplicates split analytics and dilute SEO across two URLs for +// the same page. Redirect the trailing-slash variants to their canonical +// (non-trailing-slash) paths, mirroring the production edge middleware. +const TRAILING_SLASH_REDIRECTS = { + '/Pricing/': '/Pricing', + '/UsingSeal/': '/UsingSeal', + '/GettingStarted/': '/GettingStarted', +}; + function getContentType(filePath) { const ext = path.extname(filePath).toLowerCase(); return MIME_TYPES[ext] || 'application/octet-stream'; @@ -83,6 +92,15 @@ const server = http.createServer((req, res) => { const parsedUrl = url.parse(req.url); let pathname = parsedUrl.pathname; + // Consolidate trailing-slash duplicates onto their canonical paths. + const canonical = TRAILING_SLASH_REDIRECTS[pathname]; + if (canonical) { + const location = parsedUrl.search ? canonical + parsedUrl.search : canonical; + res.writeHead(301, { Location: location }); + res.end(); + return; + } + // Content negotiation: serve markdown when Accept: text/markdown if (acceptsMarkdown(req)) { const mdFile = resolveMarkdownFile(pathname);