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)}` +}