diff --git a/.changeset/fluffy-dolls-sleep.md b/.changeset/fluffy-dolls-sleep.md new file mode 100644 index 0000000000..02b698f1e4 --- /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 c3a7652ece..3b30d77008 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 cc2ddb775d..4ee22bb308 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 67ed771231..ab4a4fc2cd 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; 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 c126896549..855bad4615 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 eeaec22447..c04c9b2b50 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; 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 ac815b06f4..cf49c57c74 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 efb73d7666..459b2b8b48 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 2c37f38c42..9a745fd5a9 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 cfc7a44d8f..c582281ec3 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 `/`. - 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 937daf279a..1370087bcc 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; @@ -131,6 +133,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: @@ -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 f6a3e7cb99..12732d880e 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 bf6cff1713..a28481cac5 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 9f64af31f9..510658345e 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 1aae961c7c..4d51289669 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 0000000000..f560f94f5a --- /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; +--- + + + Astro + + +{content} + + 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 0000000000..d67e9de3f0 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + 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 6152d10393..03fd2b11d0 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 0000000000..f560f94f5a --- /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; +--- + + + Astro + + +{content} + + 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 0000000000..d67e9de3f0 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + 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 0000000000..f560f94f5a --- /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; +--- + + + Astro + + +{content} + + 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 0000000000..d67e9de3f0 --- /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; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs index 209ad40fd0..a3ee1e9c60 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 e6fa2ac2ff..ca33030dbe 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"); --- @@ -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} diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 67bba42b18..b9fca7731e 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 93dd0e28dd..f759be9b92 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 63e2df8332..126883d54d 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'], + }, + ], }, }, }; @@ -151,6 +176,16 @@ describe('getLocaleRelativeUrl', () => { }) ).to.eq('/blog/es/'); + expect( + getLocaleRelativeUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/italiano/'); + expect( getLocaleRelativeUrl({ locale: 'en', @@ -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/', ]); });