0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-24 23:21:57 -05:00

feat: experimental i18n routing (#8974)

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com>
Co-authored-by: Elian ️ <hello@elian.codes>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2023-11-08 15:45:43 +00:00 committed by GitHub
parent c5010aad34
commit 143bacf396
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 4079 additions and 294 deletions

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
i18n routing

View file

@ -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");
---
<p>Learn more <a href={aboutURL}>About</a> this site!</p>
```
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.

View file

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

View file

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

View file

@ -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<string, string>}
* @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<string, string>;
/**
* @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;

View file

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

View file

@ -49,6 +49,14 @@ export type SSRManifest = {
componentMetadata: SSRResult['componentMetadata'];
pageModule?: SinglePageBuiltModule;
pageMap?: Map<ComponentPath, ImportComponentInstance>;
i18n: SSRManifestI18n | undefined;
};
export type SSRManifestI18n = {
fallback?: Record<string, string>;
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<T = any> = (
manifest: SSRManifest,
args?: T
) => Record<string, any>;

View file

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

View file

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

View file

@ -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<SinglePageBuiltModule> {
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,
};
}

View file

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

View file

@ -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<string, PageBuildData>;
/**
* TODO: Use this in Astro 4.0
*/
pagesByComponents: Map<string, PageBuildData[]>;
/**
* A map for page-specific output.
*/
@ -112,6 +120,7 @@ export function createBuildInternals(): BuildInternals {
entrySpecifierToBundleMap: new Map<string, string>(),
pageToBundleMap: new Map<string, string>(),
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]> {

View file

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

View file

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

View file

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

View file

@ -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<string>();
for (const [path, pageData] of Object.entries(opts.allPages)) {
for (const [path, pageData] of eachPageFromAllPages(opts.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}

View file

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

View file

@ -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<string>();
// A map of each page .astro file, to the PageBuildData which contains information
// about that page, such as its paths.
const facadeIdToPageDataMap = new Map<string, PageBuildData>();
// 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 = {

View file

@ -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<ComponentPath, PageBuildData>;
export type AllPagesData = Record<ComponentPath, PageBuildData[]>;
/** Options for the static build */
export interface StaticBuildOptions {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, any>;
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<MiddlewareResult = Response | EndpointOutput>
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
onRequest?: MiddlewareHandler<MiddlewareResult> | undefined
onRequest: MiddlewareHandler<MiddlewareResult> | undefined,
locales: undefined | string[]
): Promise<Response> {
const context = createAPIContext({
request: ctx.request,
@ -133,6 +162,7 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
props: ctx.props,
site: env.site,
adapterName: env.adapterName,
locales,
});
let response;

View file

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

View file

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

View file

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

View file

@ -73,7 +73,7 @@ export class Pipeline {
*/
async renderRoute(
renderContext: RenderContext,
componentInstance: ComponentInstance
componentInstance: ComponentInstance | undefined
): Promise<Response> {
const result = await this.#tryRenderRoute(
renderContext,
@ -106,7 +106,7 @@ export class Pipeline {
async #tryRenderRoute<MiddlewareReturnType = Response>(
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance>,
mod: Readonly<ComponentInstance> | undefined,
onRequest?: MiddlewareHandler<MiddlewareReturnType>
): Promise<Response> {
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<Response>(
@ -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}]`);

View file

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

View file

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

View file

@ -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<MiddlewareReturnType = Response>(
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance>,
onRequest?: MiddlewareHandler<MiddlewareReturnType>
): Promise<Response> {
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<Response>(
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}]`);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, RouteData[]>();
// 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 `/<locale>`.
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,

View file

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

View file

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

View file

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

View file

@ -23,6 +23,9 @@ const ALL_UNSUPPORTED: Required<AstroFeatureMap> = {
staticOutput: UNSUPPORTED,
hybridOutput: UNSUPPORTED,
assets: UNSUPPORTED_ASSETS_FEATURE,
i18n: {
detectBrowserLanguage: UNSUPPORTED,
},
};
type ValidationResult = {

View file

@ -91,4 +91,6 @@ export default class DevPipeline extends Pipeline {
async #handleEndpointResult(_: Request, response: Response): Promise<Response> {
return response;
}
async handleFallback() {}
}

View file

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

View file

@ -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<MatchedRoute | undefined> {
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,

View file

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

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-base",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hola
</body>
</html>

View file

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

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-fallabck",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
End
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Oi essa e start
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Start
</body>
</html>

View file

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

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-prefix-always",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Start
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Oi essa e start
</body>
</html>

View file

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

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-prefix-other-locales",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Oi essa e start
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Start
</body>
</html>

View file

@ -0,0 +1,12 @@
import { defineConfig} from "astro/config";
export default defineConfig({
experimental: {
i18n: {
defaultLocale: 'en',
locales: [
'en', 'pt', 'it'
]
}
},
})

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-preferred-language",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
End
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,13 @@
---
const locale = Astro.preferredLocale;
const localeList = Astro.preferredLocaleList;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
Locale: {locale ? locale : "none"}
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hola
</body>
</html>

View file

@ -0,0 +1,12 @@
import { defineConfig} from "astro/config";
export default defineConfig({
experimental: {
i18n: {
defaultLocale: 'en',
locales: [
'en', 'pt', 'it'
]
}
},
})

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hello
</body>
</html>

View file

@ -0,0 +1,15 @@
---
const locale = Astro.preferredLocale;
const localeList = Astro.preferredLocaleList;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
Locale: {locale ? locale : "none"}
Locale list: {localeList.length > 0 ? localeList.join(", ") : "empty"}
</body>
</html>

View file

@ -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;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Astro</title>
</head>
<body>
Hola
</body>
</html>

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

36
pnpm-lock.yaml generated
View file

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