Skip to content

Commit e253310

Browse files
authored
fix(seo): declare canonical URLs for unversioned and untranslated page copies (#2432)
1 parent a3a1a64 commit e253310

3 files changed

Lines changed: 36 additions & 15 deletions

File tree

src/layouts/DocLayout.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface Props {
2121
isTranslation?: boolean;
2222
prev?: AdjacentPage | null;
2323
next?: AdjacentPage | null;
24+
canonicalPathname?: string;
2425
}
2526
2627
const {
@@ -31,6 +32,7 @@ const {
3132
isTranslation = false,
3233
prev,
3334
next,
35+
canonicalPathname,
3436
} = Astro.props;
3537
3638
const lang = getLangFromUrl(Astro.url);
@@ -39,7 +41,7 @@ const t = useTranslations(lang);
3941
const breadcrumbs = buildBreadcrumbs(Astro.url.pathname, t);
4042
---
4143

42-
<Layout title={title} description={description}>
44+
<Layout title={title} description={description} canonicalPathname={canonicalPathname}>
4345
<Container>
4446
<PageTopbar breadcrumbs={breadcrumbs}>
4547
{

src/layouts/Layout.astro

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,37 @@ interface Props {
1111
description?: string;
1212
authors?: { name: string; github?: string }[];
1313
image?: string;
14+
/** Canonical pathname override, for pages served as a copy of another URL. */
15+
canonicalPathname?: string;
1416
}
1517
1618
const {
1719
title,
1820
description = 'Fast, unopinionated, minimalist web framework for Node.js',
1921
authors,
2022
image,
23+
canonicalPathname,
2124
} = Astro.props;
2225
2326
const pageTitle = title
2427
? `${title} · Express.js`
2528
: 'Express.js · Node.js web application framework';
2629
27-
const canonicalUrl = new URL(Astro.url.pathname, Astro.site || Astro.url.origin);
28-
2930
const pathname = Astro.url.pathname.replace(/\/$/, '');
3031
const segments = pathname.split('/').filter(Boolean);
3132
const langSegment = segments[0] || 'en';
3233
const restSegments = segments.slice(1);
3334
const isApi =
3435
restSegments[0] === 'api' || (restSegments[1] === 'api' && /^\dx$/.test(restSegments[0] ?? ''));
3536
const isBlogOrApi = restSegments[0] === 'blog' || isApi;
37+
38+
// Blog and API content is not translated, so their canonical is the English
39+
// page and they emit no hreflang alternates.
40+
let canonicalPath = canonicalPathname ?? Astro.url.pathname;
41+
if (isBlogOrApi) {
42+
canonicalPath = replaceLanguageInPath(canonicalPath, 'en');
43+
}
44+
const canonicalUrl = new URL(canonicalPath, Astro.site || Astro.url.origin);
3645
// Build the OG image URL:
3746
// - Home pages (no rest segments) use a per-language image: /og/home-{lang}.png
3847
// - Blog and API pages strip the lang prefix since their content is not translated: /og/blog-{slug}.png
@@ -90,18 +99,19 @@ const lang = getLangFromUrl(Astro.url);
9099
<link rel="canonical" href={canonicalUrl} />
91100

92101
{
93-
languageCodes.map((altLang) => (
94-
<link
95-
rel="alternate"
96-
hreflang={altLang}
97-
href={
98-
new URL(
99-
replaceLanguageInPath(Astro.url.pathname, altLang),
100-
Astro.site || Astro.url.origin
101-
)
102-
}
103-
/>
104-
))
102+
!isBlogOrApi &&
103+
['x-default', ...languageCodes].map((altLang) => (
104+
<link
105+
rel="alternate"
106+
hreflang={altLang}
107+
href={
108+
new URL(
109+
replaceLanguageInPath(canonicalPath, altLang === 'x-default' ? 'en' : altLang),
110+
Astro.site || Astro.url.origin
111+
)
112+
}
113+
/>
114+
))
105115
}
106116

107117
<title>{pageTitle}</title>

src/pages/[lang]/[...slug].astro

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ const editUrl = isTranslation
209209
// Shared collections (api/blog) are compiled once, so their in-content links are
210210
// baked to the default language. Re-localize them per render-language.
211211
const isSharedCollection = page.collection === 'api' || page.collection === 'blog';
212+
213+
// Versioned content served at an unversioned URL is a copy of the default
214+
// version; its canonical is the versioned URL.
215+
const contentSlug = page.collection === 'api' ? page.id : page.id.split('/').slice(1).join('/');
216+
const isVersionedContent = VERSION_PREFIXES.some((v) => contentSlug.startsWith(`${v}/`));
217+
const servedUnversioned =
218+
isVersionedContent && !VERSION_PREFIXES.some((v) => slug.startsWith(`${v}/`));
219+
const canonicalPathname = servedUnversioned ? `/${lang}/${contentSlug}/` : undefined;
212220
---
213221

214222
<DocLayout
@@ -219,6 +227,7 @@ const isSharedCollection = page.collection === 'api' || page.collection === 'blo
219227
isTranslation={isTranslation}
220228
prev={prev}
221229
next={next}
230+
canonicalPathname={canonicalPathname}
222231
>
223232
{isSharedCollection ? <Content components={{ a: LocalizedLink }} /> : <Content />}
224233
</DocLayout>

0 commit comments

Comments
 (0)