Skip to content

Commit 72dcf14

Browse files
os-zhuangclaude
andcommitted
feat(docs): emit canonical + hreflang on doc pages
Move the locale-URL/hreflang logic into lib/seo.ts and reuse it from both the sitemap and per-page metadata. Each doc page now declares a self-referential canonical and a full hreflang set (all 7 locales + x-default -> English), the stronger per-page SEO signal that the sitemap alone could not provide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9f6dec7 commit 72dcf14

3 files changed

Lines changed: 42 additions & 26 deletions

File tree

apps/docs/app/[lang]/docs/[[...slug]]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { File, Folder, Files } from 'fumadocs-ui/components/files';
99
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
1010
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
1111
import { gitConfig } from '@/lib/layout.shared';
12+
import { languageAlternates, localeUrl } from '@/lib/seo';
1213

1314
export default async function Page(props: {
1415
params: Promise<{ lang: string; slug?: string[] }>;
@@ -60,8 +61,15 @@ export async function generateMetadata(props: {
6061
const page = source.getPage(params.slug ?? [], params.lang);
6162
if (!page) notFound();
6263

64+
// Logical path is locale-independent; reconstruct each locale URL from slugs.
65+
const path = ['docs', ...page.slugs].join('/');
66+
6367
return {
6468
title: page.data.title,
6569
description: page.data.description,
70+
alternates: {
71+
canonical: localeUrl(params.lang, path),
72+
languages: languageAlternates(path),
73+
},
6674
};
6775
}

apps/docs/app/sitemap.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
11
import type { MetadataRoute } from 'next';
22
import { source } from '@/lib/source';
33
import { i18n } from '@/lib/i18n';
4-
5-
// Canonical production host. Keep in sync with middleware's domain redirect.
6-
const BASE = 'https://docs.objectos.ai';
7-
8-
// Build an absolute URL for a given locale, hiding the prefix for the
9-
// default language (matches i18n.hideLocale = 'default-locale').
10-
function localize(lang: string, path: string): string {
11-
const suffix = path ? `/${path}` : '';
12-
return lang === i18n.defaultLanguage
13-
? `${BASE}${suffix}`
14-
: `${BASE}/${lang}${suffix}`;
15-
}
16-
17-
// hreflang alternates map for a logical path, linking every locale version
18-
// plus an x-default that points at the English (canonical) URL.
19-
function alternates(path: string): Record<string, string> {
20-
const languages: Record<string, string> = {};
21-
for (const lang of i18n.languages) {
22-
languages[lang] = localize(lang, path);
23-
}
24-
languages['x-default'] = localize(i18n.defaultLanguage, path);
25-
return languages;
26-
}
4+
import { languageAlternates, localeUrl } from '@/lib/seo';
275

286
export const revalidate = false;
297

@@ -45,13 +23,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
4523
.map((page) => ({ path: ['docs', ...page.slugs].join('/'), priority: 0.8 }));
4624

4725
for (const { path, priority } of [...staticPaths, ...docPaths]) {
48-
const langAlternates = alternates(path);
26+
const languages = languageAlternates(path);
4927
for (const lang of i18n.languages) {
5028
entries.push({
51-
url: localize(lang, path),
29+
url: localeUrl(lang, path),
5230
changeFrequency: 'weekly',
5331
priority,
54-
alternates: { languages: langAlternates },
32+
alternates: { languages },
5533
});
5634
}
5735
}

apps/docs/lib/seo.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { i18n } from '@/lib/i18n';
2+
3+
// Canonical production host. Keep in sync with middleware's domain redirect.
4+
export const SITE_URL = 'https://docs.objectos.ai';
5+
6+
/**
7+
* Absolute URL for a logical (locale-independent) path, hiding the locale
8+
* prefix for the default language (matches i18n.hideLocale = 'default-locale').
9+
* `path` has no leading slash, e.g. '' (home) or 'docs/architecture'.
10+
*/
11+
export function localeUrl(lang: string, path: string): string {
12+
const suffix = path ? `/${path}` : '';
13+
return lang === i18n.defaultLanguage
14+
? `${SITE_URL}${suffix}`
15+
: `${SITE_URL}/${lang}${suffix}`;
16+
}
17+
18+
/**
19+
* hreflang alternates map for a logical path: every supported locale plus an
20+
* x-default pointing at the English (canonical) URL. Shape matches the
21+
* `alternates.languages` field of Next.js Metadata and MetadataRoute.Sitemap.
22+
*/
23+
export function languageAlternates(path: string): Record<string, string> {
24+
const languages: Record<string, string> = {};
25+
for (const lang of i18n.languages) {
26+
languages[lang] = localeUrl(lang, path);
27+
}
28+
languages['x-default'] = localeUrl(i18n.defaultLanguage, path);
29+
return languages;
30+
}

0 commit comments

Comments
 (0)