diff --git a/.changeset/cyan-grapes-suffer.md b/.changeset/cyan-grapes-suffer.md new file mode 100644 index 000000000000..84833c42b9d3 --- /dev/null +++ b/.changeset/cyan-grapes-suffer.md @@ -0,0 +1,24 @@ +--- +"astro": minor +--- + + +Adds a new `i18n.routing` config option `redirectToDefaultLocale` to disable automatic redirects of the root URL (`/`) to the default locale when `prefixDefaultLocale: true` is set. + +In projects where every route, including the default locale, is prefixed with `/[locale]/` path, this property allows you to control whether or not `src/pages/index.astro` should automatically redirect your site visitors from `/` to `/[defaultLocale]`. + +You can now opt out of this automatic redirection by setting `redirectToDefaultLocale: false`: + +```js +// astro.config.mjs +export default defineConfig({ + i18n:{ + defaultLocale: "en", + locales: ["en", "fr"], + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false + } + } +}) +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 784bda56aeb8..8de67fdae0bf 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1493,6 +1493,35 @@ export interface AstroUserConfig { */ prefixDefaultLocale: boolean; + /** + * @docs + * @name i18n.routing.redirectToDefaultLocale + * @kind h4 + * @type {boolean} + * @default `true` + * @version 4.2.0 + * @description + * + * Configures whether or not the home URL (`/`) generated by `src/pages/index.astro` + * will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set. + * + * Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site: + * ```js + * // astro.config.mjs + * export default defineConfig({ + * i18n:{ + * defaultLocale: "en", + * locales: ["en", "fr"], + * routing: { + * prefixDefaultLocale: true, + * redirectToDefaultLocale: false + * } + * } + * }) + *``` + * */ + redirectToDefaultLocale: boolean; + /** * @name i18n.routing.strategy * @type {"pathname"} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index ab4a4fc2cdf7..c0dbb54e75de 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -7,6 +7,7 @@ import type { SSRResult, } from '../../@types/astro.js'; import type { SinglePageBuiltModule } from '../build/types.js'; +import type { RoutingStrategies } from '../config/schema.js'; export type ComponentPath = string; @@ -56,7 +57,7 @@ export type SSRManifest = { export type SSRManifestI18n = { fallback?: Record; - routing?: 'prefix-always' | 'prefix-other-locales'; + routing?: RoutingStrategies; locales: Locales; defaultLocale: string; }; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 1f021f568033..47ed11aaf6fc 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -64,7 +64,10 @@ const ASTRO_CONFIG_DEFAULTS = { }, } satisfies AstroUserConfig & { server: { open: boolean } }; -type RoutingStrategies = 'prefix-always' | 'prefix-other-locales'; +export type RoutingStrategies = + | 'pathname-prefix-always' + | 'pathname-prefix-other-locales' + | 'pathname-prefix-always-no-redirect'; export const AstroConfigSchema = z.object({ root: z @@ -329,17 +332,31 @@ export const AstroConfigSchema = z.object({ routing: z .object({ prefixDefaultLocale: z.boolean().default(false), + redirectToDefaultLocale: z.boolean().default(true), strategy: z.enum(['pathname']).default('pathname'), }) .default({}) + .refine( + ({ prefixDefaultLocale, redirectToDefaultLocale }) => { + return !(prefixDefaultLocale === false && redirectToDefaultLocale === false); + }, + { + message: + 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.', + } + ) .transform((routing) => { let strategy: RoutingStrategies; switch (routing.strategy) { case 'pathname': { if (routing.prefixDefaultLocale === true) { - strategy = 'prefix-always'; + if (routing.redirectToDefaultLocale) { + strategy = 'pathname-prefix-always'; + } else { + strategy = 'pathname-prefix-always-no-redirect'; + } } else { - strategy = 'prefix-other-locales'; + strategy = 'pathname-prefix-other-locales'; } } } diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index c04c9b2b5021..e8264e881a9d 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -16,6 +16,7 @@ import { computePreferredLocaleList, } from '../render/context.js'; import { type Environment, type RenderContext } from '../render/index.js'; +import type { RoutingStrategies } from '../config/schema.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -27,7 +28,7 @@ type CreateAPIContext = { props: Record; adapterName?: string; locales: Locales | undefined; - routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; + routingStrategy: RoutingStrategies | undefined; defaultLocale: string | undefined; }; diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 459b2b8b48cc..9b94f4ad2f8f 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -11,6 +11,7 @@ import { normalizeTheLocale, toCodes } from '../../i18n/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import type { Environment } from './environment.js'; import { getParamsAndProps } from './params-and-props.js'; +import type { RoutingStrategies } from '../config/schema.js'; const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -31,7 +32,7 @@ export interface RenderContext { locals?: object; locales: Locales | undefined; defaultLocale: string | undefined; - routing: 'prefix-always' | 'prefix-other-locales' | undefined; + routing: RoutingStrategies | undefined; } export type CreateRenderContextArgs = Partial< @@ -239,7 +240,7 @@ export function computePreferredLocaleList(request: Request, locales: Locales): export function computeCurrentLocale( request: Request, locales: Locales, - routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined, + routingStrategy: RoutingStrategies | undefined, defaultLocale: string | undefined ): undefined | string { const requestUrl = new URL(request.url); @@ -256,7 +257,7 @@ export function computeCurrentLocale( } } } - if (routingStrategy === 'prefix-other-locales') { + if (routingStrategy === 'pathname-prefix-other-locales') { return defaultLocale; } return undefined; diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 29b54ae85393..5faa6442c3e0 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -18,6 +18,7 @@ import { computePreferredLocale, computePreferredLocaleList, } from './context.js'; +import type { RoutingStrategies } from '../config/schema.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); const responseSentSymbol = Symbol.for('astro.responseSent'); @@ -53,7 +54,7 @@ export interface CreateResultArgs { cookies?: AstroCookies; locales: Locales | undefined; defaultLocale: string | undefined; - routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; + routingStrategy: RoutingStrategies | undefined; } function getFunctionExpression(slot: any) { diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 191e13417604..e74df1eed47e 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -516,7 +516,7 @@ export function createRouteManifest( const i18n = settings.config.i18n; if (i18n) { // First we check if the user doesn't have an index page. - if (i18n.routing === 'prefix-always') { + if (i18n.routing === 'pathname-prefix-always') { let index = routes.find((route) => route.route === '/'); if (!index) { let relativePath = path.relative( @@ -583,7 +583,7 @@ export function createRouteManifest( // Work done, now we start creating "fallback" routes based on the configuration - if (i18n.routing === 'prefix-always') { + if (i18n.routing === 'pathname-prefix-always') { // we attempt to retrieve the index page of the default locale const defaultLocaleRoutes = routesByLocale.get(i18n.defaultLocale); if (defaultLocaleRoutes) { @@ -656,7 +656,7 @@ export function createRouteManifest( let route: string; if ( fallbackToLocale === i18n.defaultLocale && - i18n.routing === 'prefix-other-locales' + i18n.routing === 'pathname-prefix-other-locales' ) { if (fallbackToRoute.pathname) { pathname = `/${fallbackFromLocale}${fallbackToRoute.pathname}`; diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index a0dcfdff0e9a..a575dfa97316 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -3,6 +3,7 @@ import type { AstroConfig, Locales } from '../@types/astro.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; import { MissingLocale } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/index.js'; +import type { RoutingStrategies } from '../core/config/schema.js'; type GetLocaleRelativeUrl = GetLocaleOptions & { locale: string; @@ -10,7 +11,7 @@ type GetLocaleRelativeUrl = GetLocaleOptions & { locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; - routing?: 'prefix-always' | 'prefix-other-locales'; + routing?: RoutingStrategies; defaultLocale: string; }; @@ -45,7 +46,7 @@ export function getLocaleRelativeUrl({ path, prependWith, normalizeLocale = true, - routing = 'prefix-other-locales', + routing = 'pathname-prefix-other-locales', defaultLocale, }: GetLocaleRelativeUrl) { const codeToUse = peekCodePathToUse(_locales, locale); @@ -57,7 +58,7 @@ export function getLocaleRelativeUrl({ } const pathsToJoin = [base, prependWith]; const normalizedLocale = normalizeLocale ? normalizeTheLocale(codeToUse) : codeToUse; - if (routing === 'prefix-always') { + if (routing === 'pathname-prefix-always') { pathsToJoin.push(normalizedLocale); } else if (locale !== defaultLocale) { pathsToJoin.push(normalizedLocale); @@ -88,7 +89,7 @@ type GetLocalesBaseUrl = GetLocaleOptions & { locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; - routing?: 'prefix-always' | 'prefix-other-locales'; + routing?: RoutingStrategies; defaultLocale: string; }; @@ -100,7 +101,7 @@ export function getLocaleRelativeUrlList({ path, prependWith, normalizeLocale = false, - routing = 'prefix-other-locales', + routing = 'pathname-prefix-other-locales', defaultLocale, }: GetLocalesBaseUrl) { const locales = toPaths(_locales); @@ -108,7 +109,7 @@ export function getLocaleRelativeUrlList({ const pathsToJoin = [base, prependWith]; const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale; - if (routing === 'prefix-always') { + if (routing === 'pathname-prefix-always') { pathsToJoin.push(normalizedLocale); } else if (locale !== defaultLocale) { pathsToJoin.push(normalizedLocale); diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 12732d880eb0..ee23a0421b01 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -2,6 +2,7 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; import type { Locales, MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; import { getPathByLocale, normalizeTheLocale } from './index.js'; +import { shouldAppendForwardSlash } from '../core/build/util.js'; const routeDataSymbol = Symbol.for('astro.routeData'); @@ -54,30 +55,52 @@ export function createI18nMiddleware( if (response instanceof Response) { const pathnameContainsDefaultLocale = url.pathname.includes(`/${defaultLocale}`); - if (i18n.routing === 'prefix-other-locales' && pathnameContainsDefaultLocale) { - const newLocation = url.pathname.replace(`/${defaultLocale}`, ''); - response.headers.set('Location', newLocation); - return new Response(null, { - status: 404, - headers: response.headers, - }); - } else if (i18n.routing === 'prefix-always') { - if (url.pathname === base + '/' || url.pathname === base) { - if (trailingSlash === 'always') { - return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); - } else { - return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); + switch (i18n.routing) { + case 'pathname-prefix-other-locales': { + if (pathnameContainsDefaultLocale) { + const newLocation = url.pathname.replace(`/${defaultLocale}`, ''); + response.headers.set('Location', newLocation); + return new Response(null, { + status: 404, + headers: response.headers, + }); + } + break; + } + + case 'pathname-prefix-always-no-redirect': { + // We return a 404 if: + // - the current path isn't a root. e.g. / or / + // - the URL doesn't contain a locale + const isRoot = url.pathname === base + '/' || url.pathname === base; + if (!(isRoot || pathnameHasLocale(url.pathname, i18n.locales))) { + return new Response(null, { + status: 404, + headers: response.headers, + }); } + break; } - // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content. - else if (!pathnameHasLocale(url.pathname, i18n.locales)) { - return new Response(null, { - status: 404, - headers: response.headers, - }); + case 'pathname-prefix-always': { + if (url.pathname === base + '/' || url.pathname === base) { + if (trailingSlash === 'always') { + return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); + } else { + return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); + } + } + + // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content. + else if (!pathnameHasLocale(url.pathname, i18n.locales)) { + return new Response(null, { + status: 404, + headers: response.headers, + }); + } } } + if (response.status >= 300 && fallback) { const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : []; @@ -103,7 +126,7 @@ export function createI18nMiddleware( let newPathname: string; // If a locale falls back to the default locale, we want to **remove** the locale because // the default locale doesn't have a prefix - if (pathFallbackLocale === defaultLocale && routing === 'prefix-other-locales') { + if (pathFallbackLocale === defaultLocale && routing === 'pathname-prefix-other-locales') { newPathname = url.pathname.replace(`/${urlLocale}`, ``); } else { newPathname = url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`); diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro index e69de29bb2d1..51507e040d25 100644 --- a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro @@ -0,0 +1,8 @@ + + + Astro + + +I am index + + diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 567dc040e30e..c7a2d99b2e0e 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -231,7 +231,37 @@ describe('[DEV] i18n routing', () => { }); }); - describe('i18n routing with routing strategy [prefix-always]', () => { + describe('i18n routing with routing strategy [pathname-prefix-always-no-redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-prefix-always/', + i18n: { + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should NOT redirect to the index of the default locale', async () => { + const response = await fixture.fetch('/new-site'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('I am index'); + }); + }); + + describe('i18n routing with routing strategy [pathname-prefix-always]', () => { /** @type {import('./test-utils').Fixture} */ let fixture; /** @type {import('./test-utils').DevServer} */ @@ -607,7 +637,31 @@ describe('[SSG] i18n routing', () => { }); }); - describe('i18n routing with routing strategy [prefix-always]', () => { + describe('i18n routing with routing strategy [pathname-prefix-always-no-redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-prefix-always/', + i18n: { + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }); + await fixture.build(); + }); + + it('should NOT redirect to the index of the default locale', async () => { + const html = await fixture.readFile('/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('I am index'); + }); + }); + + describe('i18n routing with routing strategy [pathname-prefix-always]', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -776,7 +830,7 @@ describe('[SSG] i18n routing', () => { }); }); - describe('i18n routing with fallback and [prefix-always]', () => { + describe('i18n routing with fallback and [pathname-prefix-always]', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -1019,7 +1073,35 @@ describe('[SSR] i18n routing', () => { }); }); - describe('i18n routing with routing strategy [prefix-always]', () => { + describe('i18n routing with routing strategy [pathname-prefix-always-no-redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-prefix-always/', + output: 'server', + adapter: testAdapter(), + i18n: { + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should NOT redirect the index to the default locale', async () => { + let request = new Request('http://example.com/new-site'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('I am index'); + }); + }); + + describe('i18n routing with routing strategy [pathname-prefix-always]', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -1158,7 +1240,7 @@ describe('[SSR] i18n routing', () => { expect(response.status).to.equal(404); }); - describe('with routing strategy [prefix-always]', () => { + describe('with routing strategy [pathname-prefix-always]', () => { before(async () => { fixture = await loadFixture({ root: './fixtures/i18n-routing-fallback/', @@ -1351,7 +1433,7 @@ describe('[SSR] i18n routing', () => { }); }); - describe('with [prefix-always]', () => { + describe('with [pathname-prefix-always]', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 341ed47b422c..477bc4bedac3 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -193,5 +193,25 @@ describe('Config Validation', () => { "You can't use the default locale as a key. The default locale can only be used as value." ); }); + + it('errors if `i18n.prefixDefaultLocale` is `false` and `i18n.redirectToDefaultLocale` is `true`', async () => { + const configError = await validateConfig( + { + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: false, + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.' + ); + }); }); }); diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js index ad5a2fa23e0e..c9d7615429f6 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ b/packages/astro/test/units/i18n/astro_i18n.test.js @@ -275,7 +275,7 @@ describe('getLocaleRelativeUrl', () => { ).to.eq('/blog/en-au/'); }); - it('should return the default locale when routing strategy is [prefix-always]', () => { + it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { /** * * @type {import("../../../dist/@types").AstroUserConfig} @@ -286,7 +286,7 @@ describe('getLocaleRelativeUrl', () => { i18n: { defaultLocale: 'en', locales: ['en', 'es', 'en_US', 'en_AU'], - routing: 'prefix-always', + routing: 'pathname-prefix-always', }, }, }; @@ -520,7 +520,7 @@ describe('getLocaleRelativeUrlList', () => { ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']); }); - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStategy: prefix-always]', () => { + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStategy: pathname-prefix-always]', () => { /** * * @type {import("../../../dist/@types").AstroUserConfig} @@ -530,7 +530,7 @@ describe('getLocaleRelativeUrlList', () => { i18n: { defaultLocale: 'en', locales: ['en', 'en_US', 'es'], - routing: 'prefix-always', + routing: 'pathname-prefix-always', }, }, }; @@ -829,7 +829,7 @@ describe('getLocaleAbsoluteUrl', () => { ).to.eq('/blog/en-us/'); }); - it('should return the default locale when routing strategy is [prefix-always]', () => { + it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { /** * * @type {import("../../../dist/@types").AstroUserConfig} @@ -840,7 +840,7 @@ describe('getLocaleAbsoluteUrl', () => { i18n: { defaultLocale: 'en', locales: ['en', 'es', 'en_US', 'en_AU'], - routing: 'prefix-always', + routing: 'pathname-prefix-always', }, }, }; @@ -1112,7 +1112,7 @@ describe('getLocaleAbsoluteUrlList', () => { ]); }); - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStategy: prefix-always]', () => { + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStategy: pathname-prefix-always]', () => { /** * * @type {import("../../../dist/@types").AstroUserConfig} @@ -1122,7 +1122,7 @@ describe('getLocaleAbsoluteUrlList', () => { i18n: { defaultLocale: 'en', locales: ['en', 'en_US', 'es'], - routing: 'prefix-always', + routing: 'pathname-prefix-always', }, }, };