summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/fluffy-dolls-sleep.md26
-rw-r--r--packages/astro/client.d.ts63
-rw-r--r--packages/astro/src/@types/astro.ts12
-rw-r--r--packages/astro/src/core/app/types.ts3
-rw-r--r--packages/astro/src/core/config/schema.ts19
-rw-r--r--packages/astro/src/core/endpoint/index.ts10
-rw-r--r--packages/astro/src/core/errors/errors-data.ts6
-rw-r--r--packages/astro/src/core/render/context.ts69
-rw-r--r--packages/astro/src/core/render/result.ts3
-rw-r--r--packages/astro/src/core/routing/manifest/create.ts16
-rw-r--r--packages/astro/src/i18n/index.ts107
-rw-r--r--packages/astro/src/i18n/middleware.ts47
-rw-r--r--packages/astro/src/i18n/vite-plugin-i18n.ts6
-rw-r--r--packages/astro/src/vite-plugin-astro-server/route.ts17
-rw-r--r--packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs5
-rw-r--r--packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro18
-rw-r--r--packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro12
-rw-r--r--packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs5
-rw-r--r--packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro18
-rw-r--r--packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro12
-rw-r--r--packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro18
-rw-r--r--packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro12
-rw-r--r--packages/astro/test/fixtures/i18n-routing/astro.config.mjs8
-rw-r--r--packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro9
-rw-r--r--packages/astro/test/i18n-routing.test.js208
-rw-r--r--packages/astro/test/units/config/config-validate.test.js46
-rw-r--r--packages/astro/test/units/i18n/astro_i18n.test.js124
27 files changed, 822 insertions, 77 deletions
diff --git a/.changeset/fluffy-dolls-sleep.md b/.changeset/fluffy-dolls-sleep.md
new file mode 100644
index 000000000..02b698f1e
--- /dev/null
+++ b/.changeset/fluffy-dolls-sleep.md
@@ -0,0 +1,26 @@
+---
+'astro': minor
+---
+
+Adds a new way to configure the `i18n.locales` array.
+
+Developers can now assign a custom URL path prefix that can span multiple language codes:
+
+```js
+// astro.config.mjs
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: "english",
+ locales: [
+ "de",
+ { path: "english", codes: ["en", "en-US"]},
+ "fr",
+ ],
+ routingStrategy: "prefix-always"
+ }
+ }
+})
+```
+
+With the above configuration, the URL prefix of the default locale will be `/english/`. When computing `Astro.preferredLocale`, Astro will use the `codes`.
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index c3a7652ec..3b30d7700 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -227,6 +227,69 @@ declare module 'astro:i18n' {
* Works like `getAbsoluteLocaleUrl` but it emits the absolute URLs for ALL locales:
*/
export const getAbsoluteLocaleUrlList: (path?: string, options?: GetLocaleOptions) => string[];
+
+ /**
+ * A function that return the `path` associated to a locale (defined as code). It's particularly useful in case you decide
+ * to use locales that are broken down in paths and codes.
+ *
+ * @param {string} code The code of the locale
+ * @returns {string} The path associated to the locale
+ *
+ * ## Example
+ *
+ * ```js
+ * // astro.config.mjs
+ *
+ * export default defineConfig({
+ * i18n: {
+ * locales: [
+ * { codes: ["it", "it-VT"], path: "italiano" },
+ * "es"
+ * ]
+ * }
+ * })
+ * ```
+ *
+ * ```js
+ * import { getPathByLocale } from "astro:i18n";
+ * getPathByLocale("it"); // returns "italiano"
+ * getPathByLocale("it-VT"); // returns "italiano"
+ * getPathByLocale("es"); // returns "es"
+ * ```
+ */
+ export const getPathByLocale: (code: string) => string;
+
+ /**
+ * A function that returns the preferred locale given a certain path. This is particularly useful if you configure a locale using
+ * `path` and `codes`. When you define multiple `code`, this function will return the first code of the array.
+ *
+ * Astro will treat the first code as the one that the user prefers.
+ *
+ * @param {string} path The path that maps to a locale
+ * @returns {string} The path associated to the locale
+ *
+ * ## Example
+ *
+ * ```js
+ * // astro.config.mjs
+ *
+ * export default defineConfig({
+ * i18n: {
+ * locales: [
+ * { codes: ["it-VT", "it"], path: "italiano" },
+ * "es"
+ * ]
+ * }
+ * })
+ * ```
+ *
+ * ```js
+ * import { getLocaleByPath } from "astro:i18n";
+ * getLocaleByPath("italiano"); // returns "it-VT" because that's the first code configured
+ * getLocaleByPath("es"); // returns "es"
+ * ```
+ */
+ export const getLocaleByPath: (path: string) => string;
}
declare module 'astro:middleware' {
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index cc2ddb775..4ee22bb30 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -1438,15 +1438,17 @@ export interface AstroUserConfig {
* @docs
* @kind h4
* @name experimental.i18n.locales
- * @type {string[]}
+ * @type {Locales}
* @version 3.5.0
* @description
*
- * A list of all locales supported by the website (e.g. `['en', 'es', 'pt-br']`). This list should also include the `defaultLocale`. This is a required field.
+ * A list of all locales supported by the website, including the `defaultLocale`. This is a required field.
*
- * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list.
+ * Languages can be listed either as individual codes (e.g. `['en', 'es', 'pt-br']`) or mapped to a shared `path` of codes (e.g. `{ path: "english", codes: ["en", "en-US"]}`). These codes will be used to determine the URL structure of your deployed site.
+ *
+ * No particular language code format or syntax is enforced, but your project folders containing your content files must match exactly the `locales` items in the list. In the case of multiple `codes` pointing to a custom URL path prefix, store your content files in a folder with the same name as the `path` configured.
*/
- locales: string[];
+ locales: Locales;
/**
* @docs
@@ -2026,6 +2028,8 @@ export interface AstroInternationalizationFeature {
detectBrowserLanguage?: SupportsKind;
}
+export type Locales = (string | { codes: string[]; path: string })[];
+
export interface AstroAdapter {
name: string;
serverEntrypoint?: string;
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
index 67ed77123..ab4a4fc2c 100644
--- a/packages/astro/src/core/app/types.ts
+++ b/packages/astro/src/core/app/types.ts
@@ -1,4 +1,5 @@
import type {
+ Locales,
RouteData,
SerializedRouteData,
SSRComponentMetadata,
@@ -56,7 +57,7 @@ export type SSRManifest = {
export type SSRManifestI18n = {
fallback?: Record<string, string>;
routing?: 'prefix-always' | 'prefix-other-locales';
- locales: string[];
+ locales: Locales;
defaultLocale: string;
};
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index c12689654..855bad461 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -316,7 +316,15 @@ export const AstroConfigSchema = z.object({
z
.object({
defaultLocale: z.string(),
- locales: z.string().array(),
+ locales: z.array(
+ z.union([
+ z.string(),
+ z.object({
+ path: z.string(),
+ codes: z.string().array().nonempty(),
+ }),
+ ])
+ ),
fallback: z.record(z.string(), z.string()).optional(),
routing: z
.object({
@@ -341,7 +349,14 @@ export const AstroConfigSchema = z.object({
.optional()
.superRefine((i18n, ctx) => {
if (i18n) {
- const { defaultLocale, locales, fallback } = i18n;
+ const { defaultLocale, locales: _locales, fallback } = i18n;
+ const locales = _locales.map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ } else {
+ return locale.path;
+ }
+ });
if (!locales.includes(defaultLocale)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts
index eeaec2244..c04c9b2b5 100644
--- a/packages/astro/src/core/endpoint/index.ts
+++ b/packages/astro/src/core/endpoint/index.ts
@@ -1,4 +1,10 @@
-import type { APIContext, EndpointHandler, MiddlewareHandler, Params } from '../../@types/astro.js';
+import type {
+ APIContext,
+ EndpointHandler,
+ Locales,
+ MiddlewareHandler,
+ Params,
+} from '../../@types/astro.js';
import { renderEndpoint } from '../../runtime/server/index.js';
import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js';
@@ -20,7 +26,7 @@ type CreateAPIContext = {
site?: string;
props: Record<string, any>;
adapterName?: string;
- locales: string[] | undefined;
+ locales: Locales | undefined;
routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined;
defaultLocale: string | undefined;
};
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index ac815b06f..cf49c57c7 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -1276,10 +1276,8 @@ export const UnsupportedConfigTransformError = {
export const MissingLocale = {
name: 'MissingLocaleError',
title: 'The provided locale does not exist.',
- message: (locale: string, locales: string[]) => {
- return `The locale \`${locale}\` does not exist in the configured locales. Available locales: ${locales.join(
- ', '
- )}.`;
+ message: (locale: string) => {
+ return `The locale/path \`${locale}\` does not exist in the configured \`i18n.locales\`.`;
},
} satisfies ErrorData;
diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts
index efb73d766..459b2b8b4 100644
--- a/packages/astro/src/core/render/context.ts
+++ b/packages/astro/src/core/render/context.ts
@@ -1,12 +1,13 @@
import type {
ComponentInstance,
+ Locales,
Params,
Props,
RouteData,
SSRElement,
SSRResult,
} from '../../@types/astro.js';
-import { normalizeTheLocale } from '../../i18n/index.js';
+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';
@@ -28,7 +29,7 @@ export interface RenderContext {
params: Params;
props: Props;
locals?: object;
- locales: string[] | undefined;
+ locales: Locales | undefined;
defaultLocale: string | undefined;
routing: 'prefix-always' | 'prefix-other-locales' | undefined;
}
@@ -143,8 +144,8 @@ export function parseLocale(header: string): BrowserLocale[] {
return result;
}
-function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: string[]) {
- const normalizedLocales = locales.map(normalizeTheLocale);
+function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: Locales) {
+ const normalizedLocales = toCodes(locales).map(normalizeTheLocale);
return browserLocaleList
.filter((browserLocale) => {
if (browserLocale.locale !== '*') {
@@ -170,18 +171,26 @@ function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: strin
* If multiple locales are present in the header, they are sorted by their quality value and the highest is selected as current locale.
*
*/
-export function computePreferredLocale(request: Request, locales: string[]): string | undefined {
+export function computePreferredLocale(request: Request, locales: Locales): string | undefined {
const acceptHeader = request.headers.get('Accept-Language');
let result: string | undefined = undefined;
if (acceptHeader) {
const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);
const firstResult = browserLocaleList.at(0);
- if (firstResult) {
- if (firstResult.locale !== '*') {
- result = locales.find(
- (locale) => normalizeTheLocale(locale) === normalizeTheLocale(firstResult.locale)
- );
+ if (firstResult && firstResult.locale !== '*') {
+ for (const currentLocale of locales) {
+ if (typeof currentLocale === 'string') {
+ if (normalizeTheLocale(currentLocale) === normalizeTheLocale(firstResult.locale)) {
+ result = currentLocale;
+ }
+ } else {
+ for (const currentCode of currentLocale.codes) {
+ if (normalizeTheLocale(currentCode) === normalizeTheLocale(firstResult.locale)) {
+ result = currentLocale.path;
+ }
+ }
+ }
}
}
}
@@ -189,7 +198,7 @@ export function computePreferredLocale(request: Request, locales: string[]): str
return result;
}
-export function computePreferredLocaleList(request: Request, locales: string[]) {
+export function computePreferredLocaleList(request: Request, locales: Locales): string[] {
const acceptHeader = request.headers.get('Accept-Language');
let result: string[] = [];
if (acceptHeader) {
@@ -197,14 +206,28 @@ export function computePreferredLocaleList(request: Request, locales: string[])
// SAFETY: bang operator is safe because checked by the previous condition
if (browserLocaleList.length === 1 && browserLocaleList.at(0)!.locale === '*') {
- return locales;
+ return locales.map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ } else {
+ // SAFETY: codes is never empty
+ return locale.codes.at(0)!;
+ }
+ });
} else if (browserLocaleList.length > 0) {
for (const browserLocale of browserLocaleList) {
- const found = locales.find(
- (l) => normalizeTheLocale(l) === normalizeTheLocale(browserLocale.locale)
- );
- if (found) {
- result.push(found);
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (normalizeTheLocale(loopLocale) === normalizeTheLocale(browserLocale.locale)) {
+ result.push(loopLocale);
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === browserLocale.locale) {
+ result.push(loopLocale.path);
+ }
+ }
+ }
}
}
}
@@ -215,15 +238,21 @@ export function computePreferredLocaleList(request: Request, locales: string[])
export function computeCurrentLocale(
request: Request,
- locales: string[],
+ locales: Locales,
routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined,
defaultLocale: string | undefined
): undefined | string {
const requestUrl = new URL(request.url);
for (const segment of requestUrl.pathname.split('/')) {
for (const locale of locales) {
- if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
- return locale;
+ if (typeof locale === 'string') {
+ if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
+ return locale;
+ }
+ } else {
+ if (locale.path === segment) {
+ return locale.codes.at(0);
+ }
}
}
}
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index 2c37f38c4..9a745fd5a 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -1,6 +1,7 @@
import type {
AstroGlobal,
AstroGlobalPartial,
+ Locales,
Params,
SSRElement,
SSRLoadedRenderer,
@@ -50,7 +51,7 @@ export interface CreateResultArgs {
status: number;
locals: App.Locals;
cookies?: AstroCookies;
- locales: string[] | undefined;
+ locales: Locales | undefined;
defaultLocale: string | undefined;
routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined;
}
diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts
index cfc7a44d8..c582281ec 100644
--- a/packages/astro/src/core/routing/manifest/create.ts
+++ b/packages/astro/src/core/routing/manifest/create.ts
@@ -18,6 +18,7 @@ import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
import { removeLeadingForwardSlash, slash } from '../../path.js';
import { resolvePages } from '../../util.js';
import { getRouteGenerator } from './generator.js';
+import { getPathByLocale } from '../../../i18n/index.js';
const require = createRequire(import.meta.url);
interface Item {
@@ -502,7 +503,20 @@ export function createRouteManifest(
// First loop
// We loop over the locales minus the default locale and add only the routes that contain `/<locale>`.
- for (const locale of i18n.locales.filter((loc) => loc !== i18n.defaultLocale)) {
+ const filteredLocales = i18n.locales
+ .filter((loc) => {
+ if (typeof loc === 'string') {
+ return loc !== i18n.defaultLocale;
+ }
+ return loc.path !== i18n.defaultLocale;
+ })
+ .map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ }
+ return locale.path;
+ });
+ for (const locale of filteredLocales) {
for (const route of setRoutes) {
if (!route.route.includes(`/${locale}`)) {
continue;
diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts
index 937daf279..1370087bc 100644
--- a/packages/astro/src/i18n/index.ts
+++ b/packages/astro/src/i18n/index.ts
@@ -1,5 +1,5 @@
import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path';
-import type { AstroConfig } from '../@types/astro.js';
+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';
@@ -7,7 +7,7 @@ import { AstroError } from '../core/errors/index.js';
type GetLocaleRelativeUrl = GetLocaleOptions & {
locale: string;
base: string;
- locales: string[];
+ locales: Locales;
trailingSlash: AstroConfig['trailingSlash'];
format: AstroConfig['build']['format'];
routingStrategy?: 'prefix-always' | 'prefix-other-locales';
@@ -39,7 +39,7 @@ type GetLocaleAbsoluteUrl = GetLocaleRelativeUrl & {
export function getLocaleRelativeUrl({
locale,
base,
- locales,
+ locales: _locales,
trailingSlash,
format,
path,
@@ -48,14 +48,15 @@ export function getLocaleRelativeUrl({
routingStrategy = 'prefix-other-locales',
defaultLocale,
}: GetLocaleRelativeUrl) {
- if (!locales.includes(locale)) {
+ const codeToUse = peekCodePathToUse(_locales, locale);
+ if (!codeToUse) {
throw new AstroError({
...MissingLocale,
- message: MissingLocale.message(locale, locales),
+ message: MissingLocale.message(locale),
});
}
const pathsToJoin = [base, prependWith];
- const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale;
+ const normalizedLocale = normalizeLocale ? normalizeTheLocale(codeToUse) : codeToUse;
if (routingStrategy === 'prefix-always') {
pathsToJoin.push(normalizedLocale);
} else if (locale !== defaultLocale) {
@@ -84,7 +85,7 @@ export function getLocaleAbsoluteUrl({ site, ...rest }: GetLocaleAbsoluteUrl) {
type GetLocalesBaseUrl = GetLocaleOptions & {
base: string;
- locales: string[];
+ locales: Locales;
trailingSlash: AstroConfig['trailingSlash'];
format: AstroConfig['build']['format'];
routingStrategy?: 'prefix-always' | 'prefix-other-locales';
@@ -93,7 +94,7 @@ type GetLocalesBaseUrl = GetLocaleOptions & {
export function getLocaleRelativeUrlList({
base,
- locales,
+ locales: _locales,
trailingSlash,
format,
path,
@@ -102,6 +103,7 @@ export function getLocaleRelativeUrlList({
routingStrategy = 'prefix-other-locales',
defaultLocale,
}: GetLocalesBaseUrl) {
+ const locales = toPaths(_locales);
return locales.map((locale) => {
const pathsToJoin = [base, prependWith];
const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale;
@@ -132,6 +134,45 @@ export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl
}
/**
+ * Given a locale (code), it returns its corresponding path
+ * @param locale
+ * @param locales
+ */
+export function getPathByLocale(locale: string, locales: Locales) {
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (loopLocale === locale) {
+ return loopLocale;
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === locale) {
+ return loopLocale.path;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * An utility function that retrieves the preferred locale that correspond to a path.
+ *
+ * @param locale
+ * @param locales
+ */
+export function getLocaleByPath(path: string, locales: Locales): string | undefined {
+ for (const locale of locales) {
+ if (typeof locale !== 'string') {
+ // the first code is the one that user usually wants
+ const code = locale.codes.at(0);
+ return code;
+ }
+ 1;
+ }
+ return undefined;
+}
+
+/**
*
* Given a locale, this function:
* - replaces the `_` with a `-`;
@@ -140,3 +181,53 @@ export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl
export function normalizeTheLocale(locale: string): string {
return locale.replaceAll('_', '-').toLowerCase();
}
+
+/**
+ * Returns an array of only locales, by picking the `code`
+ * @param locales
+ */
+export function toCodes(locales: Locales): string[] {
+ const codes: string[] = [];
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ codes.push(locale);
+ } else {
+ for (const code of locale.codes) {
+ codes.push(code);
+ }
+ }
+ }
+ return codes;
+}
+
+/**
+ * It returns the array of paths
+ * @param locales
+ */
+export function toPaths(locales: Locales): string[] {
+ return locales.map((loopLocale) => {
+ if (typeof loopLocale === 'string') {
+ return loopLocale;
+ } else {
+ return loopLocale.path;
+ }
+ });
+}
+
+function peekCodePathToUse(locales: Locales, locale: string): undefined | string {
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (loopLocale === locale) {
+ return loopLocale;
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === locale) {
+ return loopLocale.path;
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts
index f6a3e7cb9..12732d880 100644
--- a/packages/astro/src/i18n/middleware.ts
+++ b/packages/astro/src/i18n/middleware.ts
@@ -1,18 +1,26 @@
import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path';
-import type { MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js';
+import type { Locales, MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js';
import type { PipelineHookFunction } from '../core/pipeline.js';
+import { getPathByLocale, normalizeTheLocale } from './index.js';
const routeDataSymbol = Symbol.for('astro.routeData');
-// Checks if the pathname doesn't have any locale, exception for the defaultLocale, which is ignored on purpose
-function checkIsLocaleFree(pathname: string, locales: string[]): boolean {
- for (const locale of locales) {
- if (pathname.includes(`/${locale}`)) {
- return false;
+// Checks if the pathname has any locale, exception for the defaultLocale, which is ignored on purpose.
+function pathnameHasLocale(pathname: string, locales: Locales): boolean {
+ const segments = pathname.split('/');
+ for (const segment of segments) {
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) {
+ return true;
+ }
+ } else if (segment === locale.path) {
+ return true;
+ }
}
}
- return true;
+ return false;
}
export function createI18nMiddleware(
@@ -45,9 +53,7 @@ export function createI18nMiddleware(
const response = await next();
if (response instanceof Response) {
- const separators = url.pathname.split('/');
const pathnameContainsDefaultLocale = url.pathname.includes(`/${defaultLocale}`);
- const isLocaleFree = checkIsLocaleFree(url.pathname, i18n.locales);
if (i18n.routing === 'prefix-other-locales' && pathnameContainsDefaultLocale) {
const newLocation = url.pathname.replace(`/${defaultLocale}`, '');
response.headers.set('Location', newLocation);
@@ -65,7 +71,7 @@ export function createI18nMiddleware(
}
// Astro can't know where the default locale is supposed to be, so it returns a 404 with no content.
- else if (isLocaleFree) {
+ else if (!pathnameHasLocale(url.pathname, i18n.locales)) {
return new Response(null, {
status: 404,
headers: response.headers,
@@ -75,17 +81,32 @@ export function createI18nMiddleware(
if (response.status >= 300 && fallback) {
const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : [];
- const urlLocale = separators.find((s) => locales.includes(s));
+ // we split the URL using the `/`, and then check in the returned array we have the locale
+ const segments = url.pathname.split('/');
+ const urlLocale = segments.find((segment) => {
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (locale === segment) {
+ return true;
+ }
+ } else if (locale.path === segment) {
+ return true;
+ }
+ }
+ return false;
+ });
if (urlLocale && fallbackKeys.includes(urlLocale)) {
const fallbackLocale = fallback[urlLocale];
+ // the user might have configured the locale using the granular locales, so we want to retrieve its corresponding path instead
+ const pathFallbackLocale = getPathByLocale(fallbackLocale, locales);
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 (fallbackLocale === defaultLocale && routing === 'prefix-other-locales') {
+ if (pathFallbackLocale === defaultLocale && routing === 'prefix-other-locales') {
newPathname = url.pathname.replace(`/${urlLocale}`, ``);
} else {
- newPathname = url.pathname.replace(`/${urlLocale}`, `/${fallbackLocale}`);
+ newPathname = url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`);
}
return context.redirect(newPathname);
diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts
index bf6cff171..a28481cac 100644
--- a/packages/astro/src/i18n/vite-plugin-i18n.ts
+++ b/packages/astro/src/i18n/vite-plugin-i18n.ts
@@ -27,7 +27,8 @@ export default function astroInternationalization({
getLocaleRelativeUrlList as _getLocaleRelativeUrlList,
getLocaleAbsoluteUrl as _getLocaleAbsoluteUrl,
getLocaleAbsoluteUrlList as _getLocaleAbsoluteUrlList,
-
+ getPathByLocale as _getPathByLocale,
+ getLocaleByPath as _getLocaleByPath,
} from "astro/virtual-modules/i18n.js";
const base = ${JSON.stringify(settings.config.base)};
@@ -59,6 +60,9 @@ export default function astroInternationalization({
export const getRelativeLocaleUrlList = (path = "", opts) => _getLocaleRelativeUrlList({
base, path, trailingSlash, format, ...i18n, ...opts });
export const getAbsoluteLocaleUrlList = (path = "", opts) => _getLocaleAbsoluteUrlList({ base, path, trailingSlash, format, site, ...i18n, ...opts });
+
+ export const getPathByLocale = (locale) => _getPathByLocale(locale, i18n.locales);
+ export const getLocaleByPath = (locale) => _getLocaleByPath(locale, i18n.locales);
`;
}
},
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index 9f64af31f..510658345 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -34,6 +34,7 @@ import { preload } from './index.js';
import { getComponentMetadata } from './metadata.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { getScriptsForURL } from './scripts.js';
+import { normalizeTheLocale } from '../i18n/index.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@@ -195,7 +196,21 @@ export async function handleRoute({
.split('/')
.filter(Boolean)
.some((segment) => {
- return locales.includes(segment);
+ let found = false;
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
+ found = true;
+ break;
+ }
+ } else {
+ if (locale.path === segment) {
+ found = true;
+ break;
+ }
+ }
+ }
+ return found;
});
// Even when we have `config.base`, the pathname is still `/` because it gets stripped before
if (!pathNameHasLocale && pathname !== '/') {
diff --git a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
index 1aae961c7..4d5128966 100644
--- a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
+++ b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
@@ -6,7 +6,10 @@ export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: [
- 'en', 'pt', 'it'
+ 'en', 'pt', 'it', {
+ path: "spanish",
+ codes: ["es", "es-ar"]
+ }
],
routing: {
prefixDefaultLocale: true
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro
new file mode 100644
index 000000000..f560f94f5
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Lo siento" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+{content}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro
new file mode 100644
index 000000000..d67e9de3f
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro
@@ -0,0 +1,12 @@
+---
+const currentLocale = Astro.currentLocale;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+Espanol
+Current Locale: {currentLocale ? currentLocale : "none"}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs
index 6152d1039..03fd2b11d 100644
--- a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs
@@ -5,7 +5,10 @@ export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: [
- 'en', 'pt', 'it'
+ 'en', 'pt', 'it', {
+ path: "spanish",
+ codes: ["es", "es-ar"]
+ }
],
routing: {
prefixDefaultLocale: true
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro
new file mode 100644
index 000000000..f560f94f5
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Lo siento" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+{content}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro
new file mode 100644
index 000000000..d67e9de3f
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro
@@ -0,0 +1,12 @@
+---
+const currentLocale = Astro.currentLocale;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+Espanol
+Current Locale: {currentLocale ? currentLocale : "none"}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro
new file mode 100644
index 000000000..f560f94f5
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Lo siento" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+{content}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro
new file mode 100644
index 000000000..d67e9de3f
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro
@@ -0,0 +1,12 @@
+---
+const currentLocale = Astro.currentLocale;
+---
+<html>
+<head>
+ <title>Astro</title>
+</head>
+<body>
+Espanol
+Current Locale: {currentLocale ? currentLocale : "none"}
+</body>
+</html>
diff --git a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs
index 209ad40fd..a3ee1e9c6 100644
--- a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs
+++ b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs
@@ -5,7 +5,13 @@ export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: [
- 'en', 'pt', 'it'
+ 'en',
+ 'pt',
+ 'it',
+ {
+ path: "spanish",
+ codes: ["es", "es-SP"]
+ }
]
}
},
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro
index e6fa2ac2f..ca33030db 100644
--- a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro
@@ -1,8 +1,10 @@
---
-import { getRelativeLocaleUrl } from "astro:i18n";
+import { getRelativeLocaleUrl, getPathByLocale, getLocaleByPath } from "astro:i18n";
let about = getRelativeLocaleUrl("pt", "about");
-
+let spanish = getRelativeLocaleUrl("es", "about");
+let spainPath = getPathByLocale("es-SP");
+let localeByPath = getLocaleByPath("spanish");
---
<html>
@@ -13,5 +15,8 @@ let about = getRelativeLocaleUrl("pt", "about");
Virtual module doesn't break
About: {about}
+ About spanish: {spanish}
+ Spain path: {spainPath}
+ Preferred path: {localeByPath}
</body>
</html>
diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js
index 67bba42b1..b9fca7731 100644
--- a/packages/astro/test/i18n-routing.test.js
+++ b/packages/astro/test/i18n-routing.test.js
@@ -26,6 +26,9 @@ describe('astro:i18n virtual module', () => {
const text = await response.text();
expect(text).includes("Virtual module doesn't break");
expect(text).includes('About: /pt/about');
+ expect(text).includes('About spanish: /spanish/about');
+ expect(text).includes('Spain path: spanish');
+ expect(text).includes('Preferred path: es');
});
});
describe('[DEV] i18n routing', () => {
@@ -66,6 +69,16 @@ describe('[DEV] i18n routing', () => {
expect(await response2.text()).includes('Hola mundo');
});
+ it('should render localised page correctly when using path+codes', async () => {
+ const response = await fixture.fetch('/spanish/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+
+ const response2 = await fixture.fetch('/spanish/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
const response = await fixture.fetch('/it/start');
expect(response.status).to.equal(404);
@@ -114,6 +127,16 @@ describe('[DEV] i18n routing', () => {
expect(await response2.text()).includes('Hola mundo');
});
+ it('should render localised page correctly when using path+codes', async () => {
+ const response = await fixture.fetch('/new-site/spanish/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+
+ const response2 = await fixture.fetch('/new-site/spanish/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
const response = await fixture.fetch('/new-site/it/start');
expect(response.status).to.equal(404);
@@ -137,9 +160,18 @@ describe('[DEV] i18n routing', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'pt', 'it'],
+ locales: [
+ 'en',
+ 'pt',
+ 'it',
+ {
+ path: 'spanish',
+ codes: ['es', 'es-AR'],
+ },
+ ],
fallback: {
it: 'en',
+ spanish: 'en',
},
},
},
@@ -179,6 +211,16 @@ describe('[DEV] i18n routing', () => {
expect(await response2.text()).includes('Hola mundo');
});
+ it('should render localised page correctly when using path+codes', async () => {
+ const response = await fixture.fetch('/new-site/spanish/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+
+ const response2 = await fixture.fetch('/new-site/spanish/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Lo siento');
+ });
+
it('should redirect to the english locale, which is the first fallback', async () => {
const response = await fixture.fetch('/new-site/it/start');
expect(response.status).to.equal(200);
@@ -244,6 +286,16 @@ describe('[DEV] i18n routing', () => {
expect(await response2.text()).includes('Hola mundo');
});
+ it('should render localised page correctly when using path+codes', async () => {
+ const response = await fixture.fetch('/new-site/spanish/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+
+ const response2 = await fixture.fetch('/new-site/spanish/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Lo siento');
+ });
+
it('should not redirect to the english locale', async () => {
const response = await fixture.fetch('/new-site/it/start');
expect(response.status).to.equal(404);
@@ -287,9 +339,18 @@ describe('[DEV] i18n routing', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'pt', 'it'],
+ locales: [
+ 'en',
+ 'pt',
+ 'it',
+ {
+ path: 'spanish',
+ codes: ['es', 'es-AR'],
+ },
+ ],
fallback: {
it: 'en',
+ spanish: 'en',
},
routing: {
prefixDefaultLocale: false,
@@ -324,6 +385,16 @@ describe('[DEV] i18n routing', () => {
expect(await response2.text()).includes('Hola mundo');
});
+ it('should render localised page correctly when using path+codes', async () => {
+ const response = await fixture.fetch('/new-site/spanish/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+
+ const response2 = await fixture.fetch('/new-site/spanish/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
it('should redirect to the english locale, which is the first fallback', async () => {
const response = await fixture.fetch('/new-site/it/start');
expect(response.status).to.equal(200);
@@ -368,6 +439,16 @@ describe('[SSG] i18n routing', () => {
expect($('body').text()).includes('Hola mundo');
});
+ it('should render localised page correctly when it has codes+path', async () => {
+ let html = await fixture.readFile('/spanish/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Espanol');
+
+ html = await fixture.readFile('/spanish/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
try {
await fixture.readFile('/it/start/index.html');
@@ -422,6 +503,16 @@ describe('[SSG] i18n routing', () => {
expect($('body').text()).includes('Hola mundo');
});
+ it('should render localised page correctly when it has codes+path', async () => {
+ let html = await fixture.readFile('/spanish/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Espanol');
+
+ html = await fixture.readFile('/spanish/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
try {
await fixture.readFile('/it/start/index.html');
@@ -487,6 +578,16 @@ describe('[SSG] i18n routing', () => {
expect($('body').text()).includes('Hola mundo');
});
+ it('should render localised page correctly when it has codes+path', async () => {
+ let html = await fixture.readFile('/spanish/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Espanol');
+
+ html = await fixture.readFile('/spanish/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
try {
await fixture.readFile('/it/start/index.html');
@@ -547,6 +648,16 @@ describe('[SSG] i18n routing', () => {
expect($('body').text()).includes('Hola mundo');
});
+ it('should render localised page correctly when it has codes+path', async () => {
+ let html = await fixture.readFile('/spanish/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Espanol');
+
+ html = await fixture.readFile('/spanish/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Lo siento');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
try {
await fixture.readFile('/it/start/index.html');
@@ -595,9 +706,18 @@ describe('[SSG] i18n routing', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'pt', 'it'],
+ locales: [
+ 'en',
+ 'pt',
+ 'it',
+ {
+ path: 'spanish',
+ codes: ['es', 'es-AR'],
+ },
+ ],
fallback: {
it: 'en',
+ spanish: 'en',
},
},
},
@@ -625,6 +745,13 @@ describe('[SSG] i18n routing', () => {
expect($('body').text()).includes('Hola mundo');
});
+ it('should redirect to the english locale correctly when it has codes+path', async () => {
+ let html = await fixture.readFile('/spanish/start/index.html');
+ let $ = cheerio.load(html);
+ expect(html).to.include('http-equiv="refresh');
+ expect(html).to.include('url=/new-site/start');
+ });
+
it('should redirect to the english locale, which is the first fallback', async () => {
const html = await fixture.readFile('/it/start/index.html');
expect(html).to.include('http-equiv="refresh');
@@ -780,6 +907,13 @@ describe('[SSR] i18n routing', () => {
expect(await response.text()).includes('Oi essa e start');
});
+ it('should render localised page correctly when locale has codes+path', async () => {
+ let request = new Request('http://example.com/spanish/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
let request = new Request('http://example.com/it/start');
let response = await app.render(request);
@@ -868,6 +1002,13 @@ describe('[SSR] i18n routing', () => {
expect(await response.text()).includes('Oi essa e start');
});
+ it('should render localised page correctly when locale has codes+path', async () => {
+ let request = new Request('http://example.com/new-site/spanish/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
let request = new Request('http://example.com/new-site/it/start');
let response = await app.render(request);
@@ -916,6 +1057,13 @@ describe('[SSR] i18n routing', () => {
expect(await response.text()).includes('Oi essa e start');
});
+ it('should render localised page correctly when locale has codes+path', async () => {
+ let request = new Request('http://example.com/spanish/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Espanol');
+ });
+
it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => {
let request = new Request('http://example.com/it/start');
let response = await app.render(request);
@@ -961,9 +1109,18 @@ describe('[SSR] i18n routing', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'pt', 'it'],
+ locales: [
+ 'en',
+ 'pt',
+ 'it',
+ {
+ codes: ['es', 'es-AR'],
+ path: 'spanish',
+ },
+ ],
fallback: {
it: 'en',
+ spanish: 'en',
},
},
},
@@ -993,6 +1150,13 @@ describe('[SSR] i18n routing', () => {
expect(response.headers.get('location')).to.equal('/new-site/start');
});
+ it('should redirect to the english locale when locale has codes+path', async () => {
+ let request = new Request('http://example.com/new-site/spanish/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(302);
+ expect(response.headers.get('location')).to.equal('/new-site/start');
+ });
+
it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
let request = new Request('http://example.com/new-site/fr/start');
let response = await app.render(request);
@@ -1123,6 +1287,42 @@ describe('[SSR] i18n routing', () => {
expect(text).includes('Locale list: pt_BR, en_AU');
});
});
+
+ describe('in case the configured locales are granular', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing/',
+ output: 'server',
+ adapter: testAdapter(),
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ {
+ path: 'english',
+ codes: ['en', 'en-AU', 'pt-BR', 'es-US'],
+ },
+ ],
+ },
+ },
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('they should be still considered when parsing the Accept-Language header', async () => {
+ let request = new Request('http://example.com/preferred-locale', {
+ headers: {
+ 'Accept-Language': 'en-AU;q=0.1,pt-BR;q=0.9',
+ },
+ });
+ let response = await app.render(request);
+ const text = await response.text();
+ expect(response.status).to.equal(200);
+ expect(text).includes('Locale: english');
+ expect(text).includes('Locale list: english');
+ });
+ });
});
describe('current locale', () => {
diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js
index 93dd0e28d..f759be9b9 100644
--- a/packages/astro/test/units/config/config-validate.test.js
+++ b/packages/astro/test/units/config/config-validate.test.js
@@ -97,6 +97,52 @@ describe('Config Validation', () => {
);
});
+ it('errors if codes are empty', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'uk',
+ locales: [
+ 'es',
+ {
+ path: 'something',
+ codes: [],
+ },
+ ],
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal('Array must contain at least 1 element(s)');
+ });
+
+ it('errors if the default locale is not in path', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'uk',
+ locales: [
+ 'es',
+ {
+ path: 'something',
+ codes: ['en-UK'],
+ },
+ ],
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal(
+ 'The default locale `uk` is not present in the `i18n.locales` array.'
+ );
+ });
+
it('errors if a fallback value does not exist', async () => {
const configError = await validateConfig(
{
diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js
index 63e2df833..126883d54 100644
--- a/packages/astro/test/units/i18n/astro_i18n.test.js
+++ b/packages/astro/test/units/i18n/astro_i18n.test.js
@@ -18,7 +18,15 @@ describe('getLocaleRelativeUrl', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -82,6 +90,16 @@ describe('getLocaleRelativeUrl', () => {
format: 'file',
})
).to.throw;
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'it-VA',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/italiano/');
});
it('should correctly return the URL without base', () => {
@@ -127,7 +145,14 @@ describe('getLocaleRelativeUrl', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'es'],
+ locales: [
+ 'en',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -153,6 +178,16 @@ describe('getLocaleRelativeUrl', () => {
expect(
getLocaleRelativeUrl({
+ locale: 'it-VA',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/italiano/');
+
+ expect(
+ getLocaleRelativeUrl({
locale: 'en',
base: '/blog/',
...config.experimental.i18n,
@@ -328,7 +363,15 @@ describe('getLocaleRelativeUrlList', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -341,7 +384,7 @@ describe('getLocaleRelativeUrlList', () => {
trailingSlash: 'never',
format: 'directory',
})
- ).to.have.members(['/blog', '/blog/en_US', '/blog/es']);
+ ).to.have.members(['/blog', '/blog/en_US', '/blog/es', '/blog/italiano']);
});
it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => {
@@ -353,7 +396,15 @@ describe('getLocaleRelativeUrlList', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -366,7 +417,7 @@ describe('getLocaleRelativeUrlList', () => {
trailingSlash: 'always',
format: 'directory',
})
- ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']);
+ ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/', '/blog/italiano/']);
});
it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => {
@@ -507,7 +558,15 @@ describe('getLocaleAbsoluteUrl', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -577,6 +636,16 @@ describe('getLocaleAbsoluteUrl', () => {
site: 'https://example.com',
})
).to.throw;
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'it-VA',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/blog/italiano/');
});
it('should correctly return the URL without base', () => {
@@ -588,7 +657,14 @@ describe('getLocaleAbsoluteUrl', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'es'],
+ locales: [
+ 'en',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -613,6 +689,16 @@ describe('getLocaleAbsoluteUrl', () => {
site: 'https://example.com',
})
).to.eq('https://example.com/es/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'it-VA',
+ base: '/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/italiano/');
});
it('should correctly handle the trailing slash', () => {
@@ -837,7 +923,15 @@ describe('getLocaleAbsoluteUrlList', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -855,6 +949,7 @@ describe('getLocaleAbsoluteUrlList', () => {
'https://example.com/blog',
'https://example.com/blog/en_US',
'https://example.com/blog/es',
+ 'https://example.com/blog/italiano',
]);
});
@@ -897,7 +992,15 @@ describe('getLocaleAbsoluteUrlList', () => {
experimental: {
i18n: {
defaultLocale: 'en',
- locales: ['en', 'en_US', 'es'],
+ locales: [
+ 'en',
+ 'en_US',
+ 'es',
+ {
+ path: 'italiano',
+ codes: ['it', 'it-VA'],
+ },
+ ],
},
},
};
@@ -915,6 +1018,7 @@ describe('getLocaleAbsoluteUrlList', () => {
'https://example.com/blog/',
'https://example.com/blog/en_US/',
'https://example.com/blog/es/',
+ 'https://example.com/blog/italiano/',
]);
});