Skip to content
Merged
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
9 changes: 7 additions & 2 deletions apps/docs/app/(docs)/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -27,9 +28,13 @@ export async function generateMetadata(props: {
}): Promise<Metadata> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const links = [
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples',
href: '/examples/basic',
},
{
caption: 'Blog',
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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<MetadataRoute.Sitemap> {
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 = [
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/components/docs/docs-category-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const categoryLinks = [
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples',
href: '/examples/basic',
active: (pathname: string) => pathname.startsWith('/examples'),
},
]
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/components/navigation/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand All @@ -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' },
],
},
Expand All @@ -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 },
Expand Down
24 changes: 24 additions & 0 deletions apps/docs/utils/sitemap-canonical-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
36 changes: 36 additions & 0 deletions apps/docs/utils/sitemap-canonical-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Public marketing apex — canonical `<link>` and sitemap `<loc>` URLs. */
export const PRODUCTION_SITE_ORIGIN = 'https://tldraw.dev'

/**
* Paths that 301 or rewrite to a canonical URL. Sitemap and `<link rel="canonical">`
* must use the canonical path so alias URLs do not dilute link equity.
*/
const DOCS_SITEMAP_PATH_ALIASES: Record<string, string> = {
'/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)}`
}
Loading