diff --git a/astro.config.mjs b/astro.config.mjs index c369fc960b..bc6458a1c3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,7 +10,8 @@ import svgr from 'vite-plugin-svgr'; import Icons from 'unplugin-icons/vite'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; -import redirects from './src/config/redirect.js'; +import redirects from './src/config/redirects/index.js'; +import movedPages from './src/config/redirects/moved-pages.json' with { type: 'json' }; import { accessibleTablesIntegration } from './src/plugins/rehype-accessible-tables.mjs'; import remarkRewriteLocalizedLinks from './src/plugins/remark-rewrite-localized-links.mjs'; import remarkCodeTabs from './src/plugins/remark-code-tabs.mjs'; @@ -21,6 +22,10 @@ const NETLIFY_PREVIEW_SITE = process.env.CONTEXT !== 'production' && process.env const site = NETLIFY_PREVIEW_SITE || 'https://expressjs.com'; +// Unlocalized paths of moved/removed pages. `[...path].astro` emits `noindex` +// redirect stubs for these in every locale; keep those stubs out of the sitemap. +const movedPagePaths = new Set(Object.keys(movedPages)); + // https://astro.build/config export default defineConfig({ redirects, @@ -69,11 +74,16 @@ export default defineConfig({ react(), sitemap({ // Only the canonical //… URLs belong in the sitemap. The catch-all - // `[...path].astro` also emits language-less redirect stubs (e.g. `/guide/x`, - // and `/` → `/en/`, all `noindex`); drop anything without a locale prefix. + // `[...path].astro` also emits `noindex` redirect stubs: language-less ones + // (e.g. `/guide/x`, `/` → `/en/`) and per-locale ones for moved pages + // (e.g. `/en/resources/middleware/csurf`). Exclude both. // Keep this locale list in sync with `i18n.locales` below. - filter: (page) => - /^\/(de|en|es|fr|it|ja|ko|pt-br|zh-cn|zh-tw)(\/|$)/.test(new URL(page).pathname), + filter: (page) => { + const { pathname } = new URL(page); + if (!/^\/(de|en|es|fr|it|ja|ko|pt-br|zh-cn|zh-tw)(\/|$)/.test(pathname)) return false; + const unlocalized = pathname.replace(/^\/[a-z-]+/, '').replace(/\/$/, ''); + return !movedPagePaths.has(unlocalized); + }, i18n: { defaultLocale: 'en', locales: { diff --git a/src/config/redirect.js b/src/config/redirects/index.js similarity index 69% rename from src/config/redirect.js rename to src/config/redirects/index.js index c087453300..6f0dfca120 100644 --- a/src/config/redirect.js +++ b/src/config/redirects/index.js @@ -6,8 +6,11 @@ // - The systematic "non-localized path → /en/…" redirects (guides, resources, // api, blog posts) are generated from the content collections by // `src/pages/[...path].astro`. -// - Stripping the `.html` extension is handled by Cloudflare, so paths are -// written without it (e.g. `/2x/guide`, not `/2x/guide.html`). +// - Stripping the `.html` extension is handled by Cloudflare, so most paths are +// written without it (e.g. `/2x/guide`, not `/2x/guide.html`). The paths that +// Cloudflare's rule skips (`/2x/*`, `/en/changelog/4x.html`, and date-based +// `/[0-9]{4}/…` blog permalinks) reach the site with the extension, so they get +// explicit `.html` entries here (see `html_excluded` below). const blog = { '/blog/posts': '/en/blog', @@ -53,12 +56,38 @@ const api_v2 = { const pages = { '/changelog/4x': 'https://github.com/expressjs/express/releases', '/en/changelog/4x': 'https://github.com/expressjs/express/releases', + // Legacy `index.html` landing pages. Cloudflare strips `.html`, leaving + // `/en/index/` (and `/index/`), which have no page of their own → send home. + '/en/index': '/en/', + '/index': '/en/', }; +// Cloudflare's `.html` rule skips `/2x/*`, `/en/changelog/4x.html`, and any +// date-based path (`^/[0-9]{4}/`), so those legacy URLs arrive with the extension +// intact. Give each a `.html` variant that redirects to the same target instead of +// 404ing (see issue #2407). +const html_excluded = { + '/en/changelog/4x.html': 'https://github.com/expressjs/express/releases', +}; +for (const [path, target] of Object.entries(api_v2)) { + // Skip `/2x/`, it already emits `dist/2x/index.html`, + // which also serves `/2x/index.html`. Adding a `/2x/index.html` route would make Astro + // treat that path as a directory too, colliding with the file (EISDIR at build time). + if (path === '/2x/') continue; + html_excluded[`${path}.html`] = target; +} +for (const [path, target] of Object.entries(blog)) { + // Only the date-based permalinks are skipped by Cloudflare (not e.g. `/blog/posts`). + if (/^\/\d{4}\//.test(path)) { + html_excluded[`${path}.html`] = target; + } +} + const redirects = { ...blog, ...api_v2, ...pages, + ...html_excluded, }; export default redirects; diff --git a/src/config/redirects/moved-content.ts b/src/config/redirects/moved-content.ts new file mode 100644 index 0000000000..1a51afcc23 --- /dev/null +++ b/src/config/redirects/moved-content.ts @@ -0,0 +1,9 @@ +import movedPagesData from './moved-pages.json'; + +// Their old URLs (e.g. `/tr/guide/routing`) no longer have +// content, so each one redirects to the same page in English instead of 404ing. +export const REMOVED_LOCALES = ['tr', 'th', 'id', 'uk', 'sk', 'ru', 'uz']; + +// Pages that moved or were removed (see `moved-pages.json`): old unlocalized path +// → a still-existing fallback (an unlocalized path, or an external URL). +export const movedPages: Record = movedPagesData; diff --git a/src/config/redirects/moved-pages.json b/src/config/redirects/moved-pages.json new file mode 100644 index 0000000000..6f79e65700 --- /dev/null +++ b/src/config/redirects/moved-pages.json @@ -0,0 +1,7 @@ +{ + "/resources/middleware/connect-rid": "/resources/middleware", + "/resources/middleware/csurf": "/resources/middleware", + "/changelog": "https://github.com/expressjs/express/releases", + "/faq": "/starter/faq", + "/guide": "/guide/routing" +} diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts index 79ed73d631..43495fd38e 100644 --- a/src/i18n/locales.ts +++ b/src/i18n/locales.ts @@ -24,6 +24,10 @@ export const languages = { export type LanguageCode = keyof typeof languages; +// All supported locale codes (e.g. `['en', 'de', …]`). Prefer this over repeating +// `Object.keys(languages)` across the codebase. +export const languageCodes = Object.keys(languages); + export const languagesArray = Object.entries(languages).map(([code, obj]) => ({ code, label: obj.label, diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 562188a1f8..9b5a389b94 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -1,4 +1,4 @@ -import { ui, defaultLang, languages } from './locales'; +import { ui, defaultLang, languageCodes } from './locales'; export function getLangFromUrl(url: URL) { const [, lang] = url.pathname.split('/'); @@ -22,18 +22,11 @@ export function useTranslations(lang: keyof typeof ui) { return getNestedValue(ui[lang], key) ?? getNestedValue(ui[defaultLang], key) ?? key; }; } -/** - * Get all supported language codes - */ -export function getLanguageCodes(): string[] { - return Object.keys(languages); -} - /** * Create a regex pattern to match language prefixes in URLs */ export function createLanguagePathRegex(): RegExp { - const codes = getLanguageCodes().join('|'); + const codes = languageCodes.join('|'); return new RegExp(`^/(${codes})/`); } diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index caee938e0c..a53638b18c 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -4,7 +4,7 @@ import { Flex, FlexItem } from '@components/primitives'; import { Footer, Sidebar } from '@/components/patterns'; import { Header } from '@/components/patterns'; import { getLangFromUrl, replaceLanguageInPath } from '@/i18n/utils'; -import { languages } from '@/i18n/locales'; +import { languages, languageCodes } from '@/i18n/locales'; interface Props { title?: string; @@ -90,7 +90,7 @@ const lang = getLangFromUrl(Astro.url); { - Object.keys(languages).map((altLang) => ( + languageCodes.map((altLang) => ( } */ - const slugs = new Set(); + const slugs = new Set(); - /** @param {string} slug */ - const add = (slug) => { + const add = (slug: string) => { if (!slug) return; slugs.add(slug); // Pages served at the default version are also available unversioned, @@ -54,10 +54,47 @@ export async function getStaticPaths() { } slugs.add('blog'); + // Removed locales redirect to the English page (`//` → `/en/`, + // and the bare `/` → `/en/`). Iterate a snapshot so the loop doesn't visit + // the paths it adds. + for (const slug of [...slugs]) { + for (const locale of REMOVED_LOCALES) { + slugs.add(`${locale}/${slug}`); + } + } + for (const locale of REMOVED_LOCALES) { + slugs.add(locale); + } + + // Moved pages 404 in every locale form, so enumerate each one (bare, and under + // every current and removed locale) to redirect it. + for (const path of Object.keys(movedPages)) { + slugs.add(path.slice(1)); + for (const locale of [...languageCodes, ...REMOVED_LOCALES]) { + slugs.add(`${locale}${path}`); + } + } + return [...slugs].map((path) => ({ params: { path } })); } -const target = `/en/${Astro.params.path}`; +// Strip any leading locale (current or removed) to get the unlocalized slug. A moved +// page goes to its configured fallback, keeping the reader's locale for internal +// targets; every other path goes to its English page. +const rawPath = String(Astro.params.path); +const firstSegment = rawPath.split('/')[0]; +const isCurrentLocale = languageCodes.includes(firstSegment); +const hasLocale = isCurrentLocale || REMOVED_LOCALES.includes(firstSegment); +const locale = isCurrentLocale ? firstSegment : 'en'; +const slug = hasLocale ? rawPath.slice(firstSegment.length + 1) : rawPath; + +const fallback = movedPages[`/${slug}`]; +let target; +if (fallback) { + target = fallback.startsWith('http') ? fallback : `/${locale}${fallback}`; +} else { + target = `/en/${slug}`; +} const canonical = new URL(target, Astro.site).href; --- diff --git a/src/pages/[lang]/[...slug].astro b/src/pages/[lang]/[...slug].astro index 92aee4440a..3d6ed74773 100644 --- a/src/pages/[lang]/[...slug].astro +++ b/src/pages/[lang]/[...slug].astro @@ -1,7 +1,7 @@ --- import { getCollection } from 'astro:content'; import DocLayout from '@layouts/DocLayout.astro'; -import { languages } from '@i18n/locales'; +import { languageCodes } from '@i18n/locales'; import { render } from 'astro:content'; import { getAdjacentPages } from '@utils/content'; import { docsMenu } from '@/config/menu/docs'; @@ -59,7 +59,7 @@ export async function getStaticPaths() { }); }); - for (const lang of Object.keys(languages)) { + for (const lang of languageCodes) { api.forEach((page) => { const slugParts = page.id.split('/'); const version = slugParts[0]; @@ -74,7 +74,7 @@ export async function getStaticPaths() { const defaultVersionApiPages = api.filter((page) => page.id.startsWith(`${DEFAULT_VERSION}/`)); - for (const lang of Object.keys(languages)) { + for (const lang of languageCodes) { defaultVersionApiPages.forEach((page) => { const [, ...restSlugParts] = page.id.split('/'); const nonVersionedSlug = restSlugParts.join('/'); @@ -116,7 +116,7 @@ export async function getStaticPaths() { p.id.startsWith(`${DEFAULT_LANG}/`) ); - for (const lang of Object.keys(languages)) { + for (const lang of languageCodes) { if (lang === DEFAULT_LANG) continue; englishDefaultVersionPages.forEach((page) => { @@ -137,7 +137,7 @@ export async function getStaticPaths() { } // Fallback: Create paths for non-English languages using English pages content - for (const lang of Object.keys(languages)) { + for (const lang of languageCodes) { if (lang === DEFAULT_LANG) continue; const englishContentPages = contentPages.filter((p) => p.id.startsWith(`${DEFAULT_LANG}/`)); @@ -160,7 +160,7 @@ export async function getStaticPaths() { } // Fallback: Create paths for non-English languages using English docs content - for (const lang of Object.keys(languages)) { + for (const lang of languageCodes) { if (lang === DEFAULT_LANG) continue; // Skip English, already added const englishPages = pages.filter((p) => p.id.startsWith(`${DEFAULT_LANG}/`)); diff --git a/src/pages/[lang]/blog/[...page].astro b/src/pages/[lang]/blog/[...page].astro index e8b88183fc..8c2d318004 100644 --- a/src/pages/[lang]/blog/[...page].astro +++ b/src/pages/[lang]/blog/[...page].astro @@ -1,6 +1,6 @@ --- import Layout from '@layouts/Layout.astro'; -import { languages } from '@i18n/locales'; +import { languageCodes } from '@i18n/locales'; import { Container, Grid, Col } from '@components/primitives'; import { Tabs } from '@components/primitives/Tabs'; import type { TabItem } from '@components/primitives'; @@ -13,7 +13,7 @@ export async function getStaticPaths({ paginate }: { paginate: PaginateFunction const PAGE_SIZE = 6; const allPosts = await getCollection('blog'); - return Object.keys(languages).flatMap((lang) => { + return languageCodes.flatMap((lang) => { const langPosts = sortByPubDateDesc(allPosts.filter((post) => post.id !== 'write-post')); const allTags = [...new Set(langPosts.flatMap((p) => p.data.tags ?? []))]; diff --git a/src/pages/[lang]/blog/[slug].astro b/src/pages/[lang]/blog/[slug].astro index 22ebf07d42..163d988b1e 100644 --- a/src/pages/[lang]/blog/[slug].astro +++ b/src/pages/[lang]/blog/[slug].astro @@ -2,7 +2,7 @@ import { getCollection, render } from 'astro:content'; import Layout from '@layouts/Layout.astro'; import { Container, Button } from '@/components/primitives'; -import { languages } from '@i18n/locales'; +import { languageCodes } from '@i18n/locales'; import { getLangFromUrl, useTranslations } from '@i18n/utils'; import { Icon } from 'astro-icon/components'; import { @@ -17,7 +17,7 @@ import { formatPubDate, sortByPubDateDesc } from '@/utils/rss'; export async function getStaticPaths() { const posts = await getCollection('blog'); - return Object.keys(languages).flatMap((lang) => + return languageCodes.flatMap((lang) => posts.map((post) => { const filename = post.id.split('/').pop()!; return { diff --git a/src/pages/[lang]/index.astro b/src/pages/[lang]/index.astro index ba47561e5d..33f53f4281 100644 --- a/src/pages/[lang]/index.astro +++ b/src/pages/[lang]/index.astro @@ -2,11 +2,11 @@ import { Hero, Features, AnnouncementBar } from '@components/patterns'; import { Card, Col } from '@components/primitives'; import Layout from '@layouts/Layout.astro'; -import { languages } from '@i18n/locales'; +import { languageCodes } from '@i18n/locales'; import { getLangFromUrl, useTranslations } from '@i18n/utils'; export function getStaticPaths() { - return Object.keys(languages).map((lang) => ({ + return languageCodes.map((lang) => ({ params: { lang }, })); } diff --git a/src/pages/og/[...id].ts b/src/pages/og/[...id].ts index 85fab5020d..91cb2298dc 100644 --- a/src/pages/og/[...id].ts +++ b/src/pages/og/[...id].ts @@ -4,7 +4,7 @@ import satori from 'satori'; import sharp from 'sharp'; import type { APIRoute, GetStaticPaths } from 'astro'; import { getCollection } from 'astro:content'; -import { languages } from '@/i18n/locales'; +import { languages, languageCodes } from '@/i18n/locales'; import { useTranslations } from '@/i18n/utils'; const COLORS = { @@ -55,7 +55,7 @@ export const getStaticPaths: GetStaticPaths = async () => { props: { title: entry.data.title }, })); - const homePaths = Object.keys(languages).map((lang) => { + const homePaths = languageCodes.map((lang) => { const t = useTranslations(lang as keyof typeof languages); return { params: { id: `home-${lang}.png` },