diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index 6755f52b5acb66..3a980e1a43f16c 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -3,6 +3,7 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; import {Fragment, useMemo} from 'react'; import {apiCategories} from 'sentry-docs/build/resolveOpenAPI'; +import {canonicalPath} from 'sentry-docs/canonical'; import {ApiCategoryPage} from 'sentry-docs/components/apiCategoryPage'; import {ApiPage} from 'sentry-docs/components/apiPage'; import {DocPage} from 'sentry-docs/components/docPage'; @@ -289,17 +290,6 @@ type MetadataProps = { }>; }; -// Helper function to clean up canonical tags missing leading or trailing slash -function formatCanonicalTag(tag: string) { - if (tag.charAt(0) !== '/') { - tag = '/' + tag; - } - if (tag.charAt(tag.length - 1) !== '/') { - tag += '/'; - } - return tag; -} - // Helper function to resolve OG image URLs function resolveOgImageUrl( imageUrl: string | undefined, @@ -369,7 +359,7 @@ export async function generateMetadata(props: MetadataProps): Promise description = pageNode.frontmatter.description ?? ''; if (pageNode.frontmatter.customCanonicalTag) { - customCanonicalTag = formatCanonicalTag(pageNode.frontmatter.customCanonicalTag); + customCanonicalTag = pageNode.frontmatter.customCanonicalTag; } noindex = pageNode.frontmatter.noindex; @@ -399,11 +389,9 @@ export async function generateMetadata(props: MetadataProps): Promise const images = [{url: ogImageUrl, width: 1200, height: 630}]; - const canonical = customCanonicalTag - ? domain + customCanonicalTag - : params.path - ? `${domain}/${params.path.join('/')}/` - : domain; + const canonical = `${domain}${canonicalPath( + customCanonicalTag || params.path?.join('/') || '' + )}`; return { title, diff --git a/app/sitemap.ts b/app/sitemap.ts index 7ba3a6c6ba4609..85ffb16a5fdf80 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,4 +1,5 @@ import type {MetadataRoute} from 'next'; +import {canonicalPath} from 'sentry-docs/canonical'; import {DEVELOP_DOCS_INDEXABLE_ROOTS} from 'sentry-docs/developDocsConfig'; import {type DocNode, getDocsRootNode} from 'sentry-docs/docTree'; import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; @@ -50,13 +51,7 @@ export default async function sitemap(): Promise { } function docsToSitemap(paths: string[], baseUrl: string): MetadataRoute.Sitemap { - const appendSlash = (path: string) => { - if (path === '' || path.endsWith('/')) { - return path; - } - return path + '/'; - }; - const toFullUrl = (path: string) => `${appendSlash(baseUrl)}${appendSlash(path)}`; + const toFullUrl = (path: string) => `${baseUrl}${canonicalPath(path)}`; const toSitemapEntry = (path: string) => ({url: toFullUrl(path)}); return ['', ...paths].map(toSitemapEntry); } diff --git a/middleware.test.ts b/middleware.test.ts index 6e8f6e7763517f..e36db091965ed1 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -199,6 +199,17 @@ describe('canonical Link header on .md responses', () => { ); }); + it.each([ + '/platforms/java/migration/7.x-to-8.0', + '/platforms/python/migration/1.x-to-2.x', + ])('does not add a trailing slash to a dotted document path: %s', async path => { + const {middleware} = await importMiddleware({}); + const res = middleware(makeRequest(`${path}.md`)); + expect(res.headers.get('Link')).toBe( + `; rel="canonical"` + ); + }); + it('maps /index.md to the root canonical URL', async () => { const {middleware} = await importMiddleware({}); const res = middleware(makeRequest('/index.md')); diff --git a/middleware.ts b/middleware.ts index 59b1b8cec656b4..713904c16bea64 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/nextjs'; import type {NextRequest} from 'next/server'; import {NextResponse, userAgent} from 'next/server'; +import {canonicalPath} from 'sentry-docs/canonical'; import { AI_AGENT_PATTERN, matchPattern, @@ -21,7 +22,11 @@ const CANONICAL_HOST = new URL(BASE_URL).hostname; // Production domains whose content should be indexable by search engines. // All other hostnames (Vercel preview/deployment URLs, old production deployments) // get X-Robots-Tag: noindex to prevent search engines from indexing stale content. -const INDEXABLE_HOSTNAMES = new Set(['docs.sentry.io', 'develop.sentry.dev', 'localhost']); +const INDEXABLE_HOSTNAMES = new Set([ + 'docs.sentry.io', + 'develop.sentry.dev', + 'localhost', +]); export const config = { // learn more: https://nextjs.org/docs/pages/building-your-application/routing/middleware#matcher @@ -309,7 +314,7 @@ function rewriteWithClassification( function mdToCanonicalPath(mdPathname: string): string { const withoutExt = mdPathname.replace(/\.md$/, ''); if (withoutExt === '/index') return '/'; - return withoutExt.endsWith('/') ? withoutExt : `${withoutExt}/`; + return canonicalPath(withoutExt); } /** diff --git a/src/canonical.spec.ts b/src/canonical.spec.ts new file mode 100644 index 00000000000000..7783406db54ff1 --- /dev/null +++ b/src/canonical.spec.ts @@ -0,0 +1,16 @@ +import {describe, expect, it} from 'vitest'; + +import {canonicalPath} from './canonical'; + +describe('canonicalPath', () => { + it.each([ + ['', '/'], + ['/', '/'], + ['platforms/java/configuration/options', '/platforms/java/configuration/options/'], + ['/platforms/java/configuration/options/', '/platforms/java/configuration/options/'], + ['platforms/java/migration/7.x-to-8.0', '/platforms/java/migration/7.x-to-8.0'], + ['/platforms/python/migration/1.x-to-2.x/', '/platforms/python/migration/1.x-to-2.x'], + ])('formats %j as %j', (pathname, expected) => { + expect(canonicalPath(pathname)).toBe(expected); + }); +}); diff --git a/src/canonical.ts b/src/canonical.ts new file mode 100644 index 00000000000000..bb13aa5e71a586 --- /dev/null +++ b/src/canonical.ts @@ -0,0 +1,18 @@ +/** + * Formats a documentation pathname to match Next.js's `trailingSlash: true` + * redirects. Next.js treats a final segment ending in `.` as + * file-like and removes its trailing slash. + */ +export function canonicalPath(pathname: string): string { + const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`; + const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, ''); + + if (!withoutTrailingSlash) { + return '/'; + } + + const finalSegment = withoutTrailingSlash.slice( + withoutTrailingSlash.lastIndexOf('/') + 1 + ); + return /\.\w+$/.test(finalSegment) ? withoutTrailingSlash : `${withoutTrailingSlash}/`; +}