mirror of
https://github.com/withastro/astro.git
synced 2025-03-31 23:31:30 -05:00
feat(i18n): refined locales (#9200)
* feat(i18n): refined locales * feat: support granular locales inside the virtual module * feat: expose new function to retrieve the path by locale * chore: update fallback logic * chore: fix other broken cases inside source code * chore: add new test cases * maybe fix the type for codegen * changelog * Apply suggestions from code review Co-authored-by: Happydev <81974850+MoustaphaDev@users.noreply.github.com> * chore: apply suggestions * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * fix: merge --------- Co-authored-by: Happydev <81974850+MoustaphaDev@users.noreply.github.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
7b74ec4ba4
commit
b4b851f5a4
27 changed files with 822 additions and 77 deletions
26
.changeset/fluffy-dolls-sleep.md
Normal file
26
.changeset/fluffy-dolls-sleep.md
Normal file
|
@ -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`.
|
63
packages/astro/client.d.ts
vendored
63
packages/astro/client.d.ts
vendored
|
@ -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' {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
`;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 !== '/') {
|
||||
|
|
|
@ -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
|
||||
|
|
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro
vendored
Normal file
|
@ -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>
|
12
packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro
vendored
Normal file
12
packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
const currentLocale = Astro.currentLocale;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Espanol
|
||||
Current Locale: {currentLocale ? currentLocale : "none"}
|
||||
</body>
|
||||
</html>
|
|
@ -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
|
||||
|
|
18
packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro
vendored
Normal file
|
@ -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>
|
12
packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro
vendored
Normal file
12
packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
const currentLocale = Astro.currentLocale;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Espanol
|
||||
Current Locale: {currentLocale ? currentLocale : "none"}
|
||||
</body>
|
||||
</html>
|
18
packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro
vendored
Normal file
|
@ -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>
|
12
packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro
vendored
Normal file
12
packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
const currentLocale = Astro.currentLocale;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Espanol
|
||||
Current Locale: {currentLocale ? currentLocale : "none"}
|
||||
</body>
|
||||
</html>
|
|
@ -5,7 +5,13 @@ export default defineConfig({
|
|||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: [
|
||||
'en', 'pt', 'it'
|
||||
'en',
|
||||
'pt',
|
||||
'it',
|
||||
{
|
||||
path: "spanish",
|
||||
codes: ["es", "es-SP"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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/',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue