From 183196554e251c5c5687a27d3921e516e4fd5eb5 Mon Sep 17 00:00:00 2001 From: Guillaume Richard Date: Thu, 28 May 2026 16:46:02 +0100 Subject: [PATCH] =?UTF-8?q?fix(docs):=20canonical=20URLs=20for=20SEO=20?= =?UTF-8?q?=E2=80=94=20metadata,=20sitemap,=20and=20footer=20links=20(#895?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit rel=canonical on every article via alias→canonical mapping (quick-start, installation, releases, examples, starter-kits). Filter sitemap to canonical paths only. Point footer, category menu, and 404 nav at canonical hrefs instead of URLs that 301. ### Change type - [ ] `bugfix` - [x] `improvement` - [ ] `feature` - [ ] `api` - [ ] `other` --- apps/docs/app/(docs)/[...slug]/page.tsx | 9 +++-- apps/docs/app/not-found.tsx | 2 +- apps/docs/app/sitemap.ts | 3 +- .../components/docs/docs-category-menu.tsx | 2 +- apps/docs/components/navigation/footer.tsx | 8 ++--- .../utils/sitemap-canonical-paths.test.ts | 24 +++++++++++++ apps/docs/utils/sitemap-canonical-paths.ts | 36 +++++++++++++++++++ 7 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 apps/docs/utils/sitemap-canonical-paths.test.ts create mode 100644 apps/docs/utils/sitemap-canonical-paths.ts diff --git a/apps/docs/app/(docs)/[...slug]/page.tsx b/apps/docs/app/(docs)/[...slug]/page.tsx index 06344920d469..b570385153f9 100644 --- a/apps/docs/app/(docs)/[...slug]/page.tsx +++ b/apps/docs/app/(docs)/[...slug]/page.tsx @@ -14,6 +14,7 @@ import { SearchButton } from '@/components/search/SearchButton' import { cn } from '@/utils/cn' import { db } from '@/utils/ContentDatabase' import { parseMarkdown } from '@/utils/parse-markdown' +import { canonicalDocsPageUrl } from '@/utils/sitemap-canonical-paths' export async function generateStaticParams() { const paths = await db.getAllPaths() @@ -27,9 +28,13 @@ export async function generateMetadata(props: { }): Promise { const params = await props.params const path = typeof params.slug === 'string' ? [params.slug] : params.slug - const content = await db.getPageContent(`/${path.join('/')}`) + const pagePath = `/${path.join('/')}` + const content = await db.getPageContent(pagePath) if (!content || content.type !== 'article') notFound() - const metadata: Metadata = { title: content.article.title } + const metadata: Metadata = { + title: content.article.title, + alternates: { canonical: canonicalDocsPageUrl(pagePath) }, + } if (content.article.description) { metadata.description = content.article.description } else { diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index 06d1f2726ae3..d712fbadda57 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -29,7 +29,7 @@ const links = [ { caption: 'Examples', icon: PlayIcon, - href: '/examples', + href: '/examples/basic', }, { caption: 'Blog', diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 9f3b7f77724d..ca458aa1b94a 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -1,8 +1,9 @@ import { MetadataRoute } from 'next' import { db } from '@/utils/ContentDatabase' +import { canonicalizeDocsSitemapPaths } from '@/utils/sitemap-canonical-paths' export default async function sitemap(): Promise { - const paths = await db.getAllPaths() + const paths = canonicalizeDocsSitemapPaths(await db.getAllPaths()) // Docs-only sitemap. Marketing pages are now owned by the dotdev app. const docsSitemap: MetadataRoute.Sitemap = [ diff --git a/apps/docs/components/docs/docs-category-menu.tsx b/apps/docs/components/docs/docs-category-menu.tsx index ec9766d1de59..fcfaea42c5ce 100644 --- a/apps/docs/components/docs/docs-category-menu.tsx +++ b/apps/docs/components/docs/docs-category-menu.tsx @@ -34,7 +34,7 @@ const categoryLinks = [ { caption: 'Examples', icon: PlayIcon, - href: '/examples', + href: '/examples/basic', active: (pathname: string) => pathname.startsWith('/examples'), }, ] diff --git a/apps/docs/components/navigation/footer.tsx b/apps/docs/components/navigation/footer.tsx index 2bcfacc8419a..c8a444187acb 100644 --- a/apps/docs/components/navigation/footer.tsx +++ b/apps/docs/components/navigation/footer.tsx @@ -8,7 +8,7 @@ const menus = [ heading: 'Product', items: [ { caption: 'Whiteboard', href: '/features/out-of-the-box-whiteboard' }, - { caption: 'Starter kits', href: '/starter-kits' }, + { caption: 'Starter kits', href: '/starter-kits/overview' }, { caption: 'Pricing', href: '/pricing' }, { caption: 'FAQ', href: '/faq' }, ], @@ -17,9 +17,9 @@ const menus = [ heading: 'Developers', items: [ { caption: 'Quick start guide', href: '/quick-start' }, - { caption: 'Starter kits', href: '/starter-kits' }, + { caption: 'Starter kits', href: '/starter-kits/overview' }, { caption: 'Examples', href: '/examples/basic' }, - { caption: 'Releases', href: '/releases-versioning' }, + { caption: 'Releases', href: '/releases' }, { caption: 'Docs', href: '/quick-start' }, ], }, @@ -42,7 +42,7 @@ const menus = [ items: [ { caption: 'Videos', href: '/blog/events' }, { caption: 'Careers', href: '/careers' }, - { caption: 'License', href: '/legal/tldraw-license' }, + { caption: 'License', href: '/community/license' }, { caption: 'Trademarks', href: '/legal/trademark-guidelines' }, { caption: 'CLA', href: '/legal/contributor-license-agreement' }, { caption: 'Privacy settings', href: '#', isCookieSetting: true }, diff --git a/apps/docs/utils/sitemap-canonical-paths.test.ts b/apps/docs/utils/sitemap-canonical-paths.test.ts new file mode 100644 index 000000000000..aa7411d7f4ae --- /dev/null +++ b/apps/docs/utils/sitemap-canonical-paths.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { canonicalDocsPageUrl, canonicalizeDocsSitemapPath } from './sitemap-canonical-paths' + +describe('canonicalizeDocsSitemapPath', () => { + it('maps duplicate-content alias paths to canonical URLs', () => { + expect(canonicalizeDocsSitemapPath('/getting-started/quick-start')).toBe('/quick-start') + expect(canonicalizeDocsSitemapPath('/getting-started/installation')).toBe('/installation') + expect(canonicalizeDocsSitemapPath('/releases-versioning')).toBe('/releases') + }) + + it('leaves canonical paths unchanged', () => { + expect(canonicalizeDocsSitemapPath('/quick-start')).toBe('/quick-start') + expect(canonicalizeDocsSitemapPath('/releases')).toBe('/releases') + }) +}) + +describe('canonicalDocsPageUrl', () => { + it('emits production-absolute canonical for alias and canonical paths', () => { + expect(canonicalDocsPageUrl('/getting-started/quick-start')).toBe( + 'https://tldraw.dev/quick-start' + ) + expect(canonicalDocsPageUrl('/quick-start')).toBe('https://tldraw.dev/quick-start') + }) +}) diff --git a/apps/docs/utils/sitemap-canonical-paths.ts b/apps/docs/utils/sitemap-canonical-paths.ts new file mode 100644 index 000000000000..3c2744662590 --- /dev/null +++ b/apps/docs/utils/sitemap-canonical-paths.ts @@ -0,0 +1,36 @@ +/** Public marketing apex — canonical `` and sitemap `` URLs. */ +export const PRODUCTION_SITE_ORIGIN = 'https://tldraw.dev' + +/** + * Paths that 301 or rewrite to a canonical URL. Sitemap and `` + * must use the canonical path so alias URLs do not dilute link equity. + */ +const DOCS_SITEMAP_PATH_ALIASES: Record = { + '/getting-started/quick-start': '/quick-start', + '/getting-started/installation': '/installation', + '/getting-started/releases': '/releases', + '/releases-versioning': '/releases', + '/examples': '/examples/basic', + '/starter-kits': '/starter-kits/overview', +} + +function normalizePathname(path: string): string { + const trimmed = path.trim() + if (!trimmed || trimmed === '/') return '/' + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + return withLeading.replace(/\/+$/, '') || '/' +} + +export function canonicalizeDocsSitemapPath(path: string): string { + const normalized = normalizePathname(path) + return DOCS_SITEMAP_PATH_ALIASES[normalized] ?? normalized +} + +export function canonicalizeDocsSitemapPaths(paths: string[]): string[] { + return Array.from(new Set(paths.map(canonicalizeDocsSitemapPath))).sort() +} + +/** Absolute canonical URL for a docs article path (alias or canonical). */ +export function canonicalDocsPageUrl(pathname: string): string { + return `${PRODUCTION_SITE_ORIGIN}${canonicalizeDocsSitemapPath(pathname)}` +}