From a4015a22c47843037e5ea32b837983a3f883b664 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 10 Oct 2023 17:47:37 +0100 Subject: [PATCH] feat(i18n): fallback via middleware (#8791) --- packages/astro/src/@types/astro.ts | 28 ++- packages/astro/src/core/build/common.ts | 2 + packages/astro/src/core/config/schema.ts | 21 ++- packages/astro/src/core/errors/errors-data.ts | 6 + packages/astro/src/core/middleware/index.ts | 4 +- .../astro/src/core/middleware/sequence.ts | 6 +- packages/astro/src/core/pipeline.ts | 14 +- packages/astro/src/core/redirects/helpers.ts | 4 + packages/astro/src/core/render/context.ts | 2 +- packages/astro/src/core/render/core.ts | 10 +- .../astro/src/core/render/params-and-props.ts | 9 +- packages/astro/src/core/render/route-cache.ts | 36 ++-- packages/astro/src/core/routing/index.ts | 2 +- .../astro/src/core/routing/manifest/create.ts | 18 -- .../core/routing/manifest/serialization.ts | 1 - packages/astro/src/core/routing/match.ts | 58 +----- packages/astro/src/i18n/middleware.ts | 38 ++++ .../src/vite-plugin-astro-server/route.ts | 177 ++++++++++++------ packages/astro/test/dev-routing.test.js | 65 ++++++- .../i18n-routing-base/astro.config.mjs | 5 +- .../i18n-routing-fallback/astro.config.mjs | 16 ++ .../i18n-routing-fallback/package.json | 8 + .../src/pages/en/blog/[id].astro | 18 ++ .../src/pages/en/start.astro | 8 + .../src/pages/index.astro | 8 + .../src/pages/pt/blog/[id].astro | 18 ++ .../src/pages/pt/start.astro | 8 + .../fixtures/i18n-routing/astro.config.mjs | 5 +- .../test/units/config/config-validate.test.js | 62 ++++++ pnpm-lock.yaml | 6 + 30 files changed, 472 insertions(+), 191 deletions(-) create mode 100644 packages/astro/src/i18n/middleware.ts create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/astro.config.mjs create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/package.json create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/src/pages/en/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/src/pages/en/start.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-fallback/src/pages/pt/start.astro diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 5c6ca0b9c..4d0127dc1 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1379,6 +1379,21 @@ export interface AstroUserConfig { */ fallback?: Record; + /** + * @docs + * @name experimental.i18n.fallbackControl + * @type {"none" | "render" | "redirect"} + * @version 3.*.* + * @description + * + * Controls the fallback system of the internationalisation: + * - `none`: Astro will do nothing and will return `404` if a translated page isn't translated; + * - `redirect`: Astro will do a redirect to the fallback language if the translated page returns a `404`; + * - `render`: currently unsupported by Astro + * + */ + fallbackControl: 'none' | 'render' | 'redirect'; + /** * @docs * @name experimental.i18n.detectBrowserLanguage @@ -1857,7 +1872,7 @@ export type AstroFeatureMap = { /** * List of features that orbit around the i18n routing */ - i18n?: AstroInternalisationFeature; + i18n?: AstroInternationalisationFeature; }; export interface AstroAssetsFeature { @@ -1872,7 +1887,7 @@ export interface AstroAssetsFeature { isSquooshCompatible?: boolean; } -export interface AstroInternalisationFeature { +export interface AstroInternationalisationFeature { /** * Wether the adapter is able to detect the language of the browser, usually using the `Accept-Language` header. */ @@ -2178,7 +2193,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; @@ -2207,7 +2228,6 @@ export interface RouteData { prerender: boolean; redirect?: RedirectConfig; redirectRoute?: RouteData; - locale: string | undefined; } export type RedirectRouteData = RouteData & { diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts index 5b3811f1d..e7efc6439 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/config/schema.ts b/packages/astro/src/core/config/schema.ts index f362a243d..e790fb786 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -278,29 +278,34 @@ export const AstroConfigSchema = z.object({ locales: z.string().array(), fallback: z.record(z.string(), z.string().array()).optional().default({}), detectBrowserLanguage: z.boolean().optional().default(false), + // TODO: properly add default when the feature goes of experimental + fallbackControl: z.enum(['none', 'redirect', 'render']).optional().default('none'), }) .optional() - .refine((i18n) => { + .superRefine((i18n, ctx) => { if (i18n) { const { defaultLocale, locales, fallback } = i18n; - if (locales.includes(defaultLocale)) { - return { + 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 [fallbackKey, fallbackArray] of Object.entries(fallback)) { if (!locales.includes(fallbackKey)) { - return { + ctx.addIssue({ + code: z.ZodIssueCode.custom, message: `The locale \`${fallbackKey}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, - }; + }); } for (const fallbackArrayKey of fallbackArray) { if (!locales.includes(fallbackArrayKey)) { - return { + ctx.addIssue({ + code: z.ZodIssueCode.custom, message: `The locale \`${fallbackArrayKey}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, - }; + }); } } } diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index aab699a1c..669d54936 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1253,5 +1253,11 @@ export const MissingLocale = { }, } satisfies ErrorData; +export const CantRenderPage = { + name: 'CantRenderPage', + title: "Astro can't render the route.", + message: "You tried to render a route that isn't a redirect and doesn't have any component.", +} 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 1b87bf1e1..0ff31bc44 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; } diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts index 29b1d1623..d8d71c66b 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 438ff275d..8e90a2117 100644 --- a/packages/astro/src/core/pipeline.ts +++ b/packages/astro/src/core/pipeline.ts @@ -67,7 +67,7 @@ export class Pipeline { */ async renderRoute( renderContext: RenderContext, - componentInstance: ComponentInstance + componentInstance: ComponentInstance | undefined ): Promise { const result = await this.#tryRenderRoute( renderContext, @@ -100,7 +100,8 @@ export class Pipeline { async #tryRenderRoute( renderContext: Readonly, env: Readonly, - mod: Readonly, + mod: Readonly | undefined, + onRequest?: MiddlewareHandler ): Promise { const apiContext = createAPIContext({ @@ -113,6 +114,7 @@ export class Pipeline { switch (renderContext.route.type) { case 'page': + case 'fallback': case 'redirect': { if (onRequest) { return await callMiddleware( @@ -138,13 +140,7 @@ export class Pipeline { } } case 'endpoint': { - const result = await callEndpoint( - mod as any as EndpointHandler, - env, - renderContext, - onRequest - ); - return result; + return await callEndpoint(mod as any as EndpointHandler, env, renderContext, onRequest); } 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 7574f2714..697cb0fd8 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 86efb63e3..e197dc929 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -34,7 +34,7 @@ export type CreateRenderContextArgs = Partial< > & { route: RouteData; request: RenderContext['request']; - mod: ComponentInstance; + mod: ComponentInstance | undefined; env: Environment; }; diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index d8c39ec1a..451a95ec9 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -13,9 +13,12 @@ import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../ 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 +32,11 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag location: redirectRouteGenerate(renderContext.route, renderContext.params), }, }); + // TODO: check this one + } else if (routeIsFallback(renderContext.route)) { + return new Response(null); + } else if (!mod) { + throw new AstroError(CantRenderPage); } // Validate the page component before rendering the page diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts index ac2884a7a..b1f8b2ca2 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/route-cache.ts b/packages/astro/src/core/render/route-cache.ts index 4bfb94e93..5b22518de 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/index.ts b/packages/astro/src/core/routing/index.ts index 08221923d..b568bb121 100644 --- a/packages/astro/src/core/routing/index.ts +++ b/packages/astro/src/core/routing/index.ts @@ -1,5 +1,5 @@ export { createRouteManifest } from './manifest/create.js'; export { deserializeRouteData, serializeRouteData } from './manifest/serialization.js'; -export { matchAllRoutes, matchRoute, matchDefaultLocaleRoutes } from './match.js'; +export { matchAllRoutes, matchRoute } from './match.js'; export { getParams } from './params.js'; export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js'; diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 9caefbe02..8fd3a8d82 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -335,11 +335,6 @@ export function createRouteManifest( const route = `/${segments .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .join('/')}`.toLowerCase(); - const locale = settings.config.experimental.i18n?.locales.find((currentLocale) => { - if (route.includes(`/${currentLocale}`)) { - return currentLocale; - } - }); routes.push({ route, type: item.isPage ? 'page' : 'endpoint', @@ -350,7 +345,6 @@ export function createRouteManifest( generate, pathname: pathname || undefined, prerender, - locale, }); } }); @@ -413,11 +407,6 @@ export function createRouteManifest( `An integration attempted to inject a route that is already used in your project: "${route}" at "${component}". \nThis route collides with: "${collision.component}".` ); } - const locale = settings.config.experimental.i18n?.locales.find((currentLocale) => { - if (route.includes(`/${currentLocale}`)) { - return currentLocale; - } - }); // the routes array was already sorted by priority, // pushing to the front of the list ensure that injected routes @@ -432,7 +421,6 @@ export function createRouteManifest( generate, pathname: pathname || void 0, prerender: prerenderInjected ?? prerender, - locale, }); }); @@ -460,11 +448,6 @@ export function createRouteManifest( .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .join('/')}`.toLowerCase(); - const locale = settings.config.experimental.i18n?.locales.find((currentLocale) => { - if (route.includes(`/${currentLocale}`)) { - return currentLocale; - } - }); const routeData: RouteData = { type: 'redirect', route, @@ -477,7 +460,6 @@ export function createRouteManifest( prerender: false, redirect: to, redirectRoute: routes.find((r) => r.route === to), - locale, }; const lastSegmentIsDynamic = (r: RouteData) => !!r.segments.at(-1)?.at(-1)?.dynamic; diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts index 5abe66e5d..71ffc221d 100644 --- a/packages/astro/src/core/routing/manifest/serialization.ts +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -32,6 +32,5 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa redirectRoute: rawRouteData.redirectRoute ? deserializeRouteData(rawRouteData.redirectRoute) : undefined, - locale: undefined, }; } diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts index 7e8d1ec2e..9b91e1e9a 100644 --- a/packages/astro/src/core/routing/match.ts +++ b/packages/astro/src/core/routing/match.ts @@ -1,4 +1,4 @@ -import type { AstroConfig, ManifestData, RouteData } from '../../@types/astro.js'; +import type { ManifestData, RouteData } from '../../@types/astro.js'; /** Find matching route from pathname */ export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { @@ -9,59 +9,3 @@ export function matchRoute(pathname: string, manifest: ManifestData): RouteData export function matchAllRoutes(pathname: string, manifest: ManifestData): RouteData[] { return manifest.routes.filter((route) => route.pattern.test(pathname)); } - -/** - * Given a pathname, the function attempts to retrieve the one that belongs to the `defaultLocale`. - * - * For example, given this configuration: - * - * ```js - * { - * defaultLocale: 'en', - * locales: ['en', 'fr'] - * } - * ``` - * - * If we don't have the page `/fr/hello`, this function will attempt to match against `/en/hello`. - */ -export function matchDefaultLocaleRoutes( - pathname: string, - manifest: ManifestData, - config: AstroConfig -): RouteData[] { - // SAFETY: the function is called upon checking if `experimental.i18n` exists first - const i18n = config.experimental.i18n!; - const base = config.base; - - const matchedRoutes: RouteData[] = []; - const defaultLocale = i18n.defaultLocale; - - for (const route of manifest.routes) { - // we don't need to check routes that don't belong to the default locale - if (route.locale === defaultLocale) { - // we check if the current route pathname contains `/en` somewhere - if ( - route.pathname?.startsWith(`/${defaultLocale}`) || - route.pathname?.startsWith(`${base}/${defaultLocale}`) - ) { - let localeToReplace; - // now we need to check if the locale inside `pathname` is actually one of the locales configured - for (const locale of i18n.locales) { - if (pathname.startsWith(`${base}/${locale}`) || pathname.startsWith(`/${locale}`)) { - localeToReplace = locale; - break; - } - } - if (localeToReplace) { - // we attempt the replace the locale found with the default locale, and now we could if matches the current `route` - const maybePathname = pathname.replace(localeToReplace, defaultLocale); - if (route.pattern.test(maybePathname)) { - matchedRoutes.push(route); - } - } - } - } - } - - return matchedRoutes; -} diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts new file mode 100644 index 000000000..5496e55dc --- /dev/null +++ b/packages/astro/src/i18n/middleware.ts @@ -0,0 +1,38 @@ +import type { AstroConfig, MiddlewareEndpointHandler } from '../@types/astro.js'; +import type { Logger } from '../core/logger/core.js'; + +export function createI18nMiddleware( + config: Readonly, + logger: Logger +): MiddlewareEndpointHandler | undefined { + const i18n = config.experimental?.i18n; + if (!i18n) { + return undefined; + } + const fallbackKeys = Object.keys(i18n.fallback); + const locales = i18n.locales; + + logger.debug('i18n', 'Successfully created middleware'); + return async (context, next) => { + if (fallbackKeys.length <= 0) { + return next(); + } + + const response = await next(); + if (i18n.fallbackControl === 'redirect' && response instanceof Response) { + const url = context.url; + const separators = url.pathname.split('/'); + + const urlLocale = separators.find((s) => locales.includes(s)); + + if (urlLocale && fallbackKeys.includes(urlLocale)) { + // TODO: correctly handle chain of fallback + const fallbackLocale = i18n.fallback[urlLocale][0]; + const newPathname = url.pathname.replace(`/${urlLocale}`, `/${fallbackLocale}`); + return context.redirect(newPathname); + } + } + + return response; + }; +} diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 4fddf3696..21d938ec9 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -9,9 +9,14 @@ 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, matchDefaultLocaleRoutes } from '../core/routing/index.js'; +import { matchAllRoutes } from '../core/routing/index.js'; import { isPage } from '../core/util.js'; import { getSortedPreloadedMatches } from '../prerender/routing.js'; import { isServerLikeOutput } from '../prerender/utils.js'; @@ -23,6 +28,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'); @@ -51,14 +58,9 @@ export async function matchRoute( pipeline: DevPipeline ): Promise { const env = pipeline.getEnvironment(); - const config = pipeline.getConfig(); const { routeCache, logger } = env; let matches = matchAllRoutes(pathname, manifestData); - // if we haven't found any match, we try to fetch the default locale matched route - if (matches.length === 0 && config.experimental.i18n) { - matches = matchDefaultLocaleRoutes(pathname, manifestData, config); - } const preloadedMatches = await getSortedPreloadedMatches({ pipeline, matches, @@ -163,70 +165,133 @@ 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, + }); + + renderContext = await createRenderContext({ + request: options.request, + pathname: options.pathname, + scripts, + links, + styles, + componentMetadata: metadata, + route: options.route, + mod, + env, + }); } - 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, logger); - 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/dev-routing.test.js b/packages/astro/test/dev-routing.test.js index 03c531157..8095b2999 100644 --- a/packages/astro/test/dev-routing.test.js +++ b/packages/astro/test/dev-routing.test.js @@ -377,10 +377,9 @@ describe('Development Routing', () => { expect(await response2.text()).includes('Hola mundo'); }); - it("should render the default locale if there isn't a fallback and the route is missing", async () => { + 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(200); - expect(await response.text()).includes('Hello'); + 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 () => { @@ -426,7 +425,65 @@ describe('Development Routing', () => { expect(await response2.text()).includes('Hola mundo'); }); - it("should render the default locale if there isn't a fallback and the route is missing", async () => { + 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 fallback [redirect]', () => { + /** @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'], + }, + fallbackControl: 'redirect', + }, + }, + }); + 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 render 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('Hello'); diff --git a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs index 2f329a667..e9e7ef6fa 100644 --- a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs @@ -7,10 +7,7 @@ export default defineConfig({ defaultLocale: 'en', locales: [ 'en', 'pt', 'it' - ], - fallback: { - 'pt-BR': ['pt'] - } + ] } } }) 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 000000000..a1a3bbe5b --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-fallback/astro.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig} from "astro/config"; + +export default defineConfig({ + base: "new-site", + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', 'pt', 'it' + ], + fallback: { + "it": ["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 000000000..0741df455 --- /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/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/en/blog/[id].astro new file mode 100644 index 000000000..97b41230d --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-fallback/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-fallback/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/en/start.astro new file mode 100644 index 000000000..d9f61aa02 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/en/start.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + 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 000000000..05faf7b0b --- /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 000000000..e37f83a30 --- /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 000000000..15a63a7b8 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-fallback/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 index 39d1baf9c..6229deced 100644 --- a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs @@ -6,10 +6,7 @@ export default defineConfig({ defaultLocale: 'en', locales: [ 'en', 'pt', 'it' - ], - fallback: { - 'pt-BR': ['pt'] - } + ] } }, base: "/new-site" diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 48088468a..be5923a04 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -77,4 +77,66 @@ 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." + ); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a58c463be..ca5e93d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2767,6 +2767,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/i18n-routing-fallback: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/import-ts-with-js: dependencies: astro: