diff --git a/.changeset/little-hornets-give.md b/.changeset/little-hornets-give.md new file mode 100644 index 000000000000..fdbb6492eec0 --- /dev/null +++ b/.changeset/little-hornets-give.md @@ -0,0 +1,48 @@ +--- +"astro": minor +--- + +Adds a new i18n routing option `manual` to allow you to write your own i18n middleware: + +```js +import { defineConfig } from "astro/config" +// astro.config.mjs +export default defineConfig({ + i18n: { + locales: ["en", "fr"], + defaultLocale: "fr", + routing: "manual" + } +}) +``` + +Adding `routing: "manual"` to your i18n config disables Astro's own i18n middleware and provides you with helper functions to write your own: `redirectToDefaultLocale`, `notFound`, and `redirectToFallback`: + +```js +// middleware.js +import { redirectToDefaultLocale } from "astro:i18n"; +export const onRequest = defineMiddleware(async (context, next) => { + if (context.url.startsWith("/about")) { + return next() + } else { + return redirectToDefaultLocale(context, 302); + } +}) +``` + +Also adds a `middleware` function that manually creates Astro's i18n middleware. This allows you to extend Astro's i18n routing instead of completely replacing it. Run `middleware` in combination with your own middleware, using the `sequence` utility to determine the order: + +```js title="src/middleware.js" +import {defineMiddleware, sequence} from "astro:middleware"; +import { middleware } from "astro:i18n"; // Astro's own i18n routing config + +export const userMiddleware = defineMiddleware(); + +export const onRequest = sequence( + userMiddleware, + middleware({ + redirectToDefaultLocale: false, + prefixDefaultLocale: true + }) +) +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index dbf929320af0..517c7dfe78e3 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1497,66 +1497,80 @@ export interface AstroUserConfig { * * Controls the routing strategy to determine your site URLs. Set this based on your folder/URL path configuration for your default language. */ - routing?: { + // prettier-ignore + routing?: /** - * @docs - * @name i18n.routing.prefixDefaultLocale - * @kind h4 - * @type {boolean} - * @default `false` - * @version 3.7.0 - * @description * - * When `false`, only non-default languages will display a language prefix. - * The `defaultLocale` will not show a language prefix and content files do not exist in a localized folder. - * URLs will be of the form `example.com/[locale]/content/` for all non-default languages, but `example.com/content/` for the default locale. - * - * When `true`, all URLs will display a language prefix. - * URLs will be of the form `example.com/[locale]/content/` for every route, including the default language. - * Localized folders are used for every language, including the default. - */ - prefixDefaultLocale?: boolean; - - /** * @docs - * @name i18n.routing.redirectToDefaultLocale - * @kind h4 - * @type {boolean} - * @default `true` - * @version 4.2.0 + * @name i18n.routing.manual + * @type {string} + * @version 4.6.0 * @description + * When this option is enabled, Astro will **disable** its i18n middleware so that you can implement your own custom logic. No other `routing` options (e.g. `prefixDefaultLocale`) may be configured with `routing: "manual"`. * - * Configures whether or not the home URL (`/`) generated by `src/pages/index.astro` - * will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set. - * - * Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site: - * ```js - * // astro.config.mjs - * export default defineConfig({ - * i18n:{ - * defaultLocale: "en", - * locales: ["en", "fr"], - * routing: { - * prefixDefaultLocale: true, - * redirectToDefaultLocale: false - * } - * } - * }) - *``` - * */ - redirectToDefaultLocale?: boolean; - - /** - * @name i18n.routing.strategy - * @type {"pathname"} - * @default `"pathname"` - * @version 3.7.0 - * @description - * - * - `"pathname": The strategy is applied to the pathname of the URLs + * You will be responsible for writing your own routing logic, or executing Astro's i18n middleware manually alongside your own. */ - strategy?: 'pathname'; - }; + 'manual' + | { + /** + * @docs + * @name i18n.routing.prefixDefaultLocale + * @kind h4 + * @type {boolean} + * @default `false` + * @version 3.7.0 + * @description + * + * When `false`, only non-default languages will display a language prefix. + * The `defaultLocale` will not show a language prefix and content files do not exist in a localized folder. + * URLs will be of the form `example.com/[locale]/content/` for all non-default languages, but `example.com/content/` for the default locale. + * + * When `true`, all URLs will display a language prefix. + * URLs will be of the form `example.com/[locale]/content/` for every route, including the default language. + * Localized folders are used for every language, including the default. + */ + prefixDefaultLocale?: boolean; + + /** + * @docs + * @name i18n.routing.redirectToDefaultLocale + * @kind h4 + * @type {boolean} + * @default `true` + * @version 4.2.0 + * @description + * + * Configures whether or not the home URL (`/`) generated by `src/pages/index.astro` + * will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set. + * + * Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site: + * ```js + * // astro.config.mjs + * export default defineConfig({ + * i18n:{ + * defaultLocale: "en", + * locales: ["en", "fr"], + * routing: { + * prefixDefaultLocale: true, + * redirectToDefaultLocale: false + * } + * } + * }) + *``` + * */ + redirectToDefaultLocale?: boolean; + + /** + * @name i18n.routing.strategy + * @type {"pathname"} + * @default `"pathname"` + * @version 3.7.0 + * @description + * + * - `"pathname": The strategy is applied to the pathname of the URLs + */ + strategy?: 'pathname'; + }; /** * @name i18n.domains @@ -1592,7 +1606,7 @@ export interface AstroUserConfig { * }) * ``` * - * Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurllist) will use the options set in `i18n.domains`. + * Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/reference/api-reference/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/reference/api-reference/#getabsolutelocaleurllist) will use the options set in `i18n.domains`. * * See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains) for more details, including the limitations of this feature. */ diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 2596ab3a69f2..ab5b314a2230 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -67,7 +67,7 @@ export type SSRManifest = { }; export type SSRManifestI18n = { - fallback?: Record; + fallback: Record | undefined; strategy: RoutingStrategies; locales: Locales; defaultLocale: string; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index f98eb4992070..832823db35fa 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -48,9 +48,13 @@ export abstract class Pipeline { */ readonly site = manifest.site ? new URL(manifest.site) : undefined ) { - this.internalMiddleware = [ - createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat), - ]; + this.internalMiddleware = []; + // We do use our middleware only if the user isn't using the manual setup + if (i18n?.strategy !== 'manual') { + this.internalMiddleware.push( + createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat) + ); + } } abstract headElements(routeData: RouteData): Promise | HeadElements; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 7dc00073fc5b..e7008322b005 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -592,7 +592,7 @@ function createBuildManifest( if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, - strategy: toRoutingStrategy(settings.config.i18n), + strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains), defaultLocale: settings.config.i18n.defaultLocale, locales: settings.config.i18n.locales, domainLookupTable: {}, diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 24437d4e5765..deb71155e94e 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -253,7 +253,7 @@ function buildManifest( if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, - strategy: toRoutingStrategy(settings.config.i18n), + strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains), locales: settings.config.i18n.locales, defaultLocale: settings.config.i18n.defaultLocale, domainLookupTable, diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index ef1a6ec85d67..de820d6837c1 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -386,21 +386,25 @@ export const AstroConfigSchema = z.object({ .optional(), fallback: z.record(z.string(), z.string()).optional(), routing: z - .object({ - prefixDefaultLocale: z.boolean().default(false), - redirectToDefaultLocale: z.boolean().default(true), - strategy: z.enum(['pathname']).default('pathname'), - }) - .default({}) - .refine( - ({ prefixDefaultLocale, redirectToDefaultLocale }) => { - return !(prefixDefaultLocale === false && redirectToDefaultLocale === false); - }, - { - message: - 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.', - } - ), + .literal('manual') + .or( + z + .object({ + prefixDefaultLocale: z.boolean().optional().default(false), + redirectToDefaultLocale: z.boolean().optional().default(true), + }) + .refine( + ({ prefixDefaultLocale, redirectToDefaultLocale }) => { + return !(prefixDefaultLocale === false && redirectToDefaultLocale === false); + }, + { + message: + 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.', + } + ) + ) + .optional() + .default({}), }) .optional() .superRefine((i18n, ctx) => { diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index f8ba0352b695..e18221c428f1 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1067,6 +1067,21 @@ export const MissingIndexForInternationalization = { hint: (src: string) => `Create an index page (\`index.astro, index.md, etc.\`) in \`${src}\`.`, } satisfies ErrorData; +/** + * @docs + * @description + * Some internationalization functions are only available when Astro's own i18n routing is disabled by the configuration setting `i18n.routing: "manual"`. + * + * @see + * - [`i18n` routing](https://docs.astro.build/en/guides/internationalization/#routing) + */ +export const IncorrectStrategyForI18n = { + name: 'IncorrectStrategyForI18n', + title: "You can't use the current function with the current strategy", + message: (functionName: string) => + `The function \`${functionName}\' can only be used when the \`i18n.routing.strategy\` is set to \`"manual"\`.`, +} satisfies ErrorData; + /** * @docs * @description @@ -1076,7 +1091,19 @@ export const NoPrerenderedRoutesWithDomains = { name: 'NoPrerenderedRoutesWithDomains', title: "Prerendered routes aren't supported when internationalization domains are enabled.", message: (component: string) => - `Static pages aren't yet supported with multiple domains. If you wish to enable this feature, you have to disable prerendering for the page ${component}`, + `Static pages aren't yet supported with multiple domains. To enable this feature, you must disable prerendering for the page ${component}`, +} satisfies ErrorData; + +/** + * @docs + * @description + * Astro throws an error if the user enables manual routing, but it doesn't have a middleware file. + */ +export const MissingMiddlewareForInternationalization = { + name: 'MissingMiddlewareForInternationalization', + title: 'Enabled manual internationalization routing without having a middleware.', + message: + "Your configuration setting `i18n.routing: 'manual'` requires you to provide your own i18n `middleware` file.", } satisfies ErrorData; /** diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts index 64c039db7271..4472e16c2cd9 100644 --- a/packages/astro/src/core/middleware/vite-plugin.ts +++ b/packages/astro/src/core/middleware/vite-plugin.ts @@ -6,6 +6,8 @@ import { addRollupInput } from '../build/add-rollup-input.js'; import type { BuildInternals } from '../build/internal.js'; import type { StaticBuildOptions } from '../build/types.js'; import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js'; +import { MissingMiddlewareForInternationalization } from '../errors/errors-data.js'; +import { AstroError } from '../errors/index.js'; export const MIDDLEWARE_MODULE_ID = '\0astro-internal:middleware'; const NOOP_MIDDLEWARE = '\0noop-middleware'; @@ -44,8 +46,14 @@ export function vitePluginMiddleware({ settings }: { settings: AstroSettings }): }, async load(id) { if (id === NOOP_MIDDLEWARE) { + if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') { + throw new AstroError(MissingMiddlewareForInternationalization); + } return 'export const onRequest = (_, next) => next()'; } else if (id === MIDDLEWARE_MODULE_ID) { + if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') { + throw new AstroError(MissingMiddlewareForInternationalization); + } // In the build, tell Vite to emit this file if (isCommandBuild) { this.emitFile({ diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index e30440fa4f8d..a6ddc93a3f6f 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -693,7 +693,7 @@ export function createRouteManifest( const i18n = settings.config.i18n; if (i18n) { - const strategy = toRoutingStrategy(i18n); + const strategy = toRoutingStrategy(i18n.routing, i18n.domains); // First we check if the user doesn't have an index page. if (strategy === 'pathname-prefix-always') { let index = routes.find((route) => route.route === '/'); diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 0b93f1d785b7..58cd1a20007d 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -1,9 +1,41 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { AstroConfig, Locales } from '../@types/astro.js'; +import type { + APIContext, + AstroConfig, + Locales, + SSRManifest, + ValidRedirectStatus, +} from '../@types/astro.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; import { MissingLocale } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/index.js'; import type { RoutingStrategies } from './utils.js'; +import { createI18nMiddleware } from './middleware.js'; +import { REROUTE_DIRECTIVE_HEADER } from '../core/constants.js'; + +export function requestHasLocale(locales: Locales) { + return function (context: APIContext): boolean { + return pathHasLocale(context.url.pathname, locales); + }; +} + +// Checks if the pathname has any locale +export function pathHasLocale(path: string, locales: Locales): boolean { + const segments = path.split('/'); + for (const segment of segments) { + for (const locale of locales) { + if (typeof locale === 'string') { + if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) { + return true; + } + } else if (segment === locale.path) { + return true; + } + } + } + + return false; +} type GetLocaleRelativeUrl = GetLocaleOptions & { locale: string; @@ -244,3 +276,117 @@ class Unreachable extends Error { ); } } + +export type MiddlewarePayload = { + base: string; + locales: Locales; + trailingSlash: AstroConfig['trailingSlash']; + format: AstroConfig['build']['format']; + strategy: RoutingStrategies; + defaultLocale: string; + domains: Record | undefined; + fallback: Record | undefined; +}; + +// NOTE: public function exported to the users via `astro:i18n` module +export function redirectToDefaultLocale({ + trailingSlash, + format, + base, + defaultLocale, +}: MiddlewarePayload) { + return function (context: APIContext, statusCode?: ValidRedirectStatus) { + if (shouldAppendForwardSlash(trailingSlash, format)) { + return context.redirect(`${appendForwardSlash(joinPaths(base, defaultLocale))}`, statusCode); + } else { + return context.redirect(`${joinPaths(base, defaultLocale)}`, statusCode); + } + }; +} + +// NOTE: public function exported to the users via `astro:i18n` module +export function notFound({ base, locales }: MiddlewarePayload) { + return function (context: APIContext, response?: Response): Response | undefined { + if (response?.headers.get(REROUTE_DIRECTIVE_HEADER) === 'no') return response; + + const url = context.url; + // We return a 404 if: + // - the current path isn't a root. e.g. / or / + // - the URL doesn't contain a locale + const isRoot = url.pathname === base + '/' || url.pathname === base; + if (!(isRoot || pathHasLocale(url.pathname, locales))) { + if (response) { + response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no'); + return new Response(null, { + status: 404, + headers: response.headers, + }); + } else { + return new Response(null, { + status: 404, + headers: { + [REROUTE_DIRECTIVE_HEADER]: 'no', + }, + }); + } + } + + return undefined; + }; +} + +// NOTE: public function exported to the users via `astro:i18n` module +export type RedirectToFallback = (context: APIContext, response: Response) => Response; + +export function redirectToFallback({ + fallback, + locales, + defaultLocale, + strategy, +}: MiddlewarePayload) { + return function (context: APIContext, response: Response): Response { + if (response.status >= 300 && fallback) { + const fallbackKeys = fallback ? Object.keys(fallback) : []; + // we split the URL using the `/`, and then check in the returned array we have the locale + const segments = context.url.pathname.split('/'); + const urlLocale = segments.find((segment) => { + for (const locale of locales) { + if (typeof locale === 'string') { + if (locale === segment) { + return true; + } + } else if (locale.path === segment) { + return true; + } + } + return false; + }); + + if (urlLocale && fallbackKeys.includes(urlLocale)) { + const fallbackLocale = fallback[urlLocale]; + // the user might have configured the locale using the granular locales, so we want to retrieve its corresponding path instead + const pathFallbackLocale = getPathByLocale(fallbackLocale, locales); + 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 (pathFallbackLocale === defaultLocale && strategy === 'pathname-prefix-other-locales') { + newPathname = context.url.pathname.replace(`/${urlLocale}`, ``); + } else { + newPathname = context.url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`); + } + return context.redirect(newPathname); + } + } + return response; + }; +} + +// NOTE: public function exported to the users via `astro:i18n` module +export function createMiddleware( + i18nManifest: SSRManifest['i18n'], + base: SSRManifest['base'], + trailingSlash: SSRManifest['trailingSlash'], + format: SSRManifest['buildFormat'] +) { + return createI18nMiddleware(i18nManifest, base, trailingSlash, format); +} diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index e03ba304498c..54aa1249f8fb 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -1,59 +1,52 @@ -import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { APIContext, Locales, MiddlewareHandler, SSRManifest } from '../@types/astro.js'; +import { + getPathByLocale, + type MiddlewarePayload, + notFound, + normalizeTheLocale, + requestHasLocale, + redirectToDefaultLocale, + redirectToFallback, +} from './index.js'; +import type { APIContext, MiddlewareHandler, SSRManifest } from '../@types/astro.js'; import type { SSRManifestI18n } from '../core/app/types.js'; -import { shouldAppendForwardSlash } from '../core/build/util.js'; -import { REROUTE_DIRECTIVE_HEADER, ROUTE_TYPE_HEADER } from '../core/constants.js'; -import { getPathByLocale, normalizeTheLocale } from './index.js'; - -// Checks if the pathname has any locale, exception for the defaultLocale, which is ignored on purpose. -function pathnameHasLocale(pathname: string, locales: Locales): boolean { - const segments = pathname.split('/'); - for (const segment of segments) { - for (const locale of locales) { - if (typeof locale === 'string') { - if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) { - return true; - } - } else if (segment === locale.path) { - return true; - } - } - } - - return false; -} +import { ROUTE_TYPE_HEADER } from '../core/constants.js'; export function createI18nMiddleware( i18n: SSRManifest['i18n'], base: SSRManifest['base'], trailingSlash: SSRManifest['trailingSlash'], - buildFormat: SSRManifest['buildFormat'] + format: SSRManifest['buildFormat'] ): MiddlewareHandler { if (!i18n) return (_, next) => next(); + const payload: MiddlewarePayload = { + ...i18n, + trailingSlash, + base, + format, + domains: {}, + }; + const _redirectToDefaultLocale = redirectToDefaultLocale(payload); + const _noFoundForNonLocaleRoute = notFound(payload); + const _requestHasLocale = requestHasLocale(payload.locales); + const _redirectToFallback = redirectToFallback(payload); - const prefixAlways = ( - url: URL, - response: Response, - context: APIContext - ): Response | undefined => { + const prefixAlways = (context: APIContext): Response | undefined => { + const url = context.url; if (url.pathname === base + '/' || url.pathname === base) { - if (shouldAppendForwardSlash(trailingSlash, buildFormat)) { - return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); - } else { - return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); - } + return _redirectToDefaultLocale(context); } // Astro can't know where the default locale is supposed to be, so it returns a 404. - else if (!pathnameHasLocale(url.pathname, i18n.locales)) { - return notFound(response); + else if (!_requestHasLocale(context)) { + return _noFoundForNonLocaleRoute(context); } return undefined; }; - const prefixOtherLocales = (url: URL, response: Response): Response | undefined => { + const prefixOtherLocales = (context: APIContext, response: Response): Response | undefined => { let pathnameContainsDefaultLocale = false; + const url = context.url; for (const segment of url.pathname.split('/')) { if (normalizeTheLocale(segment) === normalizeTheLocale(i18n.defaultLocale)) { pathnameContainsDefaultLocale = true; @@ -63,26 +56,7 @@ export function createI18nMiddleware( if (pathnameContainsDefaultLocale) { const newLocation = url.pathname.replace(`/${i18n.defaultLocale}`, ''); response.headers.set('Location', newLocation); - return notFound(response); - } - - return undefined; - }; - - /** - * We return a 404 if: - * - the current path isn't a root. e.g. / or / - * - the URL doesn't contain a locale - * @param url - * @param response - */ - const prefixAlwaysNoRedirect = (url: URL, response: Response): Response | undefined => { - // We return a 404 if: - // - the current path isn't a root. e.g. / or / - // - the URL doesn't contain a locale - const isRoot = url.pathname === base + '/' || url.pathname === base; - if (!(isRoot || pathnameHasLocale(url.pathname, i18n.locales))) { - return notFound(response); + return _noFoundForNonLocaleRoute(context); } return undefined; @@ -96,13 +70,16 @@ export function createI18nMiddleware( return response; } - const { url, currentLocale } = context; - const { locales, defaultLocale, fallback, strategy } = i18n; + const { currentLocale } = context; switch (i18n.strategy) { + // NOTE: theoretically, we should never hit this code path + case 'manual': { + return response; + } case 'domains-prefix-other-locales': { if (localeHasntDomain(i18n, currentLocale)) { - const result = prefixOtherLocales(url, response); + const result = prefixOtherLocales(context, response); if (result) { return result; } @@ -110,7 +87,7 @@ export function createI18nMiddleware( break; } case 'pathname-prefix-other-locales': { - const result = prefixOtherLocales(url, response); + const result = prefixOtherLocales(context, response); if (result) { return result; } @@ -119,7 +96,7 @@ export function createI18nMiddleware( case 'domains-prefix-always-no-redirect': { if (localeHasntDomain(i18n, currentLocale)) { - const result = prefixAlwaysNoRedirect(url, response); + const result = _noFoundForNonLocaleRoute(context, response); if (result) { return result; } @@ -128,7 +105,7 @@ export function createI18nMiddleware( } case 'pathname-prefix-always-no-redirect': { - const result = prefixAlwaysNoRedirect(url, response); + const result = _noFoundForNonLocaleRoute(context, response); if (result) { return result; } @@ -136,7 +113,7 @@ export function createI18nMiddleware( } case 'pathname-prefix-always': { - const result = prefixAlways(url, response, context); + const result = prefixAlways(context); if (result) { return result; } @@ -144,7 +121,7 @@ export function createI18nMiddleware( } case 'domains-prefix-always': { if (localeHasntDomain(i18n, currentLocale)) { - const result = prefixAlways(url, response, context); + const result = prefixAlways(context); if (result) { return result; } @@ -153,58 +130,10 @@ export function createI18nMiddleware( } } - if (response.status >= 300 && fallback) { - const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : []; - - // we split the URL using the `/`, and then check in the returned array we have the locale - const segments = url.pathname.split('/'); - const urlLocale = segments.find((segment) => { - for (const locale of locales) { - if (typeof locale === 'string') { - if (locale === segment) { - return true; - } - } else if (locale.path === segment) { - return true; - } - } - return false; - }); - - if (urlLocale && fallbackKeys.includes(urlLocale)) { - const fallbackLocale = fallback[urlLocale]; - // the user might have configured the locale using the granular locales, so we want to retrieve its corresponding path instead - const pathFallbackLocale = getPathByLocale(fallbackLocale, locales); - 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 (pathFallbackLocale === defaultLocale && strategy === 'pathname-prefix-other-locales') { - newPathname = url.pathname.replace(`/${urlLocale}`, ``); - } else { - newPathname = url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`); - } - - return context.redirect(newPathname); - } - } - - return response; + return _redirectToFallback(context, response); }; } -/** - * The i18n returns empty 404 responses in certain cases. - * Error-page-rerouting infra will attempt to render the 404.astro page, causing the middleware to run a second time. - * To avoid loops and overwriting the contents of `404.astro`, we allow error pages to pass through. - */ -function notFound(response: Response) { - if (response.headers.get(REROUTE_DIRECTIVE_HEADER) === 'no') return response; - return new Response(null, { - status: 404, - headers: response.headers, - }); -} - /** * Checks if the current locale doesn't belong to a configured domain * @param i18n diff --git a/packages/astro/src/i18n/utils.ts b/packages/astro/src/i18n/utils.ts index 3e664044cab1..1567f43db8b1 100644 --- a/packages/astro/src/i18n/utils.ts +++ b/packages/astro/src/i18n/utils.ts @@ -178,35 +178,42 @@ export function computeCurrentLocale(pathname: string, locales: Locales): undefi } export type RoutingStrategies = + | 'manual' | 'pathname-prefix-always' | 'pathname-prefix-other-locales' | 'pathname-prefix-always-no-redirect' | 'domains-prefix-always' | 'domains-prefix-other-locales' | 'domains-prefix-always-no-redirect'; -export function toRoutingStrategy(i18n: NonNullable) { - let { routing, domains } = i18n; +export function toRoutingStrategy( + routing: NonNullable['routing'], + domains: NonNullable['domains'] +) { let strategy: RoutingStrategies; const hasDomains = domains ? Object.keys(domains).length > 0 : false; - if (!hasDomains) { - if (routing?.prefixDefaultLocale === true) { - if (routing.redirectToDefaultLocale) { - strategy = 'pathname-prefix-always'; + if (routing === 'manual') { + strategy = 'manual'; + } else { + if (!hasDomains) { + if (routing?.prefixDefaultLocale === true) { + if (routing.redirectToDefaultLocale) { + strategy = 'pathname-prefix-always'; + } else { + strategy = 'pathname-prefix-always-no-redirect'; + } } else { - strategy = 'pathname-prefix-always-no-redirect'; + strategy = 'pathname-prefix-other-locales'; } } else { - strategy = 'pathname-prefix-other-locales'; - } - } else { - if (routing?.prefixDefaultLocale === true) { - if (routing.redirectToDefaultLocale) { - strategy = 'domains-prefix-always'; + if (routing?.prefixDefaultLocale === true) { + if (routing.redirectToDefaultLocale) { + strategy = 'domains-prefix-always'; + } else { + strategy = 'domains-prefix-always-no-redirect'; + } } else { - strategy = 'domains-prefix-always-no-redirect'; + strategy = 'domains-prefix-other-locales'; } - } else { - strategy = 'domains-prefix-other-locales'; } } diff --git a/packages/astro/src/virtual-modules/i18n.ts b/packages/astro/src/virtual-modules/i18n.ts index 01b93ade4fda..0714c08b7949 100644 --- a/packages/astro/src/virtual-modules/i18n.ts +++ b/packages/astro/src/virtual-modules/i18n.ts @@ -1,18 +1,36 @@ import * as I18nInternals from '../i18n/index.js'; import { toRoutingStrategy } from '../i18n/utils.js'; +import { AstroError } from '../core/errors/index.js'; +import { IncorrectStrategyForI18n } from '../core/errors/errors-data.js'; +import type { RedirectToFallback } from '../i18n/index.js'; +import type { SSRManifest } from '../core/app/types.js'; +import type { + APIContext, + AstroConfig, + MiddlewareHandler, + ValidRedirectStatus, +} from '../@types/astro.js'; import type { I18nInternalConfig } from '../i18n/vite-plugin-i18n.js'; export { normalizeTheLocale, toCodes, toPaths } from '../i18n/index.js'; const { trailingSlash, format, site, i18n, isBuild } = // @ts-expect-error __ASTRO_INTERNAL_I18N_CONFIG__ as I18nInternalConfig; -const { defaultLocale, locales, domains } = i18n!; +const { defaultLocale, locales, domains, fallback, routing } = i18n!; const base = import.meta.env.BASE_URL; -const routing = toRoutingStrategy(i18n!); +const strategy = toRoutingStrategy(routing, domains); export type GetLocaleOptions = I18nInternals.GetLocaleOptions; +const noop = (method: string) => + function () { + throw new AstroError({ + ...IncorrectStrategyForI18n, + message: IncorrectStrategyForI18n.message(method), + }); + }; + /** * @param locale A locale * @param path An optional path to add after the `locale`. @@ -43,7 +61,7 @@ export const getRelativeLocaleUrl = (locale: string, path?: string, options?: Ge format, defaultLocale, locales, - strategy: routing, + strategy, domains, ...options, }); @@ -83,7 +101,7 @@ export const getAbsoluteLocaleUrl = (locale: string, path?: string, options?: Ge site, defaultLocale, locales, - strategy: routing, + strategy, domains, isBuild, ...options, @@ -103,7 +121,7 @@ export const getRelativeLocaleUrlList = (path?: string, options?: GetLocaleOptio format, defaultLocale, locales, - strategy: routing, + strategy, domains, ...options, }); @@ -123,7 +141,7 @@ export const getAbsoluteLocaleUrlList = (path?: string, options?: GetLocaleOptio format, defaultLocale, locales, - strategy: routing, + strategy, domains, isBuild, ...options, @@ -191,3 +209,177 @@ export const getPathByLocale = (locale: string) => I18nInternals.getPathByLocale * ``` */ export const getLocaleByPath = (path: string) => I18nInternals.getLocaleByPath(path, locales); + +/** + * A function that can be used to check if the current path contains a configured locale. + * + * @param path The path that maps to a locale + * @returns Whether the `path` has the locale + * + * ## Example + * + * Given the following configuration: + * + * ```js + * // astro.config.mjs + * + * export default defineConfig({ + * i18n: { + * locales: [ + * { codes: ["it-VT", "it"], path: "italiano" }, + * "es" + * ] + * } + * }) + * ``` + * + * Here's some use cases: + * + * ```js + * import { pathHasLocale } from "astro:i18n"; + * getLocaleByPath("italiano"); // returns `true` + * getLocaleByPath("es"); // returns `true` + * getLocaleByPath("it-VT"); // returns `false` + * ``` + */ +export const pathHasLocale = (path: string) => I18nInternals.pathHasLocale(path, locales); + +/** + * + * This function returns a redirect to the default locale configured in the + * + * @param {APIContext} context The context passed to the middleware + * @param {ValidRedirectStatus?} statusCode An optional status code for the redirect. + */ +export let redirectToDefaultLocale: ( + context: APIContext, + statusCode?: ValidRedirectStatus +) => Response | undefined; + +if (i18n?.routing === 'manual') { + redirectToDefaultLocale = I18nInternals.redirectToDefaultLocale({ + base, + trailingSlash, + format, + defaultLocale, + locales, + strategy, + domains, + fallback, + }); +} else { + redirectToDefaultLocale = noop('redirectToDefaultLocale'); +} +/** + * + * Use this function to return a 404 when: + * - the current path isn't a root. e.g. / or / + * - the URL doesn't contain a locale + * + * When a `Response` is passed, the new `Response` emitted by this function will contain the same headers of the original response. + * + * @param {APIContext} context The context passed to the middleware + * @param {Response?} response An optional `Response` in case you're handling a `Response` coming from the `next` function. + * + */ +export let notFound: (context: APIContext, response?: Response) => Response | undefined; + +if (i18n?.routing === 'manual') { + notFound = I18nInternals.notFound({ + base, + trailingSlash, + format, + defaultLocale, + locales, + strategy, + domains, + fallback, + }); +} else { + notFound = noop('notFound'); +} + +/** + * Checks whether the current URL contains a configured locale. Internally, this function will use `APIContext#url.pathname` + * + * @param {APIContext} context The context passed to the middleware + */ +export let requestHasLocale: (context: APIContext) => boolean; + +if (i18n?.routing === 'manual') { + requestHasLocale = I18nInternals.requestHasLocale(locales); +} else { + requestHasLocale = noop('requestHasLocale'); +} + +/** + * Allows to use the build-in fallback system of Astro + * + * @param {APIContext} context The context passed to the middleware + * @param {Response} response An optional `Response` in case you're handling a `Response` coming from the `next` function. + */ +export let redirectToFallback: RedirectToFallback; + +if (i18n?.routing === 'manual') { + redirectToFallback = I18nInternals.redirectToFallback({ + base, + trailingSlash, + format, + defaultLocale, + locales, + strategy, + domains, + fallback, + }); +} else { + redirectToFallback = noop('useFallback'); +} + +type OnlyObject = T extends object ? T : never; +type NewAstroRoutingConfigWithoutManual = OnlyObject['routing']>; + +/** + * @param {AstroConfig['i18n']['routing']} customOptions + * + * A function that allows to programmatically create the Astro i18n middleware. + * + * This is use useful when you still want to use the default i18n logic, but add only few exceptions to your website. + * + * ## Examples + * + * ```js + * // middleware.js + * import { middleware } from "astro:i18n"; + * import { sequence, defineMiddleware } from "astro:middleware"; + * + * const customLogic = defineMiddleware(async (context, next) => { + * const response = await next(); + * + * // Custom logic after resolving the response. + * // It's possible to catch the response coming from Astro i18n middleware. + * + * return response; + * }); + * + * export const onRequest = sequence(customLogic, middleware({ + * prefixDefaultLocale: true, + * redirectToDefaultLocale: false + * })) + * + * ``` + */ +export let middleware: (customOptions: NewAstroRoutingConfigWithoutManual) => MiddlewareHandler; + +if (i18n?.routing === 'manual') { + middleware = (customOptions: NewAstroRoutingConfigWithoutManual) => { + const manifest: SSRManifest['i18n'] = { + ...i18n, + fallback: undefined, + strategy: toRoutingStrategy(customOptions, {}), + domainLookupTable: {}, + }; + return I18nInternals.createMiddleware(manifest, base, trailingSlash, format); + }; +} else { + middleware = noop('middleware'); +} diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index b08bcb4ebd57..ff0eaf3147aa 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -121,7 +121,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, - strategy: toRoutingStrategy(settings.config.i18n), + strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains), defaultLocale: settings.config.i18n.defaultLocale, locales: settings.config.i18n.locales, domainLookupTable: {}, diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index f10d9e1842ad..f1bfdfc4e13d 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -168,6 +168,7 @@ export async function handleRoute({ let options: SSROptions | undefined = undefined; let route: RouteData; const middleware = (await loadMiddleware(loader)).onRequest; + const locals = Reflect.get(incomingRequest, clientLocalsSymbol); if (!matchedRoute) { if (config.i18n) { @@ -235,7 +236,6 @@ export async function handleRoute({ const { preloadedComponent } = matchedRoute; route = matchedRoute.route; // Allows adapters to pass in locals in dev mode. - const locals = Reflect.get(incomingRequest, clientLocalsSymbol); request = createRequest({ base: config.base, url, diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs new file mode 100644 index 000000000000..0638988f063b --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig} from "astro/config"; + +export default defineConfig({ + i18n: { + defaultLocale: 'en', + locales: [ + 'en', 'pt', 'it', { + path: "spanish", + codes: ["es", "es-ar"] + } + ], + routing: "manual" + } +}) diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json new file mode 100644 index 000000000000..8230d254b88d --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/i18n-manual-with-default-middleware", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js new file mode 100644 index 000000000000..d45754902b2b --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js @@ -0,0 +1,22 @@ +import { defineMiddleware, sequence } from 'astro:middleware'; +import { middleware } from 'astro:i18n'; + +const customLogic = defineMiddleware(async (context, next) => { + const url = new URL(context.request.url); + if (url.pathname.startsWith('/about')) { + return new Response('ABOUT ME', { + status: 200, + }); + } + + const response = await next(); + + return response; +}); + +export const onRequest = sequence( + customLogic, + middleware({ + prefixDefaultLocale: true, + }) +); diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro new file mode 100644 index 000000000000..b5cb264b5f34 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro @@ -0,0 +1,8 @@ + + + Astro + + + + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro new file mode 100644 index 000000000000..f40d52dad178 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro @@ -0,0 +1,8 @@ + + + Astro + + +Blog should not render + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/blog/[id].astro new file mode 100644 index 000000000000..97b41230d6e9 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/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-manual-with-default-middleware/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/start.astro new file mode 100644 index 000000000000..d9f61aa025c1 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/start.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro new file mode 100644 index 000000000000..05faf7b0bcce --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro @@ -0,0 +1,8 @@ + + + Astro + + + Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/blog/[id].astro new file mode 100644 index 000000000000..e37f83a30243 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/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-manual-with-default-middleware/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/start.astro new file mode 100644 index 000000000000..9a37428ca626 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Hola +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro new file mode 100644 index 000000000000..a36031be6ec0 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro @@ -0,0 +1,14 @@ +--- +const currentLocale = Astro.currentLocale; + +--- + + + + Astro + + +Hola. +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs new file mode 100644 index 000000000000..0638988f063b --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig} from "astro/config"; + +export default defineConfig({ + i18n: { + defaultLocale: 'en', + locales: [ + 'en', 'pt', 'it', { + path: "spanish", + codes: ["es", "es-ar"] + } + ], + routing: "manual" + } +}) diff --git a/packages/astro/test/fixtures/i18n-routing-manual/package.json b/packages/astro/test/fixtures/i18n-routing-manual/package.json new file mode 100644 index 000000000000..b79591a69645 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/i18n-routing-manual", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js b/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js new file mode 100644 index 000000000000..29634e3ffea1 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js @@ -0,0 +1,20 @@ +import { defineMiddleware } from 'astro:middleware'; +import { redirectToDefaultLocale, requestHasLocale } from 'astro:i18n'; + +const allowList = new Set(['/help', '/help/']); + +export const onRequest = defineMiddleware(async (context, next) => { + if (allowList.has(context.url.pathname)) { + return await next(); + } + if (requestHasLocale(context)) { + return await next(); + } + + if (context.url.pathname === '/') { + return redirectToDefaultLocale(context); + } + return new Response(null, { + status: 404, + }); +}); diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/[id].astro new file mode 100644 index 000000000000..97b41230d6e9 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/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-manual/src/pages/en/blog/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/index.astro new file mode 100644 index 000000000000..edb95dc8da71 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/index.astro @@ -0,0 +1,8 @@ + + + Astro + + +Blog start + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro new file mode 100644 index 000000000000..d9f61aa025c1 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro new file mode 100644 index 000000000000..d9f61aa025c1 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro new file mode 100644 index 000000000000..f0c02bccf29e --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro @@ -0,0 +1,11 @@ +--- + +--- + + + Astro + + + Outside route + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro new file mode 100644 index 000000000000..d9f61aa025c1 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/blog/[id].astro new file mode 100644 index 000000000000..e37f83a30243 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/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-manual/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/start.astro new file mode 100644 index 000000000000..8e6455be4d76 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Oi +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro new file mode 100644 index 000000000000..a36031be6ec0 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro @@ -0,0 +1,14 @@ +--- +const currentLocale = Astro.currentLocale; + +--- + + + + Astro + + +Hola. +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js b/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js new file mode 100644 index 000000000000..2a80b04b9577 --- /dev/null +++ b/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js @@ -0,0 +1,96 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadFixture } from './test-utils.js'; +import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; + +// DEV +describe('Dev server manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual-with-default-middleware/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should return a 404', async () => { + const response = await fixture.fetch('/blog'); + const text = await response.text(); + assert.equal(response.status, 404); + assert.equal(text.includes('Blog should not render'), false); + }); + + it('should return a 200 because the custom middleware allows it', async () => { + const response = await fixture.fetch('/about'); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text.includes('ABOUT ME'), true); + }); +}); +// +// // SSG +describe('SSG manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual-with-default-middleware/', + }); + await fixture.build(); + }); + + it('should return a 404', async () => { + try { + await fixture.readFile('/blog.html'); + assert.fail(); + } catch (e) {} + }); + + it('should return a 200 because the custom middleware allows it', async () => { + let html = await fixture.readFile('/about/index.html'); + let $ = cheerio.load(html); + assert.equal($('body').text().includes('ABOUT ME'), true); + }); +}); + +// // SSR +describe('SSR manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual-with-default-middleware/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should return a 404', async () => { + let request = new Request('http://example.com/blog'); + let response = await app.render(request); + assert.equal(response.status, 404); + assert.equal((await response.text()).includes('Blog should not render'), false); + }); + + it('should return a 200 because the custom middleware allows it', async () => { + let request = new Request('http://example.com/about'); + let response = await app.render(request); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text.includes('ABOUT ME'), true); + }); +}); diff --git a/packages/astro/test/i18n-routing-manual.test.js b/packages/astro/test/i18n-routing-manual.test.js new file mode 100644 index 000000000000..24f9c187e874 --- /dev/null +++ b/packages/astro/test/i18n-routing-manual.test.js @@ -0,0 +1,147 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadFixture } from './test-utils.js'; +import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; + +// DEV +describe('Dev server manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should redirect to the default locale when middleware calls the function for route /', async () => { + const response = await fixture.fetch('/'); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text.includes('Hello'), true); + }); + + it('should render a route that is not related to the i18n routing', async () => { + const response = await fixture.fetch('/help'); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text.includes('Outside route'), true); + }); + + it('should render a i18n route', async () => { + let response = await fixture.fetch('/en/blog'); + assert.equal(response.status, 200); + let text = await response.text(); + assert.equal(text.includes('Blog start'), true); + + response = await fixture.fetch('/pt/start'); + assert.equal(response.status, 200); + text = await response.text(); + assert.equal(text.includes('Oi'), true); + + response = await fixture.fetch('/spanish'); + assert.equal(response.status, 200); + text = await response.text(); + assert.equal(text.includes('Hola.'), true); + }); +}); + +// SSG +describe('SSG manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual/', + }); + await fixture.build(); + }); + + it('should redirect to the default locale when middleware calls the function for route /', async () => { + let html = await fixture.readFile('/index.html'); + assert.equal(html.includes('http-equiv="refresh'), true); + assert.equal(html.includes('url=/en'), true); + }); + + it('should render a route that is not related to the i18n routing', async () => { + let html = await fixture.readFile('/help/index.html'); + let $ = cheerio.load(html); + assert.equal($('body').text().includes('Outside route'), true); + }); + + it('should render a i18n route', async () => { + let html = await fixture.readFile('/en/blog/index.html'); + let $ = cheerio.load(html); + assert.equal($('body').text().includes('Blog start'), true); + + html = await fixture.readFile('/pt/start/index.html'); + $ = cheerio.load(html); + assert.equal($('body').text().includes('Oi'), true); + + html = await fixture.readFile('/spanish/index.html'); + $ = cheerio.load(html); + assert.equal($('body').text().includes('Hola.'), true); + }); +}); + +// SSR +describe('SSR manual routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-manual/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should redirect to the default locale when middleware calls the function for route /', async () => { + let request = new Request('http://example.com/'); + let response = await app.render(request); + assert.equal(response.status, 302); + }); + + it('should render a route that is not related to the i18n routing', async () => { + let request = new Request('http://example.com/help'); + let response = await app.render(request); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text.includes('Outside route'), true); + }); + + it('should render a i18n route', async () => { + let request = new Request('http://example.com/en/blog'); + let response = await app.render(request); + assert.equal(response.status, 200); + let text = await response.text(); + assert.equal(text.includes('Blog start'), true); + + request = new Request('http://example.com/pt/start'); + response = await app.render(request); + assert.equal(response.status, 200); + text = await response.text(); + assert.equal(text.includes('Oi'), true); + + request = new Request('http://example.com/spanish'); + response = await app.render(request); + assert.equal(response.status, 200); + text = await response.text(); + assert.equal(text.includes('Hola.'), true); + }); +}); diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 58a609bedb61..6e1503819b40 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -773,7 +773,6 @@ describe('[SSG] i18n routing', () => { it('should redirect to the index of the default locale', async () => { const html = await fixture.readFile('/index.html'); assert.equal(html.includes('http-equiv="refresh'), true); - assert.equal(html.includes('http-equiv="refresh'), true); assert.equal(html.includes('url=/new-site/en'), true); }); diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js index 6622308cc539..b55d2db21d98 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ b/packages/astro/test/units/i18n/astro_i18n.test.js @@ -245,7 +245,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en-us/' ); @@ -258,7 +258,7 @@ describe('getLocaleRelativeUrl', () => { trailingSlash: 'always', format: 'directory', normalizeLocale: false, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en_US/' ); @@ -270,7 +270,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en-au/' ); @@ -300,7 +300,7 @@ describe('getLocaleRelativeUrl', () => { trailingSlash: 'always', format: 'directory', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en/' ); @@ -311,7 +311,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/es/' ); @@ -324,7 +324,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en/' ); @@ -335,7 +335,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/es/' ); @@ -366,7 +366,7 @@ describe('getLocaleRelativeUrl', () => { trailingSlash: 'always', format: 'directory', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en/' ); @@ -377,7 +377,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/es/' ); @@ -390,7 +390,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/en/' ); @@ -401,7 +401,7 @@ describe('getLocaleRelativeUrl', () => { ...config.i18n, trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), '/blog/es/' ); @@ -603,7 +603,7 @@ describe('getLocaleRelativeUrlList', () => { ...config.i18n, trailingSlash: 'never', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), ['/blog/en', '/blog/en-us', '/blog/es'] ); @@ -632,7 +632,7 @@ describe('getLocaleRelativeUrlList', () => { ...config.i18n, trailingSlash: 'never', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), ['/blog/en', '/blog/en-us', '/blog/es'] ); @@ -830,7 +830,7 @@ describe('getLocaleAbsoluteUrl', () => { format: 'directory', site: 'https://example.com', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -843,7 +843,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -857,7 +857,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'file', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -869,7 +869,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'file', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -883,7 +883,7 @@ describe('getLocaleAbsoluteUrl', () => { format: 'file', site: 'https://example.com', isBuild: true, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://es.example.com/blog/' ); @@ -899,7 +899,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', path: 'first-post', isBuild: true, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://es.example.com/blog/some-name/first-post/' ); @@ -927,7 +927,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/en/' ); @@ -939,7 +939,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/es/' ); @@ -968,7 +968,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'never', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en' ); @@ -980,7 +980,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -993,7 +993,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'ignore', format: 'directory', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -1007,7 +1007,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'never', format: 'file', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en' ); @@ -1019,7 +1019,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'always', format: 'file', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -1033,7 +1033,7 @@ describe('getLocaleAbsoluteUrl', () => { trailingSlash: 'ignore', format: 'file', site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en' ); @@ -1115,7 +1115,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', format: 'directory', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -1127,7 +1127,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -1141,7 +1141,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -1153,7 +1153,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -1185,7 +1185,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', format: 'directory', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -1197,7 +1197,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'directory', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -1211,7 +1211,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/en/' ); @@ -1223,7 +1223,7 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', trailingSlash: 'always', format: 'file', - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), 'https://example.com/blog/es/' ); @@ -1530,7 +1530,7 @@ describe('getLocaleAbsoluteUrlList', () => { path: 'download', ...config, ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), }), [ 'https://example.com/en/download/', @@ -1570,7 +1570,7 @@ describe('getLocaleAbsoluteUrlList', () => { path: 'download', ...config, ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), isBuild: true, }), [ @@ -1726,7 +1726,7 @@ describe('getLocaleAbsoluteUrlList', () => { locale: 'en', base: '/blog/', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), trailingSlash: 'ignore', format: 'directory', site: 'https://example.com', @@ -1760,7 +1760,7 @@ describe('getLocaleAbsoluteUrlList', () => { locale: 'en', base: '/blog/', ...config.i18n, - strategy: toRoutingStrategy(config.i18n), + strategy: toRoutingStrategy(config.i18n.routing, {}), trailingSlash: 'ignore', format: 'directory', site: 'https://example.com', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06c90798a02d..93b424f3c760 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2903,6 +2903,18 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/i18n-routing-manual: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/i18n-routing-prefix-always: dependencies: astro: diff --git a/scripts/cmd/test.js b/scripts/cmd/test.js index 87b34987ab28..04f02f73afc0 100644 --- a/scripts/cmd/test.js +++ b/scripts/cmd/test.js @@ -7,7 +7,7 @@ import arg from 'arg'; import glob from 'tiny-glob'; const isCI = !!process.env.CI; -const defaultTimeout = isCI ? 1200000 : 600000; +const defaultTimeout = isCI ? 1400000 : 600000; export default async function test() { const args = arg({