0
Fork 0
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:
Emanuele Stoppa 2023-11-30 16:13:58 -05:00 committed by GitHub
parent 7b74ec4ba4
commit b4b851f5a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 822 additions and 77 deletions

View 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`.

View file

@ -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' {

View file

@ -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;

View file

@ -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;
};

View file

@ -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,

View file

@ -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;
};

View file

@ -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;

View file

@ -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);
}
}
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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);

View file

@ -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);
`;
}
},

View file

@ -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 !== '/') {

View file

@ -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

View 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>

View file

@ -0,0 +1,12 @@
---
const currentLocale = Astro.currentLocale;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
Espanol
Current Locale: {currentLocale ? currentLocale : "none"}
</body>
</html>

View file

@ -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

View 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>

View file

@ -0,0 +1,12 @@
---
const currentLocale = Astro.currentLocale;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
Espanol
Current Locale: {currentLocale ? currentLocale : "none"}
</body>
</html>

View 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>

View file

@ -0,0 +1,12 @@
---
const currentLocale = Astro.currentLocale;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
Espanol
Current Locale: {currentLocale ? currentLocale : "none"}
</body>
</html>

View file

@ -5,7 +5,13 @@ export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: [
'en', 'pt', 'it'
'en',
'pt',
'it',
{
path: "spanish",
codes: ["es", "es-SP"]
}
]
}
},

View file

@ -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>

View file

@ -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', () => {

View file

@ -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(
{

View file

@ -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/',
]);
});