Skip to content
Merged
20 changes: 15 additions & 5 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -69,11 +74,16 @@ export default defineConfig({
react(),
sitemap({
// Only the canonical /<locale>/… 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: {
Expand Down
33 changes: 31 additions & 2 deletions src/config/redirect.js → src/config/redirects/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
9 changes: 9 additions & 0 deletions src/config/redirects/moved-content.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = movedPagesData;
7 changes: 7 additions & 0 deletions src/config/redirects/moved-pages.json
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 4 additions & 0 deletions src/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 2 additions & 9 deletions src/i18n/utils.ts
Original file line number Diff line number Diff line change
@@ -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('/');
Expand All @@ -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})/`);
}

Expand Down
4 changes: 2 additions & 2 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,7 +90,7 @@ const lang = getLangFromUrl(Astro.url);
<link rel="canonical" href={canonicalUrl} />

{
Object.keys(languages).map((altLang) => (
languageCodes.map((altLang) => (
<link
rel="alternate"
hreflang={altLang}
Expand Down
47 changes: 42 additions & 5 deletions src/pages/[...path].astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// hardcodes a 2-second delay, which is a poor redirect experience.
import { getCollection } from 'astro:content';
import { DEFAULT_VERSION } from '@config/versions';
import { REMOVED_LOCALES, movedPages } from '@/config/redirects/moved-content';
import { languageCodes } from '@i18n/locales';

export async function getStaticPaths() {
const [docs, pages, api, blog] = await Promise.all([
Expand All @@ -21,11 +23,9 @@ export async function getStaticPaths() {
getCollection('blog'),
]);

/** @type {Set<string>} */
const slugs = new Set();
const slugs = new Set<string>();

/** @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,
Expand Down Expand Up @@ -54,10 +54,47 @@ export async function getStaticPaths() {
}
slugs.add('blog');

// Removed locales redirect to the English page (`/<locale>/<slug>` → `/en/<slug>`,
// and the bare `/<locale>` → `/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;
---

Expand Down
12 changes: 6 additions & 6 deletions src/pages/[lang]/[...slug].astro
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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];
Expand All @@ -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('/');
Expand Down Expand Up @@ -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) => {
Expand All @@ -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}/`));
Expand All @@ -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}/`));
Expand Down
4 changes: 2 additions & 2 deletions src/pages/[lang]/blog/[...page].astro
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 ?? []))];
Expand Down
4 changes: 2 additions & 2 deletions src/pages/[lang]/blog/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/[lang]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}));
}
Expand Down
4 changes: 2 additions & 2 deletions src/pages/og/[...id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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` },
Expand Down
Loading