Skip to content

Commit 1831965

Browse files
authored
fix(docs): canonical URLs for SEO — metadata, sitemap, and footer links (tldraw#8952)
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`
1 parent 0f6272f commit 1831965

7 files changed

Lines changed: 75 additions & 9 deletions

File tree

apps/docs/app/(docs)/[...slug]/page.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SearchButton } from '@/components/search/SearchButton'
1414
import { cn } from '@/utils/cn'
1515
import { db } from '@/utils/ContentDatabase'
1616
import { parseMarkdown } from '@/utils/parse-markdown'
17+
import { canonicalDocsPageUrl } from '@/utils/sitemap-canonical-paths'
1718

1819
export async function generateStaticParams() {
1920
const paths = await db.getAllPaths()
@@ -27,9 +28,13 @@ export async function generateMetadata(props: {
2728
}): Promise<Metadata> {
2829
const params = await props.params
2930
const path = typeof params.slug === 'string' ? [params.slug] : params.slug
30-
const content = await db.getPageContent(`/${path.join('/')}`)
31+
const pagePath = `/${path.join('/')}`
32+
const content = await db.getPageContent(pagePath)
3133
if (!content || content.type !== 'article') notFound()
32-
const metadata: Metadata = { title: content.article.title }
34+
const metadata: Metadata = {
35+
title: content.article.title,
36+
alternates: { canonical: canonicalDocsPageUrl(pagePath) },
37+
}
3338
if (content.article.description) {
3439
metadata.description = content.article.description
3540
} else {

apps/docs/app/not-found.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const links = [
2929
{
3030
caption: 'Examples',
3131
icon: PlayIcon,
32-
href: '/examples',
32+
href: '/examples/basic',
3333
},
3434
{
3535
caption: 'Blog',

apps/docs/app/sitemap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { MetadataRoute } from 'next'
22
import { db } from '@/utils/ContentDatabase'
3+
import { canonicalizeDocsSitemapPaths } from '@/utils/sitemap-canonical-paths'
34

45
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
5-
const paths = await db.getAllPaths()
6+
const paths = canonicalizeDocsSitemapPaths(await db.getAllPaths())
67

78
// Docs-only sitemap. Marketing pages are now owned by the dotdev app.
89
const docsSitemap: MetadataRoute.Sitemap = [

apps/docs/components/docs/docs-category-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const categoryLinks = [
3434
{
3535
caption: 'Examples',
3636
icon: PlayIcon,
37-
href: '/examples',
37+
href: '/examples/basic',
3838
active: (pathname: string) => pathname.startsWith('/examples'),
3939
},
4040
]

apps/docs/components/navigation/footer.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const menus = [
88
heading: 'Product',
99
items: [
1010
{ caption: 'Whiteboard', href: '/features/out-of-the-box-whiteboard' },
11-
{ caption: 'Starter kits', href: '/starter-kits' },
11+
{ caption: 'Starter kits', href: '/starter-kits/overview' },
1212
{ caption: 'Pricing', href: '/pricing' },
1313
{ caption: 'FAQ', href: '/faq' },
1414
],
@@ -17,9 +17,9 @@ const menus = [
1717
heading: 'Developers',
1818
items: [
1919
{ caption: 'Quick start guide', href: '/quick-start' },
20-
{ caption: 'Starter kits', href: '/starter-kits' },
20+
{ caption: 'Starter kits', href: '/starter-kits/overview' },
2121
{ caption: 'Examples', href: '/examples/basic' },
22-
{ caption: 'Releases', href: '/releases-versioning' },
22+
{ caption: 'Releases', href: '/releases' },
2323
{ caption: 'Docs', href: '/quick-start' },
2424
],
2525
},
@@ -42,7 +42,7 @@ const menus = [
4242
items: [
4343
{ caption: 'Videos', href: '/blog/events' },
4444
{ caption: 'Careers', href: '/careers' },
45-
{ caption: 'License', href: '/legal/tldraw-license' },
45+
{ caption: 'License', href: '/community/license' },
4646
{ caption: 'Trademarks', href: '/legal/trademark-guidelines' },
4747
{ caption: 'CLA', href: '/legal/contributor-license-agreement' },
4848
{ caption: 'Privacy settings', href: '#', isCookieSetting: true },
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { canonicalDocsPageUrl, canonicalizeDocsSitemapPath } from './sitemap-canonical-paths'
3+
4+
describe('canonicalizeDocsSitemapPath', () => {
5+
it('maps duplicate-content alias paths to canonical URLs', () => {
6+
expect(canonicalizeDocsSitemapPath('/getting-started/quick-start')).toBe('/quick-start')
7+
expect(canonicalizeDocsSitemapPath('/getting-started/installation')).toBe('/installation')
8+
expect(canonicalizeDocsSitemapPath('/releases-versioning')).toBe('/releases')
9+
})
10+
11+
it('leaves canonical paths unchanged', () => {
12+
expect(canonicalizeDocsSitemapPath('/quick-start')).toBe('/quick-start')
13+
expect(canonicalizeDocsSitemapPath('/releases')).toBe('/releases')
14+
})
15+
})
16+
17+
describe('canonicalDocsPageUrl', () => {
18+
it('emits production-absolute canonical for alias and canonical paths', () => {
19+
expect(canonicalDocsPageUrl('/getting-started/quick-start')).toBe(
20+
'https://tldraw.dev/quick-start'
21+
)
22+
expect(canonicalDocsPageUrl('/quick-start')).toBe('https://tldraw.dev/quick-start')
23+
})
24+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/** Public marketing apex — canonical `<link>` and sitemap `<loc>` URLs. */
2+
export const PRODUCTION_SITE_ORIGIN = 'https://tldraw.dev'
3+
4+
/**
5+
* Paths that 301 or rewrite to a canonical URL. Sitemap and `<link rel="canonical">`
6+
* must use the canonical path so alias URLs do not dilute link equity.
7+
*/
8+
const DOCS_SITEMAP_PATH_ALIASES: Record<string, string> = {
9+
'/getting-started/quick-start': '/quick-start',
10+
'/getting-started/installation': '/installation',
11+
'/getting-started/releases': '/releases',
12+
'/releases-versioning': '/releases',
13+
'/examples': '/examples/basic',
14+
'/starter-kits': '/starter-kits/overview',
15+
}
16+
17+
function normalizePathname(path: string): string {
18+
const trimmed = path.trim()
19+
if (!trimmed || trimmed === '/') return '/'
20+
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
21+
return withLeading.replace(/\/+$/, '') || '/'
22+
}
23+
24+
export function canonicalizeDocsSitemapPath(path: string): string {
25+
const normalized = normalizePathname(path)
26+
return DOCS_SITEMAP_PATH_ALIASES[normalized] ?? normalized
27+
}
28+
29+
export function canonicalizeDocsSitemapPaths(paths: string[]): string[] {
30+
return Array.from(new Set(paths.map(canonicalizeDocsSitemapPath))).sort()
31+
}
32+
33+
/** Absolute canonical URL for a docs article path (alias or canonical). */
34+
export function canonicalDocsPageUrl(pathname: string): string {
35+
return `${PRODUCTION_SITE_ORIGIN}${canonicalizeDocsSitemapPath(pathname)}`
36+
}

0 commit comments

Comments
 (0)