diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index c75ae7971..e489756f0 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -127,6 +127,11 @@ declare module 'astro:transitions/client' { export const navigate: TransitionRouterModule['navigate']; } +declare module 'astro:i18n' { + type I18nModule = typeof import('./dist/i18n/index.js'); + export const getI18nBaseUrl: (locale: string) => string; +} + declare module 'astro:middleware' { export * from 'astro/middleware/namespace'; } diff --git a/packages/astro/package.json b/packages/astro/package.json index e335a6461..a3f4158e4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -78,7 +78,8 @@ "default": "./dist/core/middleware/namespace.js" }, "./transitions": "./dist/transitions/index.js", - "./transitions/router": "./dist/transitions/router.js" + "./transitions": "./dist/transitions/index.js", + "./i18n": "./dist/i18n/index.js" }, "imports": { "#astro/*": "./dist/*.js" diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2217e76f2..674f2ba3f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1330,6 +1330,68 @@ export interface AstroUserConfig { * ``` */ optimizeHoistedScript?: boolean; + + // TODO review with docs team before merging to `main` + /** + * @docs + * @name experimental.i18n + * @type {object} + * @version 3.*.* + * @description + * + * Allows to configure the beaviour of the i18n routing + */ + i18n?: { + /** + * @docs + * @name experimental.i18n.defaultLocale + * @type {string} + * @version 3.*.* + * @description + * + * The default locale of your website/application + */ + defaultLocale: string; + /** + * @docs + * @name experimental.i18n.locales + * @type {string[]} + * @version 3.*.* + * @description + * + * A list of locales supported by the website. + */ + locales: string[]; + + /** + * @docs + * @name experimental.i18n.fallback + * @type {Record} + * @version 3.*.* + * @description + * + * The fallback system of the locales. By default, the fallback system affect the **content only**, and it doesn't + * do any redirects. + * + * This means that when attempting to navigate to a page that hasn't been translated, Astro will pull the content + * from the page of the default locale and render it. No redirects will happen. + */ + fallback?: Record; + + /** + * @docs + * @name experimental.i18n.detectBrowserLanguage + * @type {boolean} + * @version 3.*.* + * @description + * + * Whether Astro should detect the language of the browser - usually using the `Accept-Language` header. This is a feature + * that should be supported by the adapter. If detected, the adapter can decide to redirect the user to the localised version of the website. + * + * When set to `true`, you should make sure that the adapter you're using is able to provide this feature to you. + */ + detectBrowserLanguage: boolean; + }; }; } @@ -1790,6 +1852,11 @@ export type AstroFeatureMap = { * The adapter can emit static assets */ assets?: AstroAssetsFeature; + + /** + * List of features that orbit around the i18n routing + */ + i18n?: AstroInternalisationFeature; }; export interface AstroAssetsFeature { @@ -1804,6 +1871,13 @@ export interface AstroAssetsFeature { isSquooshCompatible?: boolean; } +export interface AstroInternalisationFeature { + /** + * Wether the adapter is able to detect the language of the browser, usually using the `Accept-Language` header. + */ + detectBrowserLanguage?: SupportsKind; +} + export interface AstroAdapter { name: string; serverEntrypoint?: string; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 4ac40d4c5..88c83a1e2 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -271,6 +271,43 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript), + i18n: z.optional( + z + .object({ + defaultLocale: z.string(), + locales: z.string().array(), + fallback: z.record(z.string(), z.string().array()).optional(), + detectBrowserLanguage: z.boolean().optional().default(false), + }) + .optional() + .refine((i18n) => { + if (i18n) { + const { defaultLocale, locales, fallback } = i18n; + if (locales.includes(defaultLocale)) { + return { + message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`, + }; + } + if (fallback) { + for (const [fallbackKey, fallbackArray] of Object.entries(fallback)) { + if (!locales.includes(fallbackKey)) { + return { + message: `The locale \`${fallbackKey}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, + }; + } + + for (const fallbackArrayKey of fallbackArray) { + if (!locales.includes(fallbackArrayKey)) { + return { + message: `The locale \`${fallbackArrayKey}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, + }; + } + } + } + } + } + }) + ), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts new file mode 100644 index 000000000..f32ec8e23 --- /dev/null +++ b/packages/astro/src/i18n/index.ts @@ -0,0 +1,21 @@ +import type { AstroConfig } from '../@types/astro.js'; +import type { Logger } from '../core/logger/core.js'; + +/** + * The base URL + */ +export function getI18nBaseUrl(locale: string, config: AstroConfig, logger: Logger) { + const base = config.base; + + if (!config.experimental.i18n) { + logger.error('i18n', "The project isn't using i18n features, no need to use this function."); + return base ? `/${base}/` : '/'; + } + + if (base) { + logger.debug('i18n', 'The project has a base directory, using it.'); + return `${base}/${locale}/`; + } else { + return `/${locale}/`; + } +} diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts new file mode 100644 index 000000000..9f504ea07 --- /dev/null +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -0,0 +1,26 @@ +import * as vite from 'vite'; +import type { AstroSettings } from '../@types/astro.js'; +import type { Logger } from '../core/logger/core.js'; + +const virtualModuleId = 'astro:i18n'; +const resolvedVirtualModuleId = '\0' + virtualModuleId; + +type AstroInternalization = { + settings: AstroSettings; + logger: Logger; +}; + +export default function astroInternalization({ settings }: AstroInternalization): vite.Plugin { + return { + name: 'astro:i18n', + load(id) { + if (id === resolvedVirtualModuleId) { + return ` + import { getI18nBaseUrl as getI18nBaseUrlInternal } from "astro/i18n"; + + export getI18nBaseUrl = (locale) => getI18nBaseUrlInternal(locale, ${settings.config}); + `; + } + }, + }; +} diff --git a/packages/astro/src/integrations/astroFeaturesValidation.ts b/packages/astro/src/integrations/astroFeaturesValidation.ts index 3231b1577..a26f42afb 100644 --- a/packages/astro/src/integrations/astroFeaturesValidation.ts +++ b/packages/astro/src/integrations/astroFeaturesValidation.ts @@ -23,6 +23,9 @@ const ALL_UNSUPPORTED: Required = { staticOutput: UNSUPPORTED, hybridOutput: UNSUPPORTED, assets: UNSUPPORTED_ASSETS_FEATURE, + i18n: { + detectBrowserLanguage: UNSUPPORTED, + }, }; type ValidationResult = { diff --git a/packages/astro/test/units/i18n/getI18nBaseUrl.test.js b/packages/astro/test/units/i18n/getI18nBaseUrl.test.js new file mode 100644 index 000000000..b4c5a32f4 --- /dev/null +++ b/packages/astro/test/units/i18n/getI18nBaseUrl.test.js @@ -0,0 +1,43 @@ +import { getI18nBaseUrl } from '../../../dist/i18n/index.js'; +import { expect } from 'chai'; +import { Logger } from '../../../dist/core/logger/core.js'; + +const logger = new Logger(); +describe('getI18nBaseUrl', () => { + it('should correctly return the URL with the base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + }, + }, + }; + + expect(getI18nBaseUrl('en', config, logger)).to.eq('/blog/en/'); + expect(getI18nBaseUrl('es', config, logger)).to.eq('/blog/es/'); + }); + + it('should correctly return the URL without base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + }, + }, + }; + + expect(getI18nBaseUrl('en', config, logger)).to.eq('/en/'); + expect(getI18nBaseUrl('es', config, logger)).to.eq('/es/'); + }); +});