Skip to content

Commit

Permalink
feat(i18n): refined locales (#9200)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* chore: apply suggestions

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* fix: merge

---------

Co-authored-by: Happydev <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
3 people authored Nov 30, 2023
1 parent 7b74ec4 commit b4b851f
Show file tree
Hide file tree
Showing 27 changed files with 822 additions and 77 deletions.
26 changes: 26 additions & 0 deletions .changeset/fluffy-dolls-sleep.md
Original file line number Diff line number Diff line change
@@ -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 changes: 63 additions & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down
12 changes: 8 additions & 4 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2026,6 +2028,8 @@ export interface AstroInternationalizationFeature {
detectBrowserLanguage?: SupportsKind;
}

export type Locales = (string | { codes: string[]; path: string })[];

export interface AstroAdapter {
name: string;
serverEntrypoint?: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
Locales,
RouteData,
SerializedRouteData,
SSRComponentMetadata,
Expand Down Expand Up @@ -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;
};

Expand Down
19 changes: 17 additions & 2 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/core/endpoint/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
};
Expand Down
6 changes: 2 additions & 4 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
69 changes: 49 additions & 20 deletions packages/astro/src/core/render/context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 !== '*') {
Expand All @@ -170,41 +171,63 @@ 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;
}
}
}
}
}
}

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) {
const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);

// 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);
}
}
}
}
}
}
Expand All @@ -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);
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/render/result.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AstroGlobal,
AstroGlobalPartial,
Locales,
Params,
SSRElement,
SSRLoadedRenderer,
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit b4b851f

Please sign in to comment.