diff --git a/.changeset/odd-mayflies-dress.md b/.changeset/odd-mayflies-dress.md
new file mode 100644
index 0000000000..25e7ef51aa
--- /dev/null
+++ b/.changeset/odd-mayflies-dress.md
@@ -0,0 +1,5 @@
+---
+'astro': minor
+---
+
+i18n routing
diff --git a/.changeset/rude-lizards-scream.md b/.changeset/rude-lizards-scream.md
new file mode 100644
index 0000000000..53252b5758
--- /dev/null
+++ b/.changeset/rude-lizards-scream.md
@@ -0,0 +1,51 @@
+---
+'astro': minor
+---
+
+Experimental support for i18n routing.
+
+Astro's experimental i18n routing API allows you to add your multilingual content with support for configuring a default language, computing relative page URLs, and accepting preferred languages provided by your visitor's browser. You can also specify fallback languages on a per-language basis so that your visitors can always be directed to existing content on your site.
+
+Enable the experimental routing option by adding an `i18n` object to your Astro configuration with a default location and a list of all languages to support:
+
+```js
+// astro.config.mjs
+import {defineConfig} from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: "en",
+ locales: ["en", "es", "pt-br"]
+ }
+ }
+})
+```
+
+Organize your content folders by locale depending on your `i18n.routingStrategy`, and Astro will handle generating your routes and showing your preferred URLs to your visitors.
+```
+├── src
+│ ├── pages
+│ │ ├── about.astro
+│ │ ├── index.astro
+│ │ ├── es
+│ │ │ ├── about.astro
+│ │ │ ├── index.astro
+│ │ ├── pt-br
+│ │ │ ├── about.astro
+│ │ │ ├── index.astro
+```
+
+Compute relative URLs for your links with `getLocaleRelativeURL` from the new `astro:i18n` module:
+
+```astro
+---
+import {getLocaleRelativeUrl} from "astro:i18n";
+const aboutUrl = getLocaleRelativeUrl("pt-br", "about");
+---
+
Learn more About this site!
+```
+
+Enabling i18n routing also provides two new properties for browser language detection: `Astro.preferredLocale` and `Astro.preferredLocaleList`. These combine the browser's `Accept-Langauge` header, and your site's list of supported languages and can be used to automatically respect your visitor's preferred languages.
+
+Read more about Astro's [experimental i18n routing](https://docs.astro.build/en/guides/internationalization/) in our documentation.
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 0d2333da27..6ef5722b45 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -125,6 +125,87 @@ declare module 'astro:prefetch' {
export { prefetch, PrefetchOptions } from 'astro/prefetch';
}
+declare module 'astro:i18n' {
+ export type GetLocaleOptions = import('./dist/i18n/index.js').GetLocaleOptions;
+
+ /**
+ * @param {string} locale A locale
+ * @param {string} [path=""] An optional path to add after the `locale`.
+ * @param {import('./dist/i18n/index.js').GetLocaleOptions} options Customise the generated path
+ * @return {string}
+ *
+ * Returns a _relative_ path with passed locale.
+ *
+ * ## Errors
+ *
+ * Throws an error if the locale doesn't exist in the list of locales defined in the configuration.
+ *
+ * ## Examples
+ *
+ * ```js
+ * import { getLocaleRelativeUrl } from "astro:i18n";
+ * getLocaleRelativeUrl("es"); // /es
+ * getLocaleRelativeUrl("es", "getting-started"); // /es/getting-started
+ * getLocaleRelativeUrl("es_US", "getting-started", { prependWith: "blog" }); // /blog/es-us/getting-started
+ * getLocaleRelativeUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // /blog/es_US/getting-started
+ * ```
+ */
+ export const getLocaleRelativeUrl: (
+ locale: string,
+ path?: string,
+ options?: GetLocaleOptions
+ ) => string;
+
+ /**
+ *
+ * @param {string} locale A locale
+ * @param {string} [path=""] An optional path to add after the `locale`.
+ * @param {import('./dist/i18n/index.js').GetLocaleOptions} options Customise the generated path
+ * @return {string}
+ *
+ * Returns an absolute path with the passed locale. The behaviour is subject to change based on `site` configuration.
+ * If _not_ provided, the function will return a _relative_ URL.
+ *
+ * ## Errors
+ *
+ * Throws an error if the locale doesn't exist in the list of locales defined in the configuration.
+ *
+ * ## Examples
+ *
+ * If `site` is `https://example.com`:
+ *
+ * ```js
+ * import { getLocaleAbsoluteUrl } from "astro:i18n";
+ * getLocaleAbsoluteUrl("es"); // https://example.com/es
+ * getLocaleAbsoluteUrl("es", "getting-started"); // https://example.com/es/getting-started
+ * getLocaleAbsoluteUrl("es_US", "getting-started", { prependWith: "blog" }); // https://example.com/blog/es-us/getting-started
+ * getLocaleAbsoluteUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // https://example.com/blog/es_US/getting-started
+ * ```
+ */
+ export const getLocaleAbsoluteUrl: (
+ locale: string,
+ path?: string,
+ options?: GetLocaleOptions
+ ) => string;
+
+ /**
+ * @param {string} [path=""] An optional path to add after the `locale`.
+ * @param {import('./dist/i18n/index.js').GetLocaleOptions} options Customise the generated path
+ * @return {string[]}
+ *
+ * Works like `getLocaleRelativeUrl` but it emits the relative URLs for ALL locales:
+ */
+ export const getLocaleRelativeUrlList: (path?: string, options?: GetLocaleOptions) => string[];
+ /**
+ * @param {string} [path=""] An optional path to add after the `locale`.
+ * @param {import('./dist/i18n/index.js').GetLocaleOptions} options Customise the generated path
+ * @return {string[]}
+ *
+ * Works like `getLocaleAbsoluteUrl` but it emits the absolute URLs for ALL locales:
+ */
+ export const getLocaleAbsoluteUrlList: (path?: string, options?: GetLocaleOptions) => string[];
+}
+
declare module 'astro:middleware' {
export * from 'astro/middleware/namespace';
}
diff --git a/packages/astro/package.json b/packages/astro/package.json
index d1b87a79b8..96dfac5ad7 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -79,7 +79,8 @@
},
"./transitions": "./dist/transitions/index.js",
"./transitions/router": "./dist/transitions/router.js",
- "./prefetch": "./dist/prefetch/index.js"
+ "./prefetch": "./dist/prefetch/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 47fc1f7ea4..47ed001f05 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -1440,6 +1440,93 @@ export interface AstroUserConfig {
* ```
*/
devOverlay?: boolean;
+
+ // TODO review with docs team before merging to `main`
+ /**
+ * @docs
+ * @name experimental.i18n
+ * @type {object}
+ * @version 3.5.0
+ * @type {object}
+ * @description
+ *
+ * Configures experimental i18n routing and allows you to specify some customization options.
+ */
+ i18n?: {
+ /**
+ * @docs
+ * @name experimental.i18n.defaultLocale
+ * @type {string}
+ * @version 3.5.0
+ * @description
+ *
+ * The default locale of your website/application. This is a required field.
+ */
+ defaultLocale: string;
+ /**
+ * @docs
+ * @name experimental.i18n.locales
+ * @type {string[]}
+ * @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[];
+
+ /**
+ * @docs
+ * @name experimental.i18n.fallback
+ * @type {Record}
+ * @version 3.5.0
+ * @description
+ *
+ * The fallback strategy when navigating to pages that do not exist (e.g. a translated page has not been created).
+ *
+ * Use this object to declare a fallback `locale` route for each language you support. If no fallback is specified, then unavailable pages will return a 404.
+ *
+ * #### Example
+ *
+ * The following example configures your content fallback strategy to redirect unavailable pages in `/pt/` to their `es` version, and unavailable pages in `/fr/` to their `en` version. Unavailable `/es/` pages will return a 404.
+ *
+ * ```js
+ * export defualt defineConfig({
+ * experimental: {
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr", "pt", "es"],
+ * fallback: {
+ * pt: "es",
+ * fr: "en"
+ * }
+ * }
+ * }
+ * })
+ * ```
+ */
+ fallback?: Record;
+
+ /**
+ * @docs
+ * @name experimental.i18n.routingStrategy
+ * @type {'prefix-always' | 'prefix-other-locales'}
+ * @default 'prefix-other-locales'
+ * @version 3.5.0
+ * @description
+ *
+ * Controls the routing strategy to determine your site URLs.
+ *
+ * - `prefix-other-locales`(default): Only non-default languages will display a language prefix. The `defaultLocale` will not show a language prefix.
+ * URLs will be of the form `example.com/[lang]/content/` for all non-default languages, but `example.com/content/` for the default locale.
+ * - `prefix-always`: All URLs will display a language prefix.
+ * URLs will be of the form `example.com/[lang]/content/` for every route, including the default language.
+ *
+ * Note: Astro requires all content to exist within a `/[lang]/` folder, even for the default language.
+ */
+ routingStrategy: 'prefix-always' | 'prefix-other-locales';
+ };
};
}
@@ -1902,6 +1989,11 @@ export type AstroFeatureMap = {
* The adapter can emit static assets
*/
assets?: AstroAssetsFeature;
+
+ /**
+ * List of features that orbit around the i18n routing
+ */
+ i18n?: AstroInternationalizationFeature;
};
export interface AstroAssetsFeature {
@@ -1916,6 +2008,13 @@ export interface AstroAssetsFeature {
isSquooshCompatible?: boolean;
}
+export interface AstroInternationalizationFeature {
+ /**
+ * Whether 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;
@@ -1973,6 +2072,17 @@ interface AstroSharedContext<
* Object accessed via Astro middleware
*/
locals: App.Locals;
+
+ /**
+ * The current locale that is computed from the `Accept-Language` header of the browser (**SSR Only**).
+ */
+ preferredLocale: string | undefined;
+
+ /**
+ * The list of locales computed from the `Accept-Language` header of the browser, sorted by quality value (**SSR Only**).
+ */
+
+ preferredLocaleList: string[] | undefined;
}
export interface APIContext<
@@ -2074,6 +2184,34 @@ export interface APIContext<
*/
locals: App.Locals;
ResponseWithEncoding: typeof ResponseWithEncoding;
+
+ /**
+ * Available only when `experimental.i18n` enabled and in SSR.
+ *
+ * It represents the preferred locale of the user. It's computed by checking the supported locales in `i18n.locales`
+ * and locales supported by the users's browser via the header `Accept-Language`
+ *
+ * For example, given `i18n.locales` equals to `['fr', 'de']`, and the `Accept-Language` value equals to `en, de;q=0.2, fr;q=0.6`, the
+ * `Astro.preferredLanguage` will be `fr` because `en` is not supported, its [quality value] is the highest.
+ *
+ * [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
+ */
+ preferredLocale: string | undefined;
+
+ /**
+ * Available only when `experimental.i18n` enabled and in SSR.
+ *
+ * It represents the list of the preferred locales that are supported by the application. The list is sorted via [quality value].
+ *
+ * For example, given `i18n.locales` equals to `['fr', 'pt', 'de']`, and the `Accept-Language` value equals to `en, de;q=0.2, fr;q=0.6`, the
+ * `Astro.preferredLocaleList` will be equal to `['fs', 'de']` because `en` isn't supported, and `pt` isn't part of the locales contained in the
+ * header.
+ *
+ * When the `Accept-Header` is `*`, the original `i18n.locales` are returned. The value `*` means no preferences, so Astro returns all the supported locales.
+ *
+ * [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
+ */
+ preferredLocaleList: string[] | undefined;
}
export type EndpointOutput =
@@ -2216,7 +2354,13 @@ export interface AstroPluginOptions {
logger: Logger;
}
-export type RouteType = 'page' | 'endpoint' | 'redirect';
+/**
+ * - page: a route that lives in the file system, usually an Astro component
+ * - endpoint: a route that lives in the file system, usually a JS file that exposes endpoints methods
+ * - redirect: a route points to another route that lives in the file system
+ * - fallback: a route that doesn't exist in the file system that needs to be handled with other means, usually the middleware
+ */
+export type RouteType = 'page' | 'endpoint' | 'redirect' | 'fallback';
export interface RoutePart {
content: string;
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index b6bf838a93..95f64db5db 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -26,6 +26,8 @@ import {
import { matchRoute } from '../routing/match.js';
import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js';
import type { RouteInfo } from './types.js';
+import { createI18nMiddleware } from '../../i18n/middleware.js';
+import { sequence } from '../middleware/index.js';
export { deserializeManifest } from './common.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@@ -164,8 +166,19 @@ export class App {
);
let response;
try {
- if (mod.onRequest) {
- this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler);
+ let i18nMiddleware = createI18nMiddleware(this.#manifest.i18n, this.#manifest.base);
+ if (i18nMiddleware) {
+ if (mod.onRequest) {
+ this.#pipeline.setMiddlewareFunction(
+ sequence(i18nMiddleware, mod.onRequest as MiddlewareEndpointHandler)
+ );
+ } else {
+ this.#pipeline.setMiddlewareFunction(i18nMiddleware);
+ }
+ } else {
+ if (mod.onRequest) {
+ this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler);
+ }
}
response = await this.#pipeline.renderRoute(renderContext, pageModule);
} catch (err: any) {
@@ -208,6 +221,7 @@ export class App {
const pathname = '/' + this.removeBase(url.pathname);
const mod = await page.page();
const handler = mod as unknown as EndpointHandler;
+
return await createRenderContext({
request,
pathname,
@@ -215,6 +229,7 @@ export class App {
status,
env: this.#pipeline.env,
mod: handler as any,
+ locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined,
});
} else {
const pathname = prependForwardSlash(this.removeBase(url.pathname));
@@ -237,6 +252,7 @@ export class App {
}
}
const mod = await page.page();
+
return await createRenderContext({
request,
pathname,
@@ -248,6 +264,7 @@ export class App {
status,
mod,
env: this.#pipeline.env,
+ locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined,
});
}
}
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
index 0050b5d7a0..5ae0ecf2c4 100644
--- a/packages/astro/src/core/app/types.ts
+++ b/packages/astro/src/core/app/types.ts
@@ -49,6 +49,14 @@ export type SSRManifest = {
componentMetadata: SSRResult['componentMetadata'];
pageModule?: SinglePageBuiltModule;
pageMap?: Map;
+ i18n: SSRManifestI18n | undefined;
+};
+
+export type SSRManifestI18n = {
+ fallback?: Record;
+ routingStrategy?: 'prefix-always' | 'prefix-other-locales';
+ locales: string[];
+ defaultLocale: string;
};
export type SerializedSSRManifest = Omit<
@@ -60,8 +68,3 @@ export type SerializedSSRManifest = Omit<
componentMetadata: [string, SSRComponentMetadata][];
clientDirectives: [string, string][];
};
-
-export type AdapterCreateExports = (
- manifest: SSRManifest,
- args?: T
-) => Record;
diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts
index d38361c36c..e81d28efe8 100644
--- a/packages/astro/src/core/build/buildPipeline.ts
+++ b/packages/astro/src/core/build/buildPipeline.ts
@@ -11,6 +11,8 @@ import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
import type { PageBuildData, StaticBuildOptions } from './types.js';
+import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js';
+import { i18nHasFallback } from './util.js';
/**
* This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files.
@@ -154,11 +156,17 @@ export class BuildPipeline extends Pipeline {
pages.set(pageData, filePath);
}
}
- for (const [path, pageData] of this.#internals.pagesByComponent.entries()) {
- if (pageData.route.type === 'redirect') {
- pages.set(pageData, path);
+
+ for (const [path, pageDataList] of this.#internals.pagesByComponents.entries()) {
+ for (const pageData of pageDataList) {
+ if (routeIsRedirect(pageData.route)) {
+ pages.set(pageData, path);
+ } else if (routeIsFallback(pageData.route) && i18nHasFallback(this.getConfig())) {
+ pages.set(pageData, path);
+ }
}
}
+
return pages;
}
diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts
index 5b3811f1d1..e7efc6439e 100644
--- a/packages/astro/src/core/build/common.ts
+++ b/packages/astro/src/core/build/common.ts
@@ -25,6 +25,7 @@ export function getOutFolder(
switch (routeType) {
case 'endpoint':
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
+ case 'fallback':
case 'page':
case 'redirect':
switch (astroConfig.build.format) {
@@ -52,6 +53,7 @@ export function getOutFile(
case 'endpoint':
return new URL(npath.basename(pathname), outFolder);
case 'page':
+ case 'fallback':
case 'redirect':
switch (astroConfig.build.format) {
case 'directory': {
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 4bc1b25ef1..d5da974a95 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -7,7 +7,6 @@ import PQueue from 'p-queue';
import type { OutputAsset, OutputChunk } from 'rollup';
import type { BufferEncoding } from 'vfile';
import type {
- AstroConfig,
AstroSettings,
ComponentInstance,
GetStaticPathsItem,
@@ -35,7 +34,11 @@ import { runHookBuildGenerated } from '../../integrations/index.js';
import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
-import { RedirectSinglePageBuiltModule, getRedirectLocationOrThrow } from '../redirects/index.js';
+import {
+ RedirectSinglePageBuiltModule,
+ getRedirectLocationOrThrow,
+ routeIsRedirect,
+} from '../redirects/index.js';
import { createRenderContext } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js';
import {
@@ -60,7 +63,11 @@ import type {
StaticBuildOptions,
StylesheetAsset,
} from './types.js';
-import { getTimeStat } from './util.js';
+import { getTimeStat, shouldAppendForwardSlash } from './util.js';
+import { createI18nMiddleware } from '../../i18n/middleware.js';
+import { sequence } from '../middleware/index.js';
+import { routeIsFallback } from '../redirects/helpers.js';
+import type { SSRManifestI18n } from '../app/types.js';
function createEntryURL(filePath: string, outFolder: URL) {
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
@@ -86,6 +93,26 @@ async function getEntryForRedirectRoute(
return RedirectSinglePageBuiltModule;
}
+async function getEntryForFallbackRoute(
+ route: RouteData,
+ internals: BuildInternals,
+ outFolder: URL
+): Promise {
+ if (route.type !== 'fallback') {
+ throw new Error(`Expected a redirect route.`);
+ }
+ if (route.redirectRoute) {
+ const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component);
+ if (filePath) {
+ const url = createEntryURL(filePath, outFolder);
+ const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
+ return ssrEntryPage;
+ }
+ }
+
+ return RedirectSinglePageBuiltModule;
+}
+
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
return (
// Drafts are disabled
@@ -179,16 +206,15 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
await generatePage(pageData, ssrEntry, builtPaths, pipeline);
}
}
- if (pageData.route.type === 'redirect') {
- const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
- await generatePage(pageData, entry, builtPaths, pipeline);
- }
}
} else {
for (const [pageData, filePath] of pagesToGenerate) {
- if (pageData.route.type === 'redirect') {
+ if (routeIsRedirect(pageData.route)) {
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
await generatePage(pageData, entry, builtPaths, pipeline);
+ } else if (routeIsFallback(pageData.route)) {
+ const entry = await getEntryForFallbackRoute(pageData.route, internals, outFolder);
+ await generatePage(pageData, entry, builtPaths, pipeline);
} else {
const ssrEntryURLPage = createEntryURL(filePath, outFolder);
const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
@@ -237,6 +263,7 @@ async function generatePage(
) {
let timeStart = performance.now();
const logger = pipeline.getLogger();
+ const config = pipeline.getConfig();
const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component);
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
@@ -249,7 +276,19 @@ async function generatePage(
const pageModulePromise = ssrEntry.page;
const onRequest = ssrEntry.onRequest;
- if (onRequest) {
+ const i18nMiddleware = createI18nMiddleware(
+ pipeline.getManifest().i18n,
+ pipeline.getManifest().base
+ );
+ if (config.experimental.i18n && i18nMiddleware) {
+ if (onRequest) {
+ pipeline.setMiddlewareFunction(
+ sequence(i18nMiddleware, onRequest as MiddlewareEndpointHandler)
+ );
+ } else {
+ pipeline.setMiddlewareFunction(i18nMiddleware);
+ }
+ } else if (onRequest) {
pipeline.setMiddlewareFunction(onRequest as MiddlewareEndpointHandler);
}
if (!pageModulePromise) {
@@ -277,7 +316,9 @@ async function generatePage(
};
const icon =
- pageData.route.type === 'page' || pageData.route.type === 'redirect'
+ pageData.route.type === 'page' ||
+ pageData.route.type === 'redirect' ||
+ pageData.route.type === 'fallback'
? green('▶')
: magenta('λ');
if (isRelativePath(pageData.route.component)) {
@@ -410,26 +451,6 @@ interface GeneratePathOptions {
mod: ComponentInstance;
}
-function shouldAppendForwardSlash(
- trailingSlash: AstroConfig['trailingSlash'],
- buildFormat: AstroConfig['build']['format']
-): boolean {
- switch (trailingSlash) {
- case 'always':
- return true;
- case 'never':
- return false;
- case 'ignore': {
- switch (buildFormat) {
- case 'directory':
- return true;
- case 'file':
- return false;
- }
- }
- }
-}
-
function addPageName(pathname: string, opts: StaticBuildOptions): void {
const trailingSlash = opts.settings.config.trailingSlash;
const buildFormat = opts.settings.config.build.format;
@@ -518,14 +539,16 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli
pageData.route.type
);
+ const request = createRequest({
+ url,
+ headers: new Headers(),
+ logger: pipeline.getLogger(),
+ ssr,
+ });
+ const i18n = pipeline.getConfig().experimental.i18n;
const renderContext = await createRenderContext({
pathname,
- request: createRequest({
- url,
- headers: new Headers(),
- logger: pipeline.getLogger(),
- ssr,
- }),
+ request,
componentMetadata: manifest.componentMetadata,
scripts,
styles,
@@ -533,6 +556,7 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli
route: pageData.route,
env: pipeline.getEnvironment(),
mod,
+ locales: i18n ? i18n.locales : undefined,
});
let body: string | Uint8Array;
@@ -602,6 +626,15 @@ export function createBuildManifest(
internals: BuildInternals,
renderers: SSRLoadedRenderer[]
): SSRManifest {
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.experimental.i18n) {
+ i18nManifest = {
+ fallback: settings.config.experimental.i18n.fallback,
+ routingStrategy: settings.config.experimental.i18n.routingStrategy,
+ defaultLocale: settings.config.experimental.i18n.defaultLocale,
+ locales: settings.config.experimental.i18n.locales,
+ };
+ }
return {
assets: new Set(),
entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
@@ -616,5 +649,6 @@ export function createBuildManifest(
? new URL(settings.config.base, settings.config.site).toString()
: settings.config.site,
componentMetadata: internals.componentMetadata,
+ i18n: i18nManifest,
};
}
diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts
index 797a29a258..a6bcf8b179 100644
--- a/packages/astro/src/core/build/index.ts
+++ b/packages/astro/src/core/build/index.ts
@@ -198,7 +198,9 @@ class AstroBuilder {
await runHookBuildDone({
config: this.settings.config,
pages: pageNames,
- routes: Object.values(allPages).map((pd) => pd.route),
+ routes: Object.values(allPages)
+ .flat()
+ .map((pageData) => pageData.route),
logging: this.logger,
});
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
index 55cff37f3b..df3a7e6832 100644
--- a/packages/astro/src/core/build/internal.ts
+++ b/packages/astro/src/core/build/internal.ts
@@ -9,7 +9,8 @@ import {
} from './plugins/plugin-pages.js';
import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
-import type { PageBuildData, StylesheetAsset, ViteID } from './types.js';
+import type { AllPagesData, PageBuildData, StylesheetAsset, ViteID } from './types.js';
+import { routeIsFallback } from '../redirects/helpers.js';
export interface BuildInternals {
/**
@@ -37,9 +38,16 @@ export interface BuildInternals {
/**
* A map for page-specific information.
+ * // TODO: Remove in Astro 4.0
+ * @deprecated
*/
pagesByComponent: Map;
+ /**
+ * TODO: Use this in Astro 4.0
+ */
+ pagesByComponents: Map;
+
/**
* A map for page-specific output.
*/
@@ -112,6 +120,7 @@ export function createBuildInternals(): BuildInternals {
entrySpecifierToBundleMap: new Map(),
pageToBundleMap: new Map(),
pagesByComponent: new Map(),
+ pagesByComponents: new Map(),
pageOptionsByPage: new Map(),
pagesByViteID: new Map(),
pagesByClientOnly: new Map(),
@@ -134,7 +143,16 @@ export function trackPageData(
componentURL: URL
): void {
pageData.moduleSpecifier = componentModuleId;
- internals.pagesByComponent.set(component, pageData);
+ if (!routeIsFallback(pageData.route)) {
+ internals.pagesByComponent.set(component, pageData);
+ }
+ const list = internals.pagesByComponents.get(component);
+ if (list) {
+ list.push(pageData);
+ internals.pagesByComponents.set(component, list);
+ } else {
+ internals.pagesByComponents.set(component, [pageData]);
+ }
internals.pagesByViteID.set(viteID(componentURL), pageData);
}
@@ -230,6 +248,14 @@ export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values();
}
+export function* eachPageFromAllPages(allPages: AllPagesData): Generator<[string, PageBuildData]> {
+ for (const [path, list] of Object.entries(allPages)) {
+ for (const pageData of list) {
+ yield [path, pageData];
+ }
+ }
+}
+
export function* eachPageDataFromEntryPoint(
internals: BuildInternals
): Generator<[PageBuildData, string]> {
diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts
index da002a0518..f4426191a1 100644
--- a/packages/astro/src/core/build/page-data.ts
+++ b/packages/astro/src/core/build/page-data.ts
@@ -4,6 +4,7 @@ import type { AllPagesData } from './types.js';
import * as colors from 'kleur/colors';
import { debug } from '../logger/core.js';
+import { eachPageFromAllPages } from './internal.js';
export interface CollectPagesDataOptions {
settings: AstroSettings;
@@ -47,15 +48,29 @@ export async function collectPagesData(
clearInterval(routeCollectionLogTimeout);
}, 10000);
builtPaths.add(route.pathname);
- allPages[route.component] = {
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- };
+ if (allPages[route.component]) {
+ allPages[route.component].push({
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ });
+ } else {
+ allPages[route.component] = [
+ {
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ },
+ ];
+ }
clearInterval(routeCollectionLogTimeout);
if (settings.config.output === 'static') {
@@ -70,18 +85,31 @@ export async function collectPagesData(
continue;
}
// dynamic route:
- allPages[route.component] = {
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- };
+ if (allPages[route.component]) {
+ allPages[route.component].push({
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ });
+ } else {
+ allPages[route.component] = [
+ {
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ },
+ ];
+ }
}
clearInterval(dataCollectionLogTimeout);
-
return { assets, allPages };
}
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
index 41ceb282c9..e4cd17474a 100644
--- a/packages/astro/src/core/build/plugins/plugin-manifest.ts
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -5,11 +5,12 @@ import { type Plugin as VitePlugin } from 'vite';
import { runHookBuildSsr } from '../../../integrations/index.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types.js';
+import type { SSRManifestI18n } from '../../app/types.js';
import { joinPaths, prependForwardSlash } from '../../path.js';
import { serializeRouteData } from '../../routing/index.js';
import { addRollupInput } from '../add-rollup-input.js';
import { getOutFile, getOutFolder } from '../common.js';
-import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js';
+import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js';
@@ -237,8 +238,17 @@ function buildManifest(
// Set this to an empty string so that the runtime knows not to try and load this.
entryModules[BEFORE_HYDRATION_SCRIPT_ID] = '';
}
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.experimental.i18n) {
+ i18nManifest = {
+ fallback: settings.config.experimental.i18n.fallback,
+ routingStrategy: settings.config.experimental.i18n.routingStrategy,
+ locales: settings.config.experimental.i18n.locales,
+ defaultLocale: settings.config.experimental.i18n.defaultLocale,
+ };
+ }
- const ssrManifest: SerializedSSRManifest = {
+ return {
adapterName: opts.settings.adapter?.name ?? '',
routes,
site: settings.config.site,
@@ -250,7 +260,6 @@ function buildManifest(
clientDirectives: Array.from(settings.clientDirectives),
entryModules,
assets: staticFiles.map(prefixAssetPath),
+ i18n: i18nManifest,
};
-
- return ssrManifest;
}
diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts
index 22d3f795ba..628a1cb70b 100644
--- a/packages/astro/src/core/build/plugins/plugin-middleware.ts
+++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts
@@ -17,7 +17,7 @@ export function vitePluginMiddleware(
let resolvedMiddlewareId: string;
return {
name: '@astro/plugin-middleware',
-
+ enforce: 'post',
options(options) {
return addRollupInput(options, [MIDDLEWARE_MODULE_ID]);
},
diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts
index 00401285f1..20c877a54a 100644
--- a/packages/astro/src/core/build/plugins/plugin-pages.ts
+++ b/packages/astro/src/core/build/plugins/plugin-pages.ts
@@ -3,7 +3,7 @@ import type { Plugin as VitePlugin } from 'vite';
import type { AstroSettings } from '../../../@types/astro.js';
import { routeIsRedirect } from '../../redirects/index.js';
import { addRollupInput } from '../add-rollup-input.js';
-import { type BuildInternals } from '../internal.js';
+import { type BuildInternals, eachPageFromAllPages } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
@@ -42,7 +42,7 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
if (opts.settings.config.output === 'static') {
const inputs = new Set();
- for (const [path, pageData] of Object.entries(opts.allPages)) {
+ for (const [path, pageData] of eachPageFromAllPages(opts.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
index 1887351b17..7d1a1dbd35 100644
--- a/packages/astro/src/core/build/plugins/plugin-ssr.ts
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -13,6 +13,7 @@ import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js';
import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
import { getPathFromVirtualModulePageName, getVirtualModulePageNameFromPath } from './util.js';
+import { eachPageFromAllPages } from '../internal.js';
export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
@@ -42,7 +43,7 @@ function vitePluginSSR(
let i = 0;
const pageMap: string[] = [];
- for (const [path, pageData] of Object.entries(allPages)) {
+ for (const [path, pageData] of eachPageFromAllPages(allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
@@ -148,7 +149,7 @@ function vitePluginSSRSplit(
if (options.settings.config.build.split || functionPerRouteEnabled) {
const inputs = new Set();
- for (const [path, pageData] of Object.entries(options.allPages)) {
+ for (const [path, pageData] of eachPageFromAllPages(options.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
@@ -294,7 +295,7 @@ function storeEntryPoint(
fileName: string
) {
const componentPath = getPathFromVirtualModulePageName(RESOLVED_SPLIT_MODULE_ID, moduleKey);
- for (const [page, pageData] of Object.entries(options.allPages)) {
+ for (const [page, pageData] of eachPageFromAllPages(options.allPages)) {
if (componentPath == page) {
const publicPath = fileURLToPath(options.settings.config.build.server);
internals.entryPoints.set(pageData.route, pathToFileURL(join(publicPath, fileName)));
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 016e245410..43727b876f 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -30,7 +30,7 @@ import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
-import type { PageBuildData, StaticBuildOptions } from './types.js';
+import type { StaticBuildOptions } from './types.js';
import { getTimeStat } from './util.js';
export async function viteBuild(opts: StaticBuildOptions) {
@@ -46,23 +46,20 @@ export async function viteBuild(opts: StaticBuildOptions) {
// The pages to be built for rendering purposes.
const pageInput = new Set();
- // A map of each page .astro file, to the PageBuildData which contains information
- // about that page, such as its paths.
- const facadeIdToPageDataMap = new Map();
-
// Build internals needed by the CSS plugin
const internals = createBuildInternals();
- for (const [component, pageData] of Object.entries(allPages)) {
- const astroModuleURL = new URL('./' + component, settings.config.root);
- const astroModuleId = prependForwardSlash(component);
+ for (const [component, pageDataList] of Object.entries(allPages)) {
+ for (const pageData of pageDataList) {
+ const astroModuleURL = new URL('./' + component, settings.config.root);
+ const astroModuleId = prependForwardSlash(component);
- // Track the page data in internals
- trackPageData(internals, component, pageData, astroModuleId, astroModuleURL);
+ // Track the page data in internals
+ trackPageData(internals, component, pageData, astroModuleId, astroModuleURL);
- if (!routeIsRedirect(pageData.route)) {
- pageInput.add(astroModuleId);
- facadeIdToPageDataMap.set(fileURLToPath(astroModuleURL), pageData);
+ if (!routeIsRedirect(pageData.route)) {
+ pageInput.add(astroModuleId);
+ }
}
}
@@ -148,7 +145,9 @@ async function ssrBuild(
const { allPages, settings, viteConfig } = opts;
const ssr = isServerLikeOutput(settings.config);
const out = getOutputDirectory(settings.config);
- const routes = Object.values(allPages).map((pd) => pd.route);
+ const routes = Object.values(allPages)
+ .flat()
+ .map((pageData) => pageData.route);
const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input);
const viteBuildConfig: vite.InlineConfig = {
diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts
index a51fc8d000..00d6ce0461 100644
--- a/packages/astro/src/core/build/types.ts
+++ b/packages/astro/src/core/build/types.ts
@@ -30,7 +30,7 @@ export interface PageBuildData {
hoistedScript: { type: 'inline' | 'external'; value: string } | undefined;
styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>;
}
-export type AllPagesData = Record;
+export type AllPagesData = Record;
/** Options for the static build */
export interface StaticBuildOptions {
diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts
index 8e558f9bbd..f289f019b8 100644
--- a/packages/astro/src/core/build/util.ts
+++ b/packages/astro/src/core/build/util.ts
@@ -1,4 +1,38 @@
+import type { AstroConfig } from '../../@types/astro.js';
+
export function getTimeStat(timeStart: number, timeEnd: number) {
const buildTime = timeEnd - timeStart;
return buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`;
}
+
+/**
+ * Given the Astro configuration, it tells if a slash should be appended or not
+ */
+export function shouldAppendForwardSlash(
+ trailingSlash: AstroConfig['trailingSlash'],
+ buildFormat: AstroConfig['build']['format']
+): boolean {
+ switch (trailingSlash) {
+ case 'always':
+ return true;
+ case 'never':
+ return false;
+ case 'ignore': {
+ switch (buildFormat) {
+ case 'directory':
+ return true;
+ case 'file':
+ return false;
+ }
+ }
+ }
+}
+
+export function i18nHasFallback(config: AstroConfig): boolean {
+ if (config.experimental.i18n && config.experimental.i18n.fallback) {
+ // we have some fallback and the control is not none
+ return Object.keys(config.experimental.i18n.fallback).length > 0;
+ }
+
+ return false;
+}
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index ea656b9bfc..e10aa4b754 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -339,6 +339,55 @@ export const AstroConfigSchema = z.object({
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript),
devOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.devOverlay),
+ i18n: z.optional(
+ z
+ .object({
+ defaultLocale: z.string(),
+ locales: z.string().array(),
+ fallback: z.record(z.string(), z.string()).optional(),
+ // TODO: properly add default when the feature goes of experimental
+ routingStrategy: z
+ .enum(['prefix-always', 'prefix-other-locales'])
+ .optional()
+ .default('prefix-other-locales'),
+ })
+ .optional()
+ .superRefine((i18n, ctx) => {
+ if (i18n) {
+ const { defaultLocale, locales, fallback } = i18n;
+ if (!locales.includes(defaultLocale)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`,
+ });
+ }
+ if (fallback) {
+ for (const [fallbackFrom, fallbackTo] of Object.entries(fallback)) {
+ if (!locales.includes(fallbackFrom)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The locale \`${fallbackFrom}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
+ });
+ }
+
+ if (fallbackFrom === defaultLocale) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `You can't use the default locale as a key. The default locale can only be used as value.`,
+ });
+ }
+
+ if (!locales.includes(fallbackTo)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The locale \`${fallbackTo}\` 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/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index a4841bfdbc..ee6bde789c 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -31,6 +31,7 @@ import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js';
import { joinPaths } from './path.js';
+import astroInternationalization from '../i18n/vite-plugin-i18n.js';
interface CreateViteOptions {
settings: AstroSettings;
@@ -138,6 +139,7 @@ export async function createVite(
astroPrefetch({ settings }),
astroTransitions({ settings }),
astroDevOverlay({ settings, logger }),
+ !!settings.config.experimental.i18n && astroInternationalization({ settings }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
diff --git a/packages/astro/src/core/endpoint/dev/index.ts b/packages/astro/src/core/endpoint/dev/index.ts
deleted file mode 100644
index 96fe5f3d76..0000000000
--- a/packages/astro/src/core/endpoint/dev/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { EndpointHandler } from '../../../@types/astro.js';
-import { createRenderContext, type SSROptions } from '../../render/index.js';
-import { callEndpoint } from '../index.js';
-
-export async function call(options: SSROptions) {
- const { env, preload, middleware } = options;
- const endpointHandler = preload as unknown as EndpointHandler;
-
- const ctx = await createRenderContext({
- request: options.request,
- pathname: options.pathname,
- route: options.route,
- env,
- mod: preload,
- });
-
- return await callEndpoint(endpointHandler, env, ctx, middleware?.onRequest);
-}
diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts
index 751643dd48..5e2f33d846 100644
--- a/packages/astro/src/core/endpoint/index.ts
+++ b/packages/astro/src/core/endpoint/index.ts
@@ -12,7 +12,8 @@ import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
-import type { Environment, RenderContext } from '../render/index.js';
+import { type Environment, type RenderContext } from '../render/index.js';
+import { computePreferredLocale, computePreferredLocaleList } from '../render/context.js';
const encoder = new TextEncoder();
@@ -25,6 +26,7 @@ type CreateAPIContext = {
site?: string;
props: Record;
adapterName?: string;
+ locales: string[] | undefined;
};
/**
@@ -38,7 +40,11 @@ export function createAPIContext({
site,
props,
adapterName,
+ locales,
}: CreateAPIContext): APIContext {
+ let preferredLocale: string | undefined = undefined;
+ let preferredLocaleList: string[] | undefined = undefined;
+
const context = {
cookies: new AstroCookies(request),
request,
@@ -55,6 +61,28 @@ export function createAPIContext({
});
},
ResponseWithEncoding,
+ get preferredLocale(): string | undefined {
+ if (preferredLocale) {
+ return preferredLocale;
+ }
+ if (locales) {
+ preferredLocale = computePreferredLocale(request, locales);
+ return preferredLocale;
+ }
+
+ return undefined;
+ },
+ get preferredLocaleList(): string[] | undefined {
+ if (preferredLocaleList) {
+ return preferredLocaleList;
+ }
+ if (locales) {
+ preferredLocaleList = computePreferredLocaleList(request, locales);
+ return preferredLocaleList;
+ }
+
+ return undefined;
+ },
url: new URL(request.url),
get clientAddress() {
if (clientAddressSymbol in request) {
@@ -125,7 +153,8 @@ export async function callEndpoint
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
- onRequest?: MiddlewareHandler | undefined
+ onRequest: MiddlewareHandler | undefined,
+ locales: undefined | string[]
): Promise {
const context = createAPIContext({
request: ctx.request,
@@ -133,6 +162,7 @@ export async function callEndpoint
props: ctx.props,
site: env.site,
adapterName: env.adapterName,
+ locales,
});
let response;
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index 4089576587..ec84888d4c 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -1272,5 +1272,23 @@ export const UnsupportedConfigTransformError = {
hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue',
} satisfies ErrorData;
+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(
+ ', '
+ )}.`;
+ },
+} satisfies ErrorData;
+
+export const CantRenderPage = {
+ name: 'CantRenderPage',
+ title: "Astro can't render the route.",
+ message:
+ 'Astro cannot find any content to render for this route. There is no file or redirect associated with this route.',
+ hint: 'If you expect to find a route here, this may be an Astro bug. Please file an issue/restart the dev server',
+} satisfies ErrorData;
+
// Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip
export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData;
diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts
index 1b87bf1e12..77da30aee2 100644
--- a/packages/astro/src/core/middleware/index.ts
+++ b/packages/astro/src/core/middleware/index.ts
@@ -1,8 +1,8 @@
-import type { MiddlewareResponseHandler, Params } from '../../@types/astro.js';
+import type { MiddlewareEndpointHandler, Params } from '../../@types/astro.js';
import { createAPIContext } from '../endpoint/index.js';
import { sequence } from './sequence.js';
-function defineMiddleware(fn: MiddlewareResponseHandler) {
+function defineMiddleware(fn: MiddlewareEndpointHandler) {
return fn;
}
@@ -18,17 +18,23 @@ export type CreateContext = {
* Optional parameters
*/
params?: Params;
+
+ /**
+ * A list of locales that are supported by the user
+ */
+ userDefinedLocales?: string[];
};
/**
* Creates a context to be passed to Astro middleware `onRequest` function.
*/
-function createContext({ request, params }: CreateContext) {
+function createContext({ request, params, userDefinedLocales = [] }: CreateContext) {
return createAPIContext({
request,
params: params ?? {},
props: {},
site: undefined,
+ locales: userDefinedLocales,
});
}
diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts
index 29b1d1623b..d8d71c66b4 100644
--- a/packages/astro/src/core/middleware/sequence.ts
+++ b/packages/astro/src/core/middleware/sequence.ts
@@ -1,4 +1,4 @@
-import type { APIContext, MiddlewareResponseHandler } from '../../@types/astro.js';
+import type { APIContext, MiddlewareEndpointHandler } from '../../@types/astro.js';
import { defineMiddleware } from './index.js';
// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js
@@ -6,10 +6,10 @@ import { defineMiddleware } from './index.js';
*
* It accepts one or more middleware handlers and makes sure that they are run in sequence.
*/
-export function sequence(...handlers: MiddlewareResponseHandler[]): MiddlewareResponseHandler {
+export function sequence(...handlers: MiddlewareEndpointHandler[]): MiddlewareEndpointHandler {
const length = handlers.length;
if (!length) {
- const handler: MiddlewareResponseHandler = defineMiddleware((context, next) => {
+ const handler: MiddlewareEndpointHandler = defineMiddleware((context, next) => {
return next();
});
return handler;
diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts
index dd1e66a52c..3d33146c6f 100644
--- a/packages/astro/src/core/pipeline.ts
+++ b/packages/astro/src/core/pipeline.ts
@@ -73,7 +73,7 @@ export class Pipeline {
*/
async renderRoute(
renderContext: RenderContext,
- componentInstance: ComponentInstance
+ componentInstance: ComponentInstance | undefined
): Promise {
const result = await this.#tryRenderRoute(
renderContext,
@@ -106,7 +106,7 @@ export class Pipeline {
async #tryRenderRoute(
renderContext: Readonly,
env: Readonly,
- mod: Readonly,
+ mod: Readonly | undefined,
onRequest?: MiddlewareHandler
): Promise {
const apiContext = createAPIContext({
@@ -115,10 +115,12 @@ export class Pipeline {
props: renderContext.props,
site: env.site,
adapterName: env.adapterName,
+ locales: renderContext.locales,
});
switch (renderContext.route.type) {
case 'page':
+ case 'fallback':
case 'redirect': {
if (onRequest) {
return await callMiddleware(
@@ -144,13 +146,13 @@ export class Pipeline {
}
}
case 'endpoint': {
- const result = await callEndpoint(
+ return await callEndpoint(
mod as any as EndpointHandler,
env,
renderContext,
- onRequest
+ onRequest,
+ renderContext.locales
);
- return result;
}
default:
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
diff --git a/packages/astro/src/core/redirects/helpers.ts b/packages/astro/src/core/redirects/helpers.ts
index 7574f27146..697cb0fd89 100644
--- a/packages/astro/src/core/redirects/helpers.ts
+++ b/packages/astro/src/core/redirects/helpers.ts
@@ -9,6 +9,10 @@ export function routeIsRedirect(route: RouteData | undefined): route is Redirect
return route?.type === 'redirect';
}
+export function routeIsFallback(route: RouteData | undefined): route is RedirectRouteData {
+ return route?.type === 'fallback';
+}
+
export function redirectRouteGenerate(redirectRoute: RouteData, data: Params): string {
const routeData = redirectRoute.redirectRoute;
const route = redirectRoute.redirect;
diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts
index 86efb63e3d..bc20efed32 100644
--- a/packages/astro/src/core/render/context.ts
+++ b/packages/astro/src/core/render/context.ts
@@ -9,6 +9,7 @@ import type {
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Environment } from './environment.js';
import { getParamsAndProps } from './params-and-props.js';
+import { normalizeTheLocale } from '../../i18n/index.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@@ -27,6 +28,7 @@ export interface RenderContext {
params: Params;
props: Props;
locals?: object;
+ locales: string[] | undefined;
}
export type CreateRenderContextArgs = Partial<
@@ -34,7 +36,7 @@ export type CreateRenderContextArgs = Partial<
> & {
route: RouteData;
request: RenderContext['request'];
- mod: ComponentInstance;
+ mod: ComponentInstance | undefined;
env: Environment;
};
@@ -57,6 +59,7 @@ export async function createRenderContext(
pathname,
params,
props,
+ locales: options.locales,
};
// We define a custom property, so we can check the value passed to locals
@@ -76,3 +79,132 @@ export async function createRenderContext(
return context;
}
+
+type BrowserLocale = {
+ locale: string;
+ qualityValue: number | undefined;
+};
+
+/**
+ * Parses the value of the `Accept-Header` language:
+ *
+ * More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
+ *
+ * Complex example: `fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5`
+ *
+ */
+export function parseLocale(header: string): BrowserLocale[] {
+ // Any language, early return
+ if (header === '*') {
+ return [{ locale: header, qualityValue: undefined }];
+ }
+ const result: BrowserLocale[] = [];
+ // we split by `,` and trim the white spaces
+ const localeValues = header.split(',').map((str) => str.trim());
+
+ for (const localeValue of localeValues) {
+ // split the locale name from the quality value
+ const split = localeValue.split(';').map((str) => str.trim());
+ const localeName: string = split[0];
+ const qualityValue: string | undefined = split[1];
+
+ if (!split) {
+ // invalid value
+ continue;
+ }
+
+ // we check if the quality value is present, and it is actually `q=`
+ if (qualityValue && qualityValue.startsWith('q=')) {
+ const qualityValueAsFloat = Number.parseFloat(qualityValue.slice('q='.length));
+ // The previous operation can return a `NaN`, so we check if it is a safe operation
+ if (Number.isNaN(qualityValueAsFloat) || qualityValueAsFloat > 1) {
+ result.push({
+ locale: localeName,
+ qualityValue: undefined,
+ });
+ } else {
+ result.push({
+ locale: localeName,
+ qualityValue: qualityValueAsFloat,
+ });
+ }
+ } else {
+ result.push({
+ locale: localeName,
+ qualityValue: undefined,
+ });
+ }
+ }
+
+ return result;
+}
+
+function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: string[]) {
+ const normalizedLocales = locales.map(normalizeTheLocale);
+ return browserLocaleList
+ .filter((browserLocale) => {
+ if (browserLocale.locale !== '*') {
+ return normalizedLocales.includes(normalizeTheLocale(browserLocale.locale));
+ }
+ return true;
+ })
+ .sort((a, b) => {
+ if (a.qualityValue && b.qualityValue) {
+ if (a.qualityValue > b.qualityValue) {
+ return -1;
+ } else if (a.qualityValue < b.qualityValue) {
+ return 1;
+ }
+ }
+ return 0;
+ });
+}
+
+/**
+ * Set the current locale by parsing the value passed from the `Accept-Header`.
+ *
+ * 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 {
+ 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)
+ );
+ }
+ }
+ }
+
+ return result;
+}
+
+export function computePreferredLocaleList(request: Request, 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;
+ } 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);
+ }
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts
index f8889f47d1..b2cda1e289 100644
--- a/packages/astro/src/core/render/core.ts
+++ b/packages/astro/src/core/render/core.ts
@@ -1,21 +1,16 @@
-import type {
- AstroCookies,
- ComponentInstance,
- EndpointHandler,
- MiddlewareHandler,
- MiddlewareResponseHandler,
-} from '../../@types/astro.js';
+import type { AstroCookies, ComponentInstance } from '../../@types/astro.js';
import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachCookiesToResponse } from '../cookies/index.js';
-import { callEndpoint, createAPIContext } from '../endpoint/index.js';
-import { callMiddleware } from '../middleware/callMiddleware.js';
import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js';
import type { RenderContext } from './context.js';
import type { Environment } from './environment.js';
import { createResult } from './result.js';
+import { AstroError } from '../errors/index.js';
+import { CantRenderPage } from '../errors/errors-data.js';
+import { routeIsFallback } from '../redirects/helpers.js';
export type RenderPage = {
- mod: ComponentInstance;
+ mod: ComponentInstance | undefined;
renderContext: RenderContext;
env: Environment;
cookies: AstroCookies;
@@ -29,6 +24,14 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag
location: redirectRouteGenerate(renderContext.route, renderContext.params),
},
});
+ } else if (routeIsFallback(renderContext.route)) {
+ // We return a 404 because fallback routes don't exist.
+ // It's responsibility of the middleware to catch them and re-route the requests
+ return new Response(null, {
+ status: 404,
+ });
+ } else if (!mod) {
+ throw new AstroError(CantRenderPage);
}
// Validate the page component before rendering the page
@@ -56,6 +59,7 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag
status: renderContext.status ?? 200,
cookies,
locals: renderContext.locals ?? {},
+ locales: renderContext.locales,
});
// TODO: Remove in Astro 4.0
@@ -83,68 +87,3 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag
return response;
}
-
-/**
- * It attempts to render a route. A route can be a:
- * - page
- * - redirect
- * - endpoint
- *
- * ## Errors
- *
- * It throws an error if the page can't be rendered.
- * @deprecated Use the pipeline instead
- */
-export async function tryRenderRoute(
- renderContext: Readonly,
- env: Readonly,
- mod: Readonly,
- onRequest?: MiddlewareHandler
-): Promise {
- const apiContext = createAPIContext({
- request: renderContext.request,
- params: renderContext.params,
- props: renderContext.props,
- site: env.site,
- adapterName: env.adapterName,
- });
-
- switch (renderContext.route.type) {
- case 'page':
- case 'redirect': {
- if (onRequest) {
- return await callMiddleware(
- env.logger,
- onRequest as MiddlewareResponseHandler,
- apiContext,
- () => {
- return renderPage({
- mod,
- renderContext,
- env,
- cookies: apiContext.cookies,
- });
- }
- );
- } else {
- return await renderPage({
- mod,
- renderContext,
- env,
- cookies: apiContext.cookies,
- });
- }
- }
- case 'endpoint': {
- const result = await callEndpoint(
- mod as any as EndpointHandler,
- env,
- renderContext,
- onRequest
- );
- return result;
- }
- default:
- throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
- }
-}
diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts
index 098b7d0247..e8592bc65d 100644
--- a/packages/astro/src/core/render/index.ts
+++ b/packages/astro/src/core/render/index.ts
@@ -1,8 +1,7 @@
import type { AstroMiddlewareInstance, ComponentInstance, RouteData } from '../../@types/astro.js';
import type { Environment } from './environment.js';
-export { createRenderContext } from './context.js';
+export { createRenderContext, computePreferredLocale } from './context.js';
export type { RenderContext } from './context.js';
-export { tryRenderRoute } from './core.js';
export { createEnvironment } from './environment.js';
export { getParamsAndProps } from './params-and-props.js';
export { loadRenderer } from './renderer.js';
@@ -20,7 +19,7 @@ export interface SSROptions {
preload: ComponentInstance;
/** Request */
request: Request;
- /** optional, in case we need to render something outside of a dev server */
+ /** optional, in case we need to render something outside a dev server */
route: RouteData;
/**
* Optional middlewares
diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts
index ac2884a7a1..b1f8b2ca22 100644
--- a/packages/astro/src/core/render/params-and-props.ts
+++ b/packages/astro/src/core/render/params-and-props.ts
@@ -4,9 +4,10 @@ import type { Logger } from '../logger/core.js';
import { routeIsRedirect } from '../redirects/index.js';
import { getParams } from '../routing/params.js';
import { RouteCache, callGetStaticPaths, findPathItemByKey } from './route-cache.js';
+import { routeIsFallback } from '../redirects/helpers.js';
interface GetParamsAndPropsOptions {
- mod: ComponentInstance;
+ mod: ComponentInstance | undefined;
route?: RouteData | undefined;
routeCache: RouteCache;
pathname: string;
@@ -26,11 +27,13 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise
// This is a dynamic route, start getting the params
const params = getRouteParams(route, pathname) ?? {};
- if (routeIsRedirect(route)) {
+ if (routeIsRedirect(route) || routeIsFallback(route)) {
return [params, {}];
}
- validatePrerenderEndpointCollision(route, mod, params);
+ if (mod) {
+ validatePrerenderEndpointCollision(route, mod, params);
+ }
// During build, the route cache should already be populated.
// During development, the route cache is filled on-demand and may be empty.
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index b96628a5f8..91dc545df4 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -12,6 +12,7 @@ import { chunkToString } from '../../runtime/server/render/index.js';
import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Logger } from '../logger/core.js';
+import { computePreferredLocale, computePreferredLocaleList } from './context.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const responseSentSymbol = Symbol.for('astro.responseSent');
@@ -45,6 +46,7 @@ export interface CreateResultArgs {
status: number;
locals: App.Locals;
cookies?: AstroCookies;
+ locales: string[] | undefined;
}
function getFunctionExpression(slot: any) {
@@ -144,6 +146,8 @@ export function createResult(args: CreateResultArgs): SSRResult {
// Astro.cookies is defined lazily to avoid the cost on pages that do not use it.
let cookies: AstroCookies | undefined = args.cookies;
+ let preferredLocale: string | undefined = undefined;
+ let preferredLocaleList: string[] | undefined = undefined;
// Create the result object that will be passed into the render function.
// This object starts here as an empty shell (not yet the result) but then
@@ -192,6 +196,28 @@ export function createResult(args: CreateResultArgs): SSRResult {
result.cookies = cookies;
return cookies;
},
+ get preferredLocale(): string | undefined {
+ if (preferredLocale) {
+ return preferredLocale;
+ }
+ if (args.locales) {
+ preferredLocale = computePreferredLocale(request, args.locales);
+ return preferredLocale;
+ }
+
+ return undefined;
+ },
+ get preferredLocaleList(): string[] | undefined {
+ if (preferredLocaleList) {
+ return preferredLocaleList;
+ }
+ if (args.locales) {
+ preferredLocaleList = computePreferredLocaleList(request, args.locales);
+ return preferredLocaleList;
+ }
+
+ return undefined;
+ },
params,
props,
locals,
diff --git a/packages/astro/src/core/render/route-cache.ts b/packages/astro/src/core/render/route-cache.ts
index 4bfb94e93f..5b22518dee 100644
--- a/packages/astro/src/core/render/route-cache.ts
+++ b/packages/astro/src/core/render/route-cache.ts
@@ -16,7 +16,7 @@ import { validateDynamicRouteModule, validateGetStaticPathsResult } from '../rou
import { generatePaginateFunction } from './paginate.js';
interface CallGetStaticPathsOptions {
- mod: ComponentInstance;
+ mod: ComponentInstance | undefined;
route: RouteData;
routeCache: RouteCache;
logger: Logger;
@@ -33,7 +33,9 @@ export async function callGetStaticPaths({
const cached = routeCache.get(route);
if (cached?.staticPaths) return cached.staticPaths;
- validateDynamicRouteModule(mod, { ssr, route });
+ if (mod) {
+ validateDynamicRouteModule(mod, { ssr, route });
+ }
// No static paths in SSR mode. Return an empty RouteCacheEntry.
if (ssr && !route.prerender) {
@@ -42,22 +44,26 @@ export async function callGetStaticPaths({
return entry;
}
+ let staticPaths: GetStaticPathsResult = [];
// Add a check here to make TypeScript happy.
// This is already checked in validateDynamicRouteModule().
- if (!mod.getStaticPaths) {
- throw new Error('Unexpected Error.');
- }
+ if (mod) {
+ if (!mod.getStaticPaths) {
+ throw new Error('Unexpected Error.');
+ }
- // Calculate your static paths.
- let staticPaths: GetStaticPathsResult = [];
- staticPaths = await mod.getStaticPaths({
- // Q: Why the cast?
- // A: So users downstream can have nicer typings, we have to make some sacrifice in our internal typings, which necessitate a cast here
- paginate: generatePaginateFunction(route) as PaginateFunction,
- rss() {
- throw new AstroError(AstroErrorData.GetStaticPathsRemovedRSSHelper);
- },
- });
+ if (mod) {
+ // Calculate your static paths.
+ staticPaths = await mod.getStaticPaths({
+ // Q: Why the cast?
+ // A: So users downstream can have nicer typings, we have to make some sacrifice in our internal typings, which necessitate a cast here
+ paginate: generatePaginateFunction(route) as PaginateFunction,
+ rss() {
+ throw new AstroError(AstroErrorData.GetStaticPathsRemovedRSSHelper);
+ },
+ });
+ }
+ }
validateGetStaticPathsResult(staticPaths, logger, route);
diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts
index 8fd3a8d821..6f24090912 100644
--- a/packages/astro/src/core/routing/manifest/create.ts
+++ b/packages/astro/src/core/routing/manifest/create.ts
@@ -60,11 +60,9 @@ function getParts(part: string, file: string) {
return result;
}
-function getPattern(
- segments: RoutePart[][],
- base: string,
- addTrailingSlash: AstroConfig['trailingSlash']
-) {
+function getPattern(segments: RoutePart[][], config: AstroConfig) {
+ const base = config.base;
+ const addTrailingSlash = config.trailingSlash;
const pathname = segments
.map((segment) => {
if (segment.length === 1 && segment[0].spread) {
@@ -327,7 +325,7 @@ export function createRouteManifest(
components.push(item.file);
const component = item.file;
const trailingSlash = item.isPage ? settings.config.trailingSlash : 'never';
- const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const pattern = getPattern(segments, settings.config);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -388,7 +386,7 @@ export function createRouteManifest(
const isPage = type === 'page';
const trailingSlash = isPage ? config.trailingSlash : 'never';
- const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const pattern = getPattern(segments, settings.config);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -435,7 +433,7 @@ export function createRouteManifest(
return getParts(s, from);
});
- const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const pattern = getPattern(segments, settings.config);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -486,6 +484,115 @@ export function createRouteManifest(
// Didn't find a good place, insert last
routes.push(routeData);
});
+ const i18n = settings.config.experimental.i18n;
+
+ if (i18n && i18n.fallback) {
+ let fallback = Object.entries(i18n.fallback);
+
+ // A map like: locale => RouteData[]
+ const routesByLocale = new Map();
+ // We create a set, so we can remove the routes that have been added to the previous map
+ const setRoutes = new Set(routes);
+
+ // First loop
+ // We loop over the locales minus the default locale and add only the routes that contain `/`.
+ for (const locale of i18n.locales.filter((loc) => loc !== i18n.defaultLocale)) {
+ for (const route of setRoutes) {
+ if (!route.route.includes(`/${locale}`)) {
+ continue;
+ }
+ const currentRoutes = routesByLocale.get(locale);
+ if (currentRoutes) {
+ currentRoutes.push(route);
+ routesByLocale.set(locale, currentRoutes);
+ } else {
+ routesByLocale.set(locale, [route]);
+ }
+ setRoutes.delete(route);
+ }
+ }
+
+ // we loop over the remaining routes and add them to the default locale
+ for (const route of setRoutes) {
+ const currentRoutes = routesByLocale.get(i18n.defaultLocale);
+ if (currentRoutes) {
+ currentRoutes.push(route);
+ routesByLocale.set(i18n.defaultLocale, currentRoutes);
+ } else {
+ routesByLocale.set(i18n.defaultLocale, [route]);
+ }
+ setRoutes.delete(route);
+ }
+
+ if (fallback.length > 0) {
+ for (const [fallbackFromLocale, fallbackToLocale] of fallback) {
+ let fallbackToRoutes;
+ if (fallbackToLocale === i18n.defaultLocale) {
+ fallbackToRoutes = routesByLocale.get(i18n.defaultLocale);
+ } else {
+ fallbackToRoutes = routesByLocale.get(fallbackToLocale);
+ }
+ const fallbackFromRoutes = routesByLocale.get(fallbackFromLocale);
+
+ // Technically, we should always have a fallback to. Added this to make TS happy.
+ if (!fallbackToRoutes) {
+ continue;
+ }
+
+ for (const fallbackToRoute of fallbackToRoutes) {
+ const hasRoute =
+ fallbackFromRoutes &&
+ // we check if the fallback from locale (the origin) has already this route
+ fallbackFromRoutes.some((route) => {
+ if (fallbackToLocale === i18n.defaultLocale) {
+ return route.route.replace(`/${fallbackFromLocale}`, '') === fallbackToRoute.route;
+ } else {
+ return (
+ route.route.replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`) ===
+ fallbackToRoute.route
+ );
+ }
+ });
+
+ if (!hasRoute) {
+ let pathname: string | undefined;
+ let route: string;
+ if (fallbackToLocale === i18n.defaultLocale) {
+ if (fallbackToRoute.pathname) {
+ pathname = `/${fallbackFromLocale}${fallbackToRoute.pathname}`;
+ }
+ route = `/${fallbackFromLocale}${fallbackToRoute.route}`;
+ } else {
+ pathname = fallbackToRoute.pathname?.replace(
+ `/${fallbackToLocale}`,
+ `/${fallbackFromLocale}`
+ );
+ route = fallbackToRoute.route.replace(
+ `/${fallbackToLocale}`,
+ `/${fallbackFromLocale}`
+ );
+ }
+
+ const segments = removeLeadingForwardSlash(route)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, route);
+ });
+ routes.push({
+ ...fallbackToRoute,
+ pathname,
+ route,
+ segments,
+ pattern: getPattern(segments, config),
+ type: 'fallback',
+ });
+ }
+ }
+ }
+ }
+ }
return {
routes,
diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts
new file mode 100644
index 0000000000..e385396136
--- /dev/null
+++ b/packages/astro/src/i18n/index.ts
@@ -0,0 +1,142 @@
+import { AstroError } from '../core/errors/index.js';
+import { MissingLocale } from '../core/errors/errors-data.js';
+import { shouldAppendForwardSlash } from '../core/build/util.js';
+import type { AstroConfig } from '../@types/astro.js';
+import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path';
+
+type GetLocaleRelativeUrl = GetLocaleOptions & {
+ locale: string;
+ base: string;
+ locales: string[];
+ trailingSlash: AstroConfig['trailingSlash'];
+ format: AstroConfig['build']['format'];
+ routingStrategy?: 'prefix-always' | 'prefix-other-locales';
+ defaultLocale: string;
+};
+
+export type GetLocaleOptions = {
+ /**
+ * Makes the locale URL-friendly by replacing underscores with dashes, and converting the locale to lower case.
+ * @default true
+ */
+ normalizeLocale?: boolean;
+ /**
+ * An optional path to add after the `locale`.
+ */
+ path?: string;
+ /**
+ * An optional path to prepend to `locale`.
+ */
+ prependWith?: string;
+};
+
+type GetLocaleAbsoluteUrl = GetLocaleRelativeUrl & {
+ site: AstroConfig['site'];
+};
+/**
+ * The base URL
+ */
+export function getLocaleRelativeUrl({
+ locale,
+ base,
+ locales,
+ trailingSlash,
+ format,
+ path,
+ prependWith,
+ normalizeLocale = true,
+ routingStrategy = 'prefix-other-locales',
+ defaultLocale,
+}: GetLocaleRelativeUrl) {
+ if (!locales.includes(locale)) {
+ throw new AstroError({
+ ...MissingLocale,
+ message: MissingLocale.message(locale, locales),
+ });
+ }
+ const pathsToJoin = [base, prependWith];
+ const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale;
+ if (routingStrategy === 'prefix-always') {
+ pathsToJoin.push(normalizedLocale);
+ } else if (locale !== defaultLocale) {
+ pathsToJoin.push(normalizedLocale);
+ }
+ pathsToJoin.push(path);
+
+ if (shouldAppendForwardSlash(trailingSlash, format)) {
+ return appendForwardSlash(joinPaths(...pathsToJoin));
+ } else {
+ return joinPaths(...pathsToJoin);
+ }
+}
+
+/**
+ * The absolute URL
+ */
+export function getLocaleAbsoluteUrl({ site, ...rest }: GetLocaleAbsoluteUrl) {
+ const locale = getLocaleRelativeUrl(rest);
+ if (site) {
+ return joinPaths(site, locale);
+ } else {
+ return locale;
+ }
+}
+
+type GetLocalesBaseUrl = GetLocaleOptions & {
+ base: string;
+ locales: string[];
+ trailingSlash: AstroConfig['trailingSlash'];
+ format: AstroConfig['build']['format'];
+ routingStrategy?: 'prefix-always' | 'prefix-other-locales';
+ defaultLocale: string;
+};
+
+export function getLocaleRelativeUrlList({
+ base,
+ locales,
+ trailingSlash,
+ format,
+ path,
+ prependWith,
+ normalizeLocale = false,
+ routingStrategy = 'prefix-other-locales',
+ defaultLocale,
+}: GetLocalesBaseUrl) {
+ return locales.map((locale) => {
+ const pathsToJoin = [base, prependWith];
+ const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale;
+
+ if (routingStrategy === 'prefix-always') {
+ pathsToJoin.push(normalizedLocale);
+ } else if (locale !== defaultLocale) {
+ pathsToJoin.push(normalizedLocale);
+ }
+ pathsToJoin.push(path);
+ if (shouldAppendForwardSlash(trailingSlash, format)) {
+ return appendForwardSlash(joinPaths(...pathsToJoin));
+ } else {
+ return joinPaths(...pathsToJoin);
+ }
+ });
+}
+
+export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl) {
+ const locales = getLocaleRelativeUrlList(rest);
+ return locales.map((locale) => {
+ if (site) {
+ return joinPaths(site, locale);
+ } else {
+ return locale;
+ }
+ });
+}
+
+/**
+ *
+ * Given a locale, this function:
+ * - replaces the `_` with a `-`;
+ * - transforms all letters to be lower case;
+ */
+export function normalizeTheLocale(locale: string): string {
+ return locale.replaceAll('_', '-').toLowerCase();
+}
diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts
new file mode 100644
index 0000000000..b8ebfe8329
--- /dev/null
+++ b/packages/astro/src/i18n/middleware.ts
@@ -0,0 +1,81 @@
+import type { MiddlewareEndpointHandler } from '../@types/astro.js';
+import type { SSRManifest } from '../@types/astro.js';
+import { joinPaths } from '@astrojs/internal-helpers/path';
+
+// 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;
+ }
+ }
+
+ return true;
+}
+
+export function createI18nMiddleware(
+ i18n: SSRManifest['i18n'],
+ base: SSRManifest['base']
+): MiddlewareEndpointHandler | undefined {
+ if (!i18n) {
+ return undefined;
+ }
+
+ return async (context, next) => {
+ if (!i18n) {
+ return await next();
+ }
+
+ const { locales, defaultLocale, fallback } = i18n;
+ const url = context.url;
+
+ 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.routingStrategy === 'prefix-other-locales' && pathnameContainsDefaultLocale) {
+ const newLocation = url.pathname.replace(`/${defaultLocale}`, '');
+ response.headers.set('Location', newLocation);
+ return new Response(null, {
+ status: 404,
+ headers: response.headers,
+ });
+ } else if (i18n.routingStrategy === 'prefix-always') {
+ if (url.pathname === base || url.pathname === base + '/') {
+ return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`);
+ }
+
+ // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content.
+ else if (isLocaleFree) {
+ return new Response(null, {
+ status: 404,
+ headers: response.headers,
+ });
+ }
+ }
+ if (response.status >= 300 && fallback) {
+ const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : [];
+
+ const urlLocale = separators.find((s) => locales.includes(s));
+
+ if (urlLocale && fallbackKeys.includes(urlLocale)) {
+ const fallbackLocale = fallback[urlLocale];
+ 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) {
+ newPathname = url.pathname.replace(`/${urlLocale}`, ``);
+ } else {
+ newPathname = url.pathname.replace(`/${urlLocale}`, `/${fallbackLocale}`);
+ }
+
+ return context.redirect(newPathname);
+ }
+ }
+ }
+
+ return response;
+ };
+}
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 0000000000..d3630bc2ac
--- /dev/null
+++ b/packages/astro/src/i18n/vite-plugin-i18n.ts
@@ -0,0 +1,66 @@
+import * as vite from 'vite';
+import type { AstroSettings } from '../@types/astro.js';
+
+const virtualModuleId = 'astro:i18n';
+const resolvedVirtualModuleId = '\0' + virtualModuleId;
+
+type AstroInternationalization = {
+ settings: AstroSettings;
+};
+
+export default function astroInternationalization({
+ settings,
+}: AstroInternationalization): vite.Plugin {
+ return {
+ name: 'astro:i18n',
+ enforce: 'pre',
+ async resolveId(id) {
+ if (id === virtualModuleId) {
+ return resolvedVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedVirtualModuleId) {
+ return `
+ import {
+ getLocaleRelativeUrl as _getLocaleRelativeUrl,
+ getLocaleRelativeUrlList as _getLocaleRelativeUrlList,
+ getLocaleAbsoluteUrl as _getLocaleAbsoluteUrl,
+ getLocaleAbsoluteUrlList as _getLocaleAbsoluteUrlList,
+
+ } from "astro/i18n";
+
+ const base = ${JSON.stringify(settings.config.base)};
+ const trailingSlash = ${JSON.stringify(settings.config.trailingSlash)};
+ const format = ${JSON.stringify(settings.config.build.format)};
+ const site = ${JSON.stringify(settings.config.site)};
+ const i18n = ${JSON.stringify(settings.config.experimental.i18n)};
+
+ export const getLocaleRelativeUrl = (locale, path = "", opts) => _getLocaleRelativeUrl({
+ locale,
+ path,
+ base,
+ trailingSlash,
+ format,
+ ...i18n,
+ ...opts
+ });
+ export const getLocaleAbsoluteUrl = (locale, path = "", opts) => _getLocaleAbsoluteUrl({
+ locale,
+ path,
+ base,
+ trailingSlash,
+ format,
+ site,
+ ...i18n,
+ ...opts
+ });
+
+ export const getLocaleRelativeUrlList = (path = "", opts) => _getLocaleRelativeUrlList({
+ base, path, trailingSlash, format, ...i18n, ...opts });
+ export const getLocaleAbsoluteUrlList = (path = "", opts) => _getLocaleAbsoluteUrlList({ base, path, trailingSlash, format, site, ...i18n, ...opts });
+ `;
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/integrations/astroFeaturesValidation.ts b/packages/astro/src/integrations/astroFeaturesValidation.ts
index 3231b15771..a26f42afbe 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/src/vite-plugin-astro-server/devPipeline.ts b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts
index e16b3d7e23..d9758f4783 100644
--- a/packages/astro/src/vite-plugin-astro-server/devPipeline.ts
+++ b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts
@@ -91,4 +91,6 @@ export default class DevPipeline extends Pipeline {
async #handleEndpointResult(_: Request, response: Response): Promise {
return response;
}
+
+ async handleFallback() {}
}
diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts
index daa1c01e68..34125e84f3 100644
--- a/packages/astro/src/vite-plugin-astro-server/plugin.ts
+++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts
@@ -9,6 +9,7 @@ import { baseMiddleware } from './base.js';
import { createController } from './controller.js';
import DevPipeline from './devPipeline.js';
import { handleRequest } from './request.js';
+import type { SSRManifestI18n } from '../core/app/types.js';
export interface AstroPluginOptions {
settings: AstroSettings;
@@ -85,6 +86,15 @@ export default function createVitePluginAstroServer({
* @param renderers
*/
export function createDevelopmentManifest(settings: AstroSettings): SSRManifest {
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.experimental.i18n) {
+ i18nManifest = {
+ fallback: settings.config.experimental.i18n.fallback,
+ routingStrategy: settings.config.experimental.i18n.routingStrategy,
+ defaultLocale: settings.config.experimental.i18n.defaultLocale,
+ locales: settings.config.experimental.i18n.locales,
+ };
+ }
return {
compressHTML: settings.config.compressHTML,
assets: new Set(),
@@ -99,5 +109,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
? new URL(settings.config.base, settings.config.site).toString()
: settings.config.site,
componentMetadata: new Map(),
+ i18n: i18nManifest,
};
}
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index c9eb06bf66..7588f04f79 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -10,7 +10,12 @@ import type {
} from '../@types/astro.js';
import { AstroErrorData, isAstroError } from '../core/errors/index.js';
import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
-import { createRenderContext, getParamsAndProps, type SSROptions } from '../core/render/index.js';
+import {
+ createRenderContext,
+ getParamsAndProps,
+ type RenderContext,
+ type SSROptions,
+} from '../core/render/index.js';
import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js';
import { isPage, resolveIdToUrl } from '../core/util.js';
@@ -24,6 +29,8 @@ import { preload } from './index.js';
import { getComponentMetadata } from './metadata.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { getScriptsForURL } from './scripts.js';
+import { createI18nMiddleware } from '../i18n/middleware.js';
+import { sequence } from '../core/middleware/index.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@@ -53,7 +60,8 @@ export async function matchRoute(
): Promise {
const env = pipeline.getEnvironment();
const { routeCache, logger } = env;
- const matches = matchAllRoutes(pathname, manifestData);
+ let matches = matchAllRoutes(pathname, manifestData);
+
const preloadedMatches = await getSortedPreloadedMatches({
pipeline,
matches,
@@ -158,70 +166,136 @@ export async function handleRoute({
const config = pipeline.getConfig();
const moduleLoader = pipeline.getModuleLoader();
const { logger } = env;
- if (!matchedRoute) {
+ if (!matchedRoute && !config.experimental.i18n) {
return handle404Response(origin, incomingRequest, incomingResponse);
}
- const filePath: URL | undefined = matchedRoute.filePath;
- const { route, preloadedComponent } = matchedRoute;
const buildingToSSR = isServerLikeOutput(config);
- // Headers are only available when using SSR.
- const request = createRequest({
- url,
- headers: buildingToSSR ? incomingRequest.headers : new Headers(),
- method: incomingRequest.method,
- body,
- logger,
- ssr: buildingToSSR,
- clientAddress: buildingToSSR ? incomingRequest.socket.remoteAddress : undefined,
- locals: Reflect.get(incomingRequest, clientLocalsSymbol), // Allows adapters to pass in locals in dev mode.
- });
-
- // Set user specified headers to response object.
- for (const [name, value] of Object.entries(config.server.headers ?? {})) {
- if (value) incomingResponse.setHeader(name, value);
- }
-
- const options: SSROptions = {
- env,
- filePath,
- preload: preloadedComponent,
- pathname,
- request,
- route,
- };
+ let request: Request;
+ let renderContext: RenderContext;
+ let mod: ComponentInstance | undefined = undefined;
+ let options: SSROptions | undefined = undefined;
+ let route: RouteData;
const middleware = await loadMiddleware(moduleLoader, settings.config.srcDir);
- if (middleware) {
- options.middleware = middleware;
+
+ if (!matchedRoute) {
+ if (config.experimental.i18n) {
+ const locales = config.experimental.i18n.locales;
+ const pathNameHasLocale = pathname
+ .split('/')
+ .filter(Boolean)
+ .some((segment) => {
+ return locales.includes(segment);
+ });
+ if (!pathNameHasLocale) {
+ return handle404Response(origin, incomingRequest, incomingResponse);
+ }
+ request = createRequest({
+ url,
+ headers: buildingToSSR ? incomingRequest.headers : new Headers(),
+ logger,
+ ssr: buildingToSSR,
+ });
+ route = {
+ component: '',
+ generate(_data: any): string {
+ return '';
+ },
+ params: [],
+ pattern: new RegExp(''),
+ prerender: false,
+ segments: [],
+ type: 'fallback',
+ route: '',
+ };
+ renderContext = await createRenderContext({
+ request,
+ pathname,
+ env,
+ mod,
+ route,
+ });
+ } else {
+ return handle404Response(origin, incomingRequest, incomingResponse);
+ }
+ } else {
+ const filePath: URL | undefined = matchedRoute.filePath;
+ const { preloadedComponent } = matchedRoute;
+ route = matchedRoute.route;
+ // Headers are only available when using SSR.
+ request = createRequest({
+ url,
+ headers: buildingToSSR ? incomingRequest.headers : new Headers(),
+ method: incomingRequest.method,
+ body,
+ logger,
+ ssr: buildingToSSR,
+ clientAddress: buildingToSSR ? incomingRequest.socket.remoteAddress : undefined,
+ locals: Reflect.get(incomingRequest, clientLocalsSymbol), // Allows adapters to pass in locals in dev mode.
+ });
+
+ // Set user specified headers to response object.
+ for (const [name, value] of Object.entries(config.server.headers ?? {})) {
+ if (value) incomingResponse.setHeader(name, value);
+ }
+
+ options = {
+ env,
+ filePath,
+ preload: preloadedComponent,
+ pathname,
+ request,
+ route,
+ };
+ if (middleware) {
+ options.middleware = middleware;
+ }
+
+ mod = options.preload;
+
+ const { scripts, links, styles, metadata } = await getScriptsAndStyles({
+ pipeline,
+ filePath: options.filePath,
+ });
+
+ const i18n = pipeline.getConfig().experimental.i18n;
+
+ renderContext = await createRenderContext({
+ request: options.request,
+ pathname: options.pathname,
+ scripts,
+ links,
+ styles,
+ componentMetadata: metadata,
+ route: options.route,
+ mod,
+ env,
+ locales: i18n ? i18n.locales : undefined,
+ });
}
- const mod = options.preload;
- const { scripts, links, styles, metadata } = await getScriptsAndStyles({
- pipeline,
- filePath: options.filePath,
- });
+ const onRequest = middleware?.onRequest as MiddlewareEndpointHandler | undefined;
+ if (config.experimental.i18n) {
+ const i18Middleware = createI18nMiddleware(config.experimental.i18n, config.base);
- const renderContext = await createRenderContext({
- request: options.request,
- pathname: options.pathname,
- scripts,
- links,
- styles,
- componentMetadata: metadata,
- route: options.route,
- mod,
- env,
- });
- const onRequest = options.middleware?.onRequest as MiddlewareEndpointHandler | undefined;
- if (onRequest) {
+ if (i18Middleware) {
+ if (onRequest) {
+ pipeline.setMiddlewareFunction(sequence(i18Middleware, onRequest));
+ } else {
+ pipeline.setMiddlewareFunction(i18Middleware);
+ }
+ } else if (onRequest) {
+ pipeline.setMiddlewareFunction(onRequest);
+ }
+ } else if (onRequest) {
pipeline.setMiddlewareFunction(onRequest);
}
let response = await pipeline.renderRoute(renderContext, mod);
if (response.status === 404 && has404Route(manifestData)) {
const fourOhFourRoute = await matchRoute('/404', manifestData, pipeline);
- if (fourOhFourRoute?.route !== options.route)
+ if (options && fourOhFourRoute?.route !== options.route)
return handleRoute({
...options,
matchedRoute: fourOhFourRoute,
diff --git a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
new file mode 100644
index 0000000000..d20245efb0
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ base: "new-site",
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ],
+ routingStrategy: "prefix-always"
+ }
+ }
+})
diff --git a/packages/astro/test/fixtures/i18n-routing-base/package.json b/packages/astro/test/fixtures/i18n-routing-base/package.json
new file mode 100644
index 0000000000..f179231127
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing-base",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro
new file mode 100644
index 0000000000..d9f61aa025
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro
new file mode 100644
index 0000000000..15a63a7b87
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hola
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-fallback/astro.config.mjs
new file mode 100644
index 0000000000..f7524a6421
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/astro.config.mjs
@@ -0,0 +1,17 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ base: "new-site",
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ],
+ fallback: {
+ "it": "en",
+ pt: "en"
+ }
+ }
+ }
+})
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/package.json b/packages/astro/test/fixtures/i18n-routing-fallback/package.json
new file mode 100644
index 0000000000..0741df4553
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing-fallabck",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/end.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/end.astro
new file mode 100644
index 0000000000..9f33d8aa0b
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/end.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+End
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/start.astro
new file mode 100644
index 0000000000..5a4a84c2cf
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Oi essa e start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/start.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/start.astro
new file mode 100644
index 0000000000..990baecd9a
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs
new file mode 100644
index 0000000000..f2aac5899c
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ],
+ routingStrategy: "prefix-always"
+ }
+ },
+ base: "/new-site"
+})
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/package.json b/packages/astro/test/fixtures/i18n-routing-prefix-always/package.json
new file mode 100644
index 0000000000..8b04265b57
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing-prefix-always",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/index.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/index.astro
new file mode 100644
index 0000000000..3e50ac6bf3
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro
new file mode 100644
index 0000000000..990baecd9a
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro
new file mode 100644
index 0000000000..5a4a84c2cf
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Oi essa e start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/astro.config.mjs
new file mode 100644
index 0000000000..3b8911f6d4
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/astro.config.mjs
@@ -0,0 +1,15 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ],
+ routingStrategy: "prefix-other-locales"
+ },
+
+ },
+ base: "/new-site"
+})
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/package.json b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/package.json
new file mode 100644
index 0000000000..2365993b52
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing-prefix-other-locales",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/start.astro
new file mode 100644
index 0000000000..5a4a84c2cf
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Oi essa e start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/start.astro
new file mode 100644
index 0000000000..990baecd9a
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Start
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs
new file mode 100644
index 0000000000..209ad40fd0
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ]
+ }
+ },
+})
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json
new file mode 100644
index 0000000000..6cb31aafbd
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing-preferred-language",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro
new file mode 100644
index 0000000000..9f33d8aa0b
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+End
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro
new file mode 100644
index 0000000000..d9f61aa025
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro
new file mode 100644
index 0000000000..1fb998c60b
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro
@@ -0,0 +1,13 @@
+---
+const locale = Astro.preferredLocale;
+const localeList = Astro.preferredLocaleList;
+---
+
+
+
+ Astro
+
+
+ Locale: {locale ? locale : "none"}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro
new file mode 100644
index 0000000000..15a63a7b87
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hola
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs
new file mode 100644
index 0000000000..209ad40fd0
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig} from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ 'en', 'pt', 'it'
+ ]
+ }
+ },
+})
diff --git a/packages/astro/test/fixtures/i18n-routing/package.json b/packages/astro/test/fixtures/i18n-routing/package.json
new file mode 100644
index 0000000000..6146808c1e
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-routing",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing/src/pages/en/blog/[id].astro
new file mode 100644
index 0000000000..97b41230d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/en/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hello world" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/en/start.astro
new file mode 100644
index 0000000000..d9f61aa025
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/en/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/index.astro
new file mode 100644
index 0000000000..05faf7b0bc
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+ Hello
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/preferred-locale.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/preferred-locale.astro
new file mode 100644
index 0000000000..e6b9324ce6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/preferred-locale.astro
@@ -0,0 +1,15 @@
+---
+const locale = Astro.preferredLocale;
+const localeList = Astro.preferredLocaleList;
+
+---
+
+
+
+ Astro
+
+
+ Locale: {locale ? locale : "none"}
+ Locale list: {localeList.length > 0 ? localeList.join(", ") : "empty"}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/blog/[id].astro
new file mode 100644
index 0000000000..e37f83a302
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/blog/[id].astro
@@ -0,0 +1,18 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {id: '1'}, props: { content: "Hola mundo" }},
+ {params: {id: '2'}, props: { content: "Eat Something" }},
+ {params: {id: '3'}, props: { content: "How are you?" }},
+ ];
+}
+const { content } = Astro.props;
+---
+
+
+ Astro
+
+
+{content}
+
+
diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro
new file mode 100644
index 0000000000..15a63a7b87
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro
@@ -0,0 +1,8 @@
+
+
+ Astro
+
+
+Hola
+
+
diff --git a/packages/astro/test/i18-routing.test.js b/packages/astro/test/i18-routing.test.js
new file mode 100644
index 0000000000..0aa183d54f
--- /dev/null
+++ b/packages/astro/test/i18-routing.test.js
@@ -0,0 +1,916 @@
+import { loadFixture } from './test-utils.js';
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import testAdapter from './test-adapter.js';
+
+describe('[DEV] i18n routing', () => {
+ describe('i18n routing', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('should render the en locale', async () => {
+ const response = await fixture.fetch('/en/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+
+ const response2 = await fixture.fetch('/en/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ const response = await fixture.fetch('/pt/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+
+ const response2 = await fixture.fetch('/pt/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hola mundo');
+ });
+
+ 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);
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ const response = await fixture.fetch('/fr/start');
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing, with base', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-base/',
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('should render the en locale', async () => {
+ const response = await fixture.fetch('/new-site/en/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Hello');
+
+ const response2 = await fixture.fetch('/new-site/en/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ const response = await fixture.fetch('/new-site/pt/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Hola');
+
+ const response2 = await fixture.fetch('/new-site/pt/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hola mundo');
+ });
+
+ 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);
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ const response = await fixture.fetch('/new-site/fr/start');
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-other-locales]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-other-locales/',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'pt', 'it'],
+ fallback: {
+ it: 'en',
+ },
+ },
+ },
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('should render the default locale without prefix', async () => {
+ const response = await fixture.fetch('/new-site/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+
+ const response2 = await fixture.fetch('/new-site/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
+ it('should return 404 when route contains the default locale', async () => {
+ const response = await fixture.fetch('/new-site/en/start');
+ expect(response.status).to.equal(404);
+
+ const response2 = await fixture.fetch('/new-site/en/blog/1');
+ expect(response2.status).to.equal(404);
+ });
+
+ it('should render localised page correctly', async () => {
+ const response = await fixture.fetch('/new-site/pt/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+
+ const response2 = await fixture.fetch('/new-site/pt/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hola mundo');
+ });
+
+ 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);
+ expect(await response.text()).includes('Start');
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ const response = await fixture.fetch('/new-site/fr/start');
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-always]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('should redirect to the index of the default locale', async () => {
+ const response = await fixture.fetch('/new-site');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Hello');
+ });
+
+ it('should not render the default locale without prefix', async () => {
+ const response = await fixture.fetch('/new-site/start');
+ expect(response.status).to.equal(404);
+ expect(await response.text()).not.includes('Start');
+
+ const response2 = await fixture.fetch('/new-site/blog/1');
+ expect(response2.status).to.equal(404);
+ expect(await response2.text()).not.includes('Hello world');
+ });
+
+ it('should render the default locale with prefix', async () => {
+ const response = await fixture.fetch('/new-site/en/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+
+ const response2 = await fixture.fetch('/new-site/en/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ const response = await fixture.fetch('/new-site/pt/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+
+ const response2 = await fixture.fetch('/new-site/pt/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hola mundo');
+ });
+
+ it('should not redirect to the english locale', async () => {
+ const response = await fixture.fetch('/new-site/it/start');
+ expect(response.status).to.equal(404);
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ const response = await fixture.fetch('/new-site/fr/start');
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing fallback', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-fallback/',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'pt', 'it'],
+ fallback: {
+ it: 'en',
+ },
+ routingStrategy: 'prefix-other-locales',
+ },
+ },
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('should render the default locale without prefix', async () => {
+ const response = await fixture.fetch('/new-site/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+
+ const response2 = await fixture.fetch('/new-site/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ const response = await fixture.fetch('/new-site/pt/start');
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+
+ const response2 = await fixture.fetch('/new-site/pt/blog/1');
+ expect(response2.status).to.equal(200);
+ expect(await response2.text()).includes('Hola mundo');
+ });
+
+ 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);
+ expect(await response.text()).includes('Start');
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ const response = await fixture.fetch('/new-site/fr/start');
+ expect(response.status).to.equal(404);
+ });
+ });
+});
+
+describe('[SSG] i18n routing', () => {
+ describe('i18n routing', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ });
+ await fixture.build();
+ });
+
+ it('should render the en locale', async () => {
+ let html = await fixture.readFile('/en/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Start');
+
+ html = await fixture.readFile('/en/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ let html = await fixture.readFile('/pt/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Oi essa e start');
+
+ html = await fixture.readFile('/pt/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola mundo');
+ });
+
+ 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');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ try {
+ await fixture.readFile('/fr/start/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+ });
+
+ describe('i18n routing, with base', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-base/',
+ });
+ await fixture.build();
+ });
+
+ it('should render the en locale', async () => {
+ let html = await fixture.readFile('/en/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello');
+
+ html = await fixture.readFile('/en/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ let html = await fixture.readFile('/pt/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola');
+
+ html = await fixture.readFile('/pt/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola mundo');
+ });
+
+ 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');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ try {
+ await fixture.readFile('/fr/start/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-other-locales]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-other-locales/',
+ });
+ await fixture.build();
+ });
+
+ it('should render the en locale', async () => {
+ let html = await fixture.readFile('/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Start');
+
+ html = await fixture.readFile('/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello world');
+ });
+
+ it('should return 404 when route contains the default locale', async () => {
+ try {
+ await fixture.readFile('/start/en/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+
+ it('should render localised page correctly', async () => {
+ let html = await fixture.readFile('/pt/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Oi essa e start');
+
+ html = await fixture.readFile('/pt/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola mundo');
+ });
+
+ 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');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ try {
+ await fixture.readFile('/fr/start/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-always]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ });
+ await fixture.build();
+ });
+
+ it('should redirect to the index of the default locale', async () => {
+ const html = await fixture.readFile('/index.html');
+ expect(html).to.include('http-equiv="refresh');
+ expect(html).to.include('url=/new-site/en');
+ });
+
+ it('should render the en locale', async () => {
+ let html = await fixture.readFile('/en/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Start');
+
+ html = await fixture.readFile('/en/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ let html = await fixture.readFile('/pt/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Oi essa e start');
+
+ html = await fixture.readFile('/pt/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola mundo');
+ });
+
+ 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');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ try {
+ await fixture.readFile('/fr/start/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+ });
+
+ describe('i18n routing with fallback', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-fallback/',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'pt', 'it'],
+ fallback: {
+ it: 'en',
+ },
+ },
+ },
+ });
+ await fixture.build();
+ });
+
+ it('should render the en locale', async () => {
+ let html = await fixture.readFile('/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Start');
+
+ html = await fixture.readFile('/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hello world');
+ });
+
+ it('should render localised page correctly', async () => {
+ let html = await fixture.readFile('/pt/start/index.html');
+ let $ = cheerio.load(html);
+ expect($('body').text()).includes('Oi essa e start');
+
+ html = await fixture.readFile('/pt/blog/1/index.html');
+ $ = cheerio.load(html);
+ expect($('body').text()).includes('Hola mundo');
+ });
+
+ 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');
+ expect(html).to.include('url=/new-site/start');
+ });
+
+ it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
+ try {
+ await fixture.readFile('/fr/start/index.html');
+ // failed
+ return false;
+ } catch {
+ // success
+ return true;
+ }
+ });
+ });
+});
+
+describe('[SSR] i18n routing', () => {
+ let app;
+ describe('default', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should redirect to the index of the default locale', async () => {
+ let request = new Request('http://example.com/new-site');
+ let response = await app.render(request);
+ expect(response.status).to.equal(302);
+ expect(response.headers.get('location')).to.equal('/new-site/en');
+ });
+
+ it('should render the en locale', async () => {
+ let request = new Request('http://example.com/en/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+ });
+
+ it('should render localised page correctly', async () => {
+ let request = new Request('http://example.com/pt/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+
+ 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/fr/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('with base', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should render the en locale', async () => {
+ let request = new Request('http://example.com/en/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+ });
+
+ it('should render localised page correctly', async () => {
+ let request = new Request('http://example.com/new-site/pt/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-other-locales]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-other-locales/',
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should render the en locale', async () => {
+ let request = new Request('http://example.com/new-site/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+ });
+
+ it('should return 404 if route contains the default locale', async () => {
+ let request = new Request('http://example.com/new-site/en/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(404);
+ });
+
+ it('should render localised page correctly', async () => {
+ let request = new Request('http://example.com/new-site/pt/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('i18n routing with routing strategy [prefix-always]', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-prefix-always/',
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should redirect the index to the default locale', async () => {
+ let request = new Request('http://example.com/new-site');
+ let response = await app.render(request);
+ expect(response.status).to.equal(302);
+ expect(response.headers.get('location')).to.equal('/new-site/en');
+ });
+
+ it('should render the en locale', async () => {
+ let request = new Request('http://example.com/new-site/en/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+ });
+
+ it('should render localised page correctly', async () => {
+ let request = new Request('http://example.com/pt/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+ });
+
+ 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);
+ expect(response.status).to.equal(404);
+ });
+
+ 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/fr/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('with fallback', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing-fallback/',
+ output: 'server',
+ adapter: testAdapter(),
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'pt', 'it'],
+ fallback: {
+ it: 'en',
+ },
+ },
+ },
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should render the en locale', async () => {
+ let request = new Request('http://example.com/new-site/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Start');
+ });
+
+ it('should render localised page correctly', async () => {
+ let request = new Request('http://example.com/new-site/pt/start');
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Oi essa e start');
+ });
+
+ it('should redirect to the english locale, which is the first fallback', async () => {
+ let request = new Request('http://example.com/new-site/it/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);
+ expect(response.status).to.equal(404);
+ });
+ });
+
+ describe('preferred locale', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing/',
+ output: 'server',
+ adapter: testAdapter(),
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('should not render the locale when the value is *', async () => {
+ let request = new Request('http://example.com/preferred-locale', {
+ headers: {
+ 'Accept-Language': '*',
+ },
+ });
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Locale: none');
+ });
+
+ it('should render the locale pt', async () => {
+ let request = new Request('http://example.com/preferred-locale', {
+ headers: {
+ 'Accept-Language': 'pt',
+ },
+ });
+ let response = await app.render(request);
+ expect(response.status).to.equal(200);
+ expect(await response.text()).includes('Locale: pt');
+ });
+
+ it('should render empty locales', async () => {
+ let request = new Request('http://example.com/preferred-locale', {
+ headers: {
+ 'Accept-Language': 'fr;q=0.1,fr-AU;q=0.9',
+ },
+ });
+ let response = await app.render(request);
+ const text = await response.text();
+ expect(response.status).to.equal(200);
+ expect(text).includes('Locale: none');
+ expect(text).includes('Locale list: empty');
+ });
+
+ it('should render none as preferred locale, but have a list of locales that correspond to the initial locales', async () => {
+ let request = new Request('http://example.com/preferred-locale', {
+ headers: {
+ 'Accept-Language': '*',
+ },
+ });
+ let response = await app.render(request);
+ const text = await response.text();
+ expect(response.status).to.equal(200);
+ expect(text).includes('Locale: none');
+ expect(text).includes('Locale list: en, pt, it');
+ });
+
+ describe('in case the configured locales use underscores', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-routing/',
+ output: 'server',
+ adapter: testAdapter(),
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['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: pt_BR');
+ expect(text).includes('Locale list: pt_BR, en_AU');
+ });
+ });
+ });
+});
diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js
index 48088468ae..93dd0e28dd 100644
--- a/packages/astro/test/units/config/config-validate.test.js
+++ b/packages/astro/test/units/config/config-validate.test.js
@@ -77,4 +77,87 @@ describe('Config Validation', () => {
'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop'
);
});
+
+ describe('i18n', async () => {
+ it('defaultLocale is not in locales', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['es'],
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal(
+ 'The default locale `en` is not present in the `i18n.locales` array.'
+ );
+ });
+
+ it('errors if a fallback value does not exist', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['es', 'en'],
+ fallback: {
+ es: 'it',
+ },
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal(
+ "The locale `it` value in the `i18n.fallback` record doesn't exist in the `i18n.locales` array."
+ );
+ });
+
+ it('errors if a fallback key does not exist', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['es', 'en'],
+ fallback: {
+ it: 'en',
+ },
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal(
+ "The locale `it` key in the `i18n.fallback` record doesn't exist in the `i18n.locales` array."
+ );
+ });
+
+ it('errors if a fallback key contains the default locale', async () => {
+ const configError = await validateConfig(
+ {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['es', 'en'],
+ fallback: {
+ en: 'es',
+ },
+ },
+ },
+ },
+ process.cwd()
+ ).catch((err) => err);
+ expect(configError instanceof z.ZodError).to.equal(true);
+ expect(configError.errors[0].message).to.equal(
+ "You can't use the default locale as a key. The default locale can only be used as value."
+ );
+ });
+ });
});
diff --git a/packages/astro/test/units/i18n/astro_i18n.js b/packages/astro/test/units/i18n/astro_i18n.js
new file mode 100644
index 0000000000..f90ad14e69
--- /dev/null
+++ b/packages/astro/test/units/i18n/astro_i18n.js
@@ -0,0 +1,1071 @@
+import {
+ getLocaleRelativeUrl,
+ getLocaleRelativeUrlList,
+ getLocaleAbsoluteUrl,
+ getLocaleAbsoluteUrlList,
+} from '../../../dist/i18n/index.js';
+import { parseLocale } from '../../../dist/core/render/context.js';
+import { expect } from 'chai';
+
+describe('getLocaleRelativeUrl', () => {
+ it('should correctly return the URL with the base', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+
+ // directory format
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog/',
+ trailingSlash: 'always',
+ format: 'directory',
+ ...config.experimental.i18n,
+ })
+ ).to.eq('/blog/');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.throw;
+
+ // file format
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.throw;
+ });
+
+ it('should correctly return the URL without base', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es'],
+ },
+ },
+ };
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/es/');
+ });
+
+ it('should correctly handle the trailing slash', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'directory',
+ })
+ ).to.eq('/blog');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'directory',
+ })
+ ).to.eq('/blog/');
+
+ // directory file
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'file',
+ })
+ ).to.eq('/blog');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ // ignore + file => no trailing slash
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'file',
+ })
+ ).to.eq('/blog');
+ });
+
+ it('should normalize locales by default', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'en_AU'],
+ },
+ },
+ };
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/en-us/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ normalizeLocale: false,
+ })
+ ).to.eq('/blog/en_US/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_AU',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/en-au/');
+ });
+
+ it('should return the default locale when routing strategy is [prefix-always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es', 'en_US', 'en_AU'],
+ routingStrategy: 'prefix-always',
+ },
+ },
+ };
+
+ // directory format
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog/',
+ trailingSlash: 'always',
+ format: 'directory',
+ ...config.experimental.i18n,
+ })
+ ).to.eq('/blog/en/');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.throw;
+
+ // file format
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/en/');
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('/blog/es/');
+
+ expect(
+ getLocaleRelativeUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.throw;
+ });
+});
+
+describe('getLocaleRelativeUrlList', () => {
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'directory',
+ })
+ ).to.have.members(['/blog', '/blog/en_US', '/blog/es']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'file',
+ })
+ ).to.have.members(['/blog', '/blog/en_US', '/blog/es']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'file',
+ })
+ ).to.have.members(['/blog', '/blog/en_US', '/blog/es']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'directory',
+ })
+ ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStategy: prefix-always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ routingStrategy: 'prefix-always',
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleRelativeUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'directory',
+ })
+ ).to.have.members(['/blog/en', '/blog/en_US', '/blog/es']);
+ });
+});
+
+describe('getLocaleAbsoluteUrl', () => {
+ it('should correctly return the URL with the base', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+
+ // directory format
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog/',
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ ...config.experimental.i18n,
+ })
+ ).to.eq('https://example.com/blog/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.throw;
+
+ // file format
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/blog/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.throw;
+ });
+
+ it('should correctly return the URL without base', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es'],
+ },
+ },
+ };
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.eq('https://example.com/es/');
+ });
+
+ it('should correctly handle the trailing slash', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'directory',
+ })
+ ).to.eq('https://example.com/blog');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'directory',
+ })
+ ).to.eq('https://example.com/blog/');
+
+ // directory file
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'file',
+ })
+ ).to.eq('https://example.com/blog');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ // ignore + file => no trailing slash
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'file',
+ })
+ ).to.eq('https://example.com/blog');
+ });
+
+ it('should normalize locales', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'en_AU'],
+ },
+ },
+ };
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/en-us/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_AU',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('/blog/en-au/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ normalizeLocale: true,
+ })
+ ).to.eq('/blog/en-us/');
+ });
+
+ it('should return the default locale when routing strategy is [prefix-always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ base: '/blog',
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es', 'en_US', 'en_AU'],
+ routingStrategy: 'prefix-always',
+ },
+ },
+ };
+
+ // directory format
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog/',
+ trailingSlash: 'always',
+ site: 'https://example.com',
+ format: 'directory',
+ ...config.experimental.i18n,
+ })
+ ).to.eq('https://example.com/blog/en/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ site: 'https://example.com',
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ site: 'https://example.com',
+ trailingSlash: 'always',
+ format: 'directory',
+ })
+ ).to.throw;
+
+ // file format
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ site: 'https://example.com',
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('https://example.com/blog/en/');
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'es',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ site: 'https://example.com',
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.eq('https://example.com/blog/es/');
+
+ expect(
+ getLocaleAbsoluteUrl({
+ locale: 'en_US',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ site: 'https://example.com',
+ trailingSlash: 'always',
+ format: 'file',
+ })
+ ).to.throw;
+ });
+});
+
+describe('getLocaleAbsoluteUrlList', () => {
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog',
+ 'https://example.com/blog/en_US',
+ 'https://example.com/blog/es',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog/',
+ 'https://example.com/blog/en_US/',
+ 'https://example.com/blog/es/',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'always',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog/',
+ 'https://example.com/blog/en_US/',
+ 'https://example.com/blog/es/',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'never',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog',
+ 'https://example.com/blog/en_US',
+ 'https://example.com/blog/es',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'file',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog',
+ 'https://example.com/blog/en_US',
+ 'https://example.com/blog/es',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog/',
+ 'https://example.com/blog/en_US/',
+ 'https://example.com/blog/es/',
+ ]);
+ });
+
+ it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStategy: prefix-always]', () => {
+ /**
+ *
+ * @type {import("../../../dist/@types").AstroUserConfig}
+ */
+ const config = {
+ experimental: {
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'en_US', 'es'],
+ routingStrategy: 'prefix-always',
+ },
+ },
+ };
+ // directory format
+ expect(
+ getLocaleAbsoluteUrlList({
+ locale: 'en',
+ base: '/blog/',
+ ...config.experimental.i18n,
+ trailingSlash: 'ignore',
+ format: 'directory',
+ site: 'https://example.com',
+ })
+ ).to.have.members([
+ 'https://example.com/blog/en/',
+ 'https://example.com/blog/en_US/',
+ 'https://example.com/blog/es/',
+ ]);
+ });
+});
+
+describe('parse accept-header', () => {
+ it('should be parsed correctly', () => {
+ expect(parseLocale('*')).to.have.deep.members([{ locale: '*', qualityValue: undefined }]);
+ expect(parseLocale('fr')).to.have.deep.members([{ locale: 'fr', qualityValue: undefined }]);
+ expect(parseLocale('fr;q=0.6')).to.have.deep.members([{ locale: 'fr', qualityValue: 0.6 }]);
+ expect(parseLocale('fr;q=0.6,fr-CA;q=0.5')).to.have.deep.members([
+ { locale: 'fr', qualityValue: 0.6 },
+ { locale: 'fr-CA', qualityValue: 0.5 },
+ ]);
+
+ expect(parseLocale('fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5')).to.have.deep.members([
+ { locale: 'fr-CH', qualityValue: undefined },
+ { locale: 'fr', qualityValue: 0.9 },
+ { locale: 'en', qualityValue: 0.8 },
+ { locale: 'de', qualityValue: 0.7 },
+ { locale: '*', qualityValue: 0.5 },
+ ]);
+ });
+
+ it('should not return incorrect quality values', () => {
+ expect(parseLocale('wrong')).to.have.deep.members([
+ { locale: 'wrong', qualityValue: undefined },
+ ]);
+ expect(parseLocale('fr;f=0.7')).to.have.deep.members([
+ { locale: 'fr', qualityValue: undefined },
+ ]);
+ expect(parseLocale('fr;q=something')).to.have.deep.members([
+ { locale: 'fr', qualityValue: undefined },
+ ]);
+
+ expect(parseLocale('fr;q=1000')).to.have.deep.members([
+ { locale: 'fr', qualityValue: undefined },
+ ]);
+ });
+});
diff --git a/packages/astro/test/units/render/head.test.js b/packages/astro/test/units/render/head.test.js
index 3bb0fc84f9..1edd85d0dd 100644
--- a/packages/astro/test/units/render/head.test.js
+++ b/packages/astro/test/units/render/head.test.js
@@ -9,9 +9,10 @@ import {
renderHead,
Fragment,
} from '../../../dist/runtime/server/index.js';
-import { createRenderContext, tryRenderRoute } from '../../../dist/core/render/index.js';
+import { createRenderContext } from '../../../dist/core/render/index.js';
import { createBasicEnvironment } from '../test-utils.js';
import * as cheerio from 'cheerio';
+import { Pipeline } from '../../../dist/core/pipeline.js';
const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
@@ -97,7 +98,8 @@ describe('core/render', () => {
env,
});
- const response = await tryRenderRoute(ctx, env, PageModule);
+ const pipeline = new Pipeline(env);
+ const response = await pipeline.renderRoute(ctx, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@@ -178,7 +180,9 @@ describe('core/render', () => {
mod: PageModule,
});
- const response = await tryRenderRoute(ctx, env, PageModule);
+ const pipeline = new Pipeline(env);
+
+ const response = await pipeline.renderRoute(ctx, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@@ -225,7 +229,8 @@ describe('core/render', () => {
mod: PageModule,
});
- const response = await tryRenderRoute(ctx, env, PageModule);
+ const pipeline = new Pipeline(env);
+ const response = await pipeline.renderRoute(ctx, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
diff --git a/packages/astro/test/units/render/jsx.test.js b/packages/astro/test/units/render/jsx.test.js
index 65437cfc85..0368cc6fc6 100644
--- a/packages/astro/test/units/render/jsx.test.js
+++ b/packages/astro/test/units/render/jsx.test.js
@@ -6,13 +6,10 @@ import {
renderSlot,
} from '../../../dist/runtime/server/index.js';
import { jsx } from '../../../dist/jsx-runtime/index.js';
-import {
- createRenderContext,
- tryRenderRoute,
- loadRenderer,
-} from '../../../dist/core/render/index.js';
+import { createRenderContext, loadRenderer } from '../../../dist/core/render/index.js';
import { createAstroJSXComponent, renderer as jsxRenderer } from '../../../dist/jsx/index.js';
import { createBasicEnvironment } from '../test-utils.js';
+import { Pipeline } from '../../../dist/core/pipeline.js';
const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
const loadJSXRenderer = () => loadRenderer(jsxRenderer, { import: (s) => import(s) });
@@ -51,7 +48,8 @@ describe('core/render', () => {
mod,
});
- const response = await tryRenderRoute(ctx, env, mod);
+ const pipeline = new Pipeline(env);
+ const response = await pipeline.renderRoute(ctx, mod);
expect(response.status).to.equal(200);
@@ -96,7 +94,8 @@ describe('core/render', () => {
env,
mod,
});
- const response = await tryRenderRoute(ctx, env, mod);
+ const pipeline = new Pipeline(env);
+ const response = await pipeline.renderRoute(ctx, mod);
expect(response.status).to.equal(200);
@@ -123,7 +122,8 @@ describe('core/render', () => {
mod,
});
- const response = await tryRenderRoute(ctx, env, mod);
+ const pipeline = new Pipeline(env);
+ const response = await pipeline.renderRoute(ctx, mod);
try {
await response.text();
diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts
index 6f6fd9bc17..3ac5d231bf 100644
--- a/packages/create-astro/src/index.ts
+++ b/packages/create-astro/src/index.ts
@@ -20,6 +20,7 @@ process.on('SIGTERM', exit);
// if you make any changes to the flow or wording here.
export async function main() {
// Clear console because PNPM startup is super ugly
+ // eslint-disable-next-line no-console
console.clear();
// NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed
// to no longer require `--` to pass args and instead pass `--` directly to us. This
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 65d076fa0a..c3657d2ec1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2797,6 +2797,42 @@ importers:
specifier: ^10.17.1
version: 10.18.1
+ packages/astro/test/fixtures/i18n-routing:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
+ packages/astro/test/fixtures/i18n-routing-base:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
+ packages/astro/test/fixtures/i18n-routing-fallback:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
+ packages/astro/test/fixtures/i18n-routing-prefix-always:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
+ packages/astro/test/fixtures/i18n-routing-prefix-other-locales:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
+ packages/astro/test/fixtures/i18n-routing-redirect-preferred-language:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
packages/astro/test/fixtures/import-ts-with-js:
dependencies:
astro: