diff --git a/src/layouts/DocLayout.astro b/src/layouts/DocLayout.astro index b9c159cd01..f7da6fdedd 100644 --- a/src/layouts/DocLayout.astro +++ b/src/layouts/DocLayout.astro @@ -21,6 +21,7 @@ interface Props { isTranslation?: boolean; prev?: AdjacentPage | null; next?: AdjacentPage | null; + canonicalPathname?: string; } const { @@ -31,6 +32,7 @@ const { isTranslation = false, prev, next, + canonicalPathname, } = Astro.props; const lang = getLangFromUrl(Astro.url); @@ -39,7 +41,7 @@ const t = useTranslations(lang); const breadcrumbs = buildBreadcrumbs(Astro.url.pathname, t); --- - + { diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index a53638b18c..b406a93228 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -11,6 +11,8 @@ interface Props { description?: string; authors?: { name: string; github?: string }[]; image?: string; + /** Canonical pathname override, for pages served as a copy of another URL. */ + canonicalPathname?: string; } const { @@ -18,14 +20,13 @@ const { description = 'Fast, unopinionated, minimalist web framework for Node.js', authors, image, + canonicalPathname, } = Astro.props; const pageTitle = title ? `${title} · Express.js` : 'Express.js · Node.js web application framework'; -const canonicalUrl = new URL(Astro.url.pathname, Astro.site || Astro.url.origin); - const pathname = Astro.url.pathname.replace(/\/$/, ''); const segments = pathname.split('/').filter(Boolean); const langSegment = segments[0] || 'en'; @@ -33,6 +34,14 @@ const restSegments = segments.slice(1); const isApi = restSegments[0] === 'api' || (restSegments[1] === 'api' && /^\dx$/.test(restSegments[0] ?? '')); const isBlogOrApi = restSegments[0] === 'blog' || isApi; + +// Blog and API content is not translated, so their canonical is the English +// page and they emit no hreflang alternates. +let canonicalPath = canonicalPathname ?? Astro.url.pathname; +if (isBlogOrApi) { + canonicalPath = replaceLanguageInPath(canonicalPath, 'en'); +} +const canonicalUrl = new URL(canonicalPath, Astro.site || Astro.url.origin); // Build the OG image URL: // - Home pages (no rest segments) use a per-language image: /og/home-{lang}.png // - 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); { - languageCodes.map((altLang) => ( - - )) + !isBlogOrApi && + ['x-default', ...languageCodes].map((altLang) => ( + + )) } {pageTitle} diff --git a/src/pages/[lang]/[...slug].astro b/src/pages/[lang]/[...slug].astro index 3d6ed74773..6f8666f5c7 100644 --- a/src/pages/[lang]/[...slug].astro +++ b/src/pages/[lang]/[...slug].astro @@ -209,6 +209,14 @@ const editUrl = isTranslation // Shared collections (api/blog) are compiled once, so their in-content links are // baked to the default language. Re-localize them per render-language. const isSharedCollection = page.collection === 'api' || page.collection === 'blog'; + +// Versioned content served at an unversioned URL is a copy of the default +// version; its canonical is the versioned URL. +const contentSlug = page.collection === 'api' ? page.id : page.id.split('/').slice(1).join('/'); +const isVersionedContent = VERSION_PREFIXES.some((v) => contentSlug.startsWith(`${v}/`)); +const servedUnversioned = + isVersionedContent && !VERSION_PREFIXES.some((v) => slug.startsWith(`${v}/`)); +const canonicalPathname = servedUnversioned ? `/${lang}/${contentSlug}/` : undefined; --- {isSharedCollection ? : }