Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 5 additions & 17 deletions app/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,7 +359,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
description = pageNode.frontmatter.description ?? '';

if (pageNode.frontmatter.customCanonicalTag) {
customCanonicalTag = formatCanonicalTag(pageNode.frontmatter.customCanonicalTag);
customCanonicalTag = pageNode.frontmatter.customCanonicalTag;
}

noindex = pageNode.frontmatter.noindex;
Expand Down Expand Up @@ -399,11 +389,9 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>

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,
Expand Down
9 changes: 2 additions & 7 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -50,13 +51,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}

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);
}
11 changes: 11 additions & 0 deletions middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<https://docs.sentry.io${path}>; rel="canonical"`
);
});

it('maps /index.md to the root canonical URL', async () => {
const {middleware} = await importMiddleware({});
const res = middleware(makeRequest('/index.md'));
Expand Down
9 changes: 7 additions & 2 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/canonical.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions src/canonical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Formats a documentation pathname to match Next.js's `trailingSlash: true`
* redirects. Next.js treats a final segment ending in `.<word characters>` 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}/`;
}
Loading