diff --git a/.changeset/odd-mayflies-dress.md b/.changeset/odd-mayflies-dress.md new file mode 100644 index 0000000000..25e7ef51aa --- /dev/null +++ b/.changeset/odd-mayflies-dress.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +i18n routing diff --git a/.changeset/rude-lizards-scream.md b/.changeset/rude-lizards-scream.md new file mode 100644 index 0000000000..53252b5758 --- /dev/null +++ b/.changeset/rude-lizards-scream.md @@ -0,0 +1,51 @@ +--- +'astro': minor +--- + +Experimental support for i18n routing. + +Astro's experimental i18n routing API allows you to add your multilingual content with support for configuring a default language, computing relative page URLs, and accepting preferred languages provided by your visitor's browser. You can also specify fallback languages on a per-language basis so that your visitors can always be directed to existing content on your site. + +Enable the experimental routing option by adding an `i18n` object to your Astro configuration with a default location and a list of all languages to support: + +```js +// astro.config.mjs +import {defineConfig} from "astro/config"; + +export default defineConfig({ + experimental: { + i18n: { + defaultLocale: "en", + locales: ["en", "es", "pt-br"] + } + } +}) +``` + +Organize your content folders by locale depending on your `i18n.routingStrategy`, and Astro will handle generating your routes and showing your preferred URLs to your visitors. +``` +├── src +│ ├── pages +│ │ ├── about.astro +│ │ ├── index.astro +│ │ ├── es +│ │ │ ├── about.astro +│ │ │ ├── index.astro +│ │ ├── pt-br +│ │ │ ├── about.astro +│ │ │ ├── index.astro +``` + +Compute relative URLs for your links with `getLocaleRelativeURL` from the new `astro:i18n` module: + +```astro +--- +import {getLocaleRelativeUrl} from "astro:i18n"; +const aboutUrl = getLocaleRelativeUrl("pt-br", "about"); +--- +

Learn more About this site!

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