Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(i18n): refined locales #9200

Merged
merged 16 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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
6 changes: 4 additions & 2 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1473,15 +1473,15 @@ 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.
*
* No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list.
*/
locales: string[];
locales: (string | { path: string; codes: string[] })[];

/**
* @docs
Expand Down Expand Up @@ -2044,6 +2044,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>;
routingStrategy?: '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 @@ -344,7 +344,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(),
// TODO: properly add default when the feature goes of experimental
routingStrategy: z
Expand All @@ -355,7 +363,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
3 changes: 2 additions & 1 deletion packages/astro/src/core/endpoint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
APIContext,
EndpointHandler,
EndpointOutput,
Locales,
MiddlewareEndpointHandler,
MiddlewareHandler,
Params,
Expand Down Expand Up @@ -30,7 +31,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 @@ -1275,10 +1275,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;
routingStrategy: '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
24 changes: 23 additions & 1 deletion packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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,21 @@ 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;
} else {
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 All @@ -520,6 +535,9 @@ export function createRouteManifest(

// we loop over the remaining routes and add them to the default locale
for (const route of setRoutes) {
if (route.type !== 'page') {
continue;
}
const currentRoutes = routesByLocale.get(i18n.defaultLocale);
if (currentRoutes) {
currentRoutes.push(route);
Expand Down Expand Up @@ -571,6 +589,10 @@ export function createRouteManifest(
if (fallback.length > 0) {
for (const [fallbackFromLocale, fallbackToLocale] of fallback) {
let fallbackToRoutes;
// const fallbackFromLocale = getPathByLocale(_fallbackFromLocale, i18n.locales);
// if (!fallbackFromLocale) {
// continue;
// }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were these left in on purpose?

if (fallbackToLocale === i18n.defaultLocale) {
fallbackToRoutes = routesByLocale.get(i18n.defaultLocale);
} else {
Expand Down
Loading