feat(i18n): fallback via middleware (#8791)

This commit is contained in:
Emanuele Stoppa 2023-10-10 17:47:37 +01:00 committed by GitHub
parent 7678ef33a0
commit a4015a22c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 472 additions and 191 deletions

View file

@ -1379,6 +1379,21 @@ export interface AstroUserConfig {
*/ */
fallback?: Record<string, string[]>; fallback?: Record<string, string[]>;
/**
* @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 * @docs
* @name experimental.i18n.detectBrowserLanguage * @name experimental.i18n.detectBrowserLanguage
@ -1857,7 +1872,7 @@ export type AstroFeatureMap = {
/** /**
* List of features that orbit around the i18n routing * List of features that orbit around the i18n routing
*/ */
i18n?: AstroInternalisationFeature; i18n?: AstroInternationalisationFeature;
}; };
export interface AstroAssetsFeature { export interface AstroAssetsFeature {
@ -1872,7 +1887,7 @@ export interface AstroAssetsFeature {
isSquooshCompatible?: boolean; 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. * 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; 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 { export interface RoutePart {
content: string; content: string;
@ -2207,7 +2228,6 @@ export interface RouteData {
prerender: boolean; prerender: boolean;
redirect?: RedirectConfig; redirect?: RedirectConfig;
redirectRoute?: RouteData; redirectRoute?: RouteData;
locale: string | undefined;
} }
export type RedirectRouteData = RouteData & { export type RedirectRouteData = RouteData & {

View file

@ -25,6 +25,7 @@ export function getOutFolder(
switch (routeType) { switch (routeType) {
case 'endpoint': case 'endpoint':
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
case 'fallback':
case 'page': case 'page':
case 'redirect': case 'redirect':
switch (astroConfig.build.format) { switch (astroConfig.build.format) {
@ -52,6 +53,7 @@ export function getOutFile(
case 'endpoint': case 'endpoint':
return new URL(npath.basename(pathname), outFolder); return new URL(npath.basename(pathname), outFolder);
case 'page': case 'page':
case 'fallback':
case 'redirect': case 'redirect':
switch (astroConfig.build.format) { switch (astroConfig.build.format) {
case 'directory': { case 'directory': {

View file

@ -278,29 +278,34 @@ export const AstroConfigSchema = z.object({
locales: z.string().array(), locales: z.string().array(),
fallback: z.record(z.string(), z.string().array()).optional().default({}), fallback: z.record(z.string(), z.string().array()).optional().default({}),
detectBrowserLanguage: z.boolean().optional().default(false), 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() .optional()
.refine((i18n) => { .superRefine((i18n, ctx) => {
if (i18n) { if (i18n) {
const { defaultLocale, locales, fallback } = i18n; const { defaultLocale, locales, fallback } = i18n;
if (locales.includes(defaultLocale)) { if (!locales.includes(defaultLocale)) {
return { ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`, message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`,
}; });
} }
if (fallback) { if (fallback) {
for (const [fallbackKey, fallbackArray] of Object.entries(fallback)) { for (const [fallbackKey, fallbackArray] of Object.entries(fallback)) {
if (!locales.includes(fallbackKey)) { 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.`, message: `The locale \`${fallbackKey}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
}; });
} }
for (const fallbackArrayKey of fallbackArray) { for (const fallbackArrayKey of fallbackArray) {
if (!locales.includes(fallbackArrayKey)) { 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.`, message: `The locale \`${fallbackArrayKey}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
}; });
} }
} }
} }

View file

@ -1253,5 +1253,11 @@ export const MissingLocale = {
}, },
} satisfies ErrorData; } 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 // 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; export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData;

View file

@ -1,8 +1,8 @@
import type { MiddlewareResponseHandler, Params } from '../../@types/astro.js'; import type { MiddlewareEndpointHandler, Params } from '../../@types/astro.js';
import { createAPIContext } from '../endpoint/index.js'; import { createAPIContext } from '../endpoint/index.js';
import { sequence } from './sequence.js'; import { sequence } from './sequence.js';
function defineMiddleware(fn: MiddlewareResponseHandler) { function defineMiddleware(fn: MiddlewareEndpointHandler) {
return fn; return fn;
} }

View file

@ -1,4 +1,4 @@
import type { APIContext, MiddlewareResponseHandler } from '../../@types/astro.js'; import type { APIContext, MiddlewareEndpointHandler } from '../../@types/astro.js';
import { defineMiddleware } from './index.js'; import { defineMiddleware } from './index.js';
// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.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. * 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; const length = handlers.length;
if (!length) { if (!length) {
const handler: MiddlewareResponseHandler = defineMiddleware((context, next) => { const handler: MiddlewareEndpointHandler = defineMiddleware((context, next) => {
return next(); return next();
}); });
return handler; return handler;

View file

@ -67,7 +67,7 @@ export class Pipeline {
*/ */
async renderRoute( async renderRoute(
renderContext: RenderContext, renderContext: RenderContext,
componentInstance: ComponentInstance componentInstance: ComponentInstance | undefined
): Promise<Response> { ): Promise<Response> {
const result = await this.#tryRenderRoute( const result = await this.#tryRenderRoute(
renderContext, renderContext,
@ -100,7 +100,8 @@ export class Pipeline {
async #tryRenderRoute<MiddlewareReturnType = Response>( async #tryRenderRoute<MiddlewareReturnType = Response>(
renderContext: Readonly<RenderContext>, renderContext: Readonly<RenderContext>,
env: Readonly<Environment>, env: Readonly<Environment>,
mod: Readonly<ComponentInstance>, mod: Readonly<ComponentInstance> | undefined,
onRequest?: MiddlewareHandler<MiddlewareReturnType> onRequest?: MiddlewareHandler<MiddlewareReturnType>
): Promise<Response> { ): Promise<Response> {
const apiContext = createAPIContext({ const apiContext = createAPIContext({
@ -113,6 +114,7 @@ export class Pipeline {
switch (renderContext.route.type) { switch (renderContext.route.type) {
case 'page': case 'page':
case 'fallback':
case 'redirect': { case 'redirect': {
if (onRequest) { if (onRequest) {
return await callMiddleware<Response>( return await callMiddleware<Response>(
@ -138,13 +140,7 @@ export class Pipeline {
} }
} }
case 'endpoint': { case 'endpoint': {
const result = await callEndpoint( return await callEndpoint(mod as any as EndpointHandler, env, renderContext, onRequest);
mod as any as EndpointHandler,
env,
renderContext,
onRequest
);
return result;
} }
default: default:
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);

View file

@ -9,6 +9,10 @@ export function routeIsRedirect(route: RouteData | undefined): route is Redirect
return route?.type === 'redirect'; 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 { export function redirectRouteGenerate(redirectRoute: RouteData, data: Params): string {
const routeData = redirectRoute.redirectRoute; const routeData = redirectRoute.redirectRoute;
const route = redirectRoute.redirect; const route = redirectRoute.redirect;

View file

@ -34,7 +34,7 @@ export type CreateRenderContextArgs = Partial<
> & { > & {
route: RouteData; route: RouteData;
request: RenderContext['request']; request: RenderContext['request'];
mod: ComponentInstance; mod: ComponentInstance | undefined;
env: Environment; env: Environment;
}; };

View file

@ -13,9 +13,12 @@ import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../
import type { RenderContext } from './context.js'; import type { RenderContext } from './context.js';
import type { Environment } from './environment.js'; import type { Environment } from './environment.js';
import { createResult } from './result.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 = { export type RenderPage = {
mod: ComponentInstance; mod: ComponentInstance | undefined;
renderContext: RenderContext; renderContext: RenderContext;
env: Environment; env: Environment;
cookies: AstroCookies; cookies: AstroCookies;
@ -29,6 +32,11 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag
location: redirectRouteGenerate(renderContext.route, renderContext.params), 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 // Validate the page component before rendering the page

View file

@ -4,9 +4,10 @@ import type { Logger } from '../logger/core.js';
import { routeIsRedirect } from '../redirects/index.js'; import { routeIsRedirect } from '../redirects/index.js';
import { getParams } from '../routing/params.js'; import { getParams } from '../routing/params.js';
import { RouteCache, callGetStaticPaths, findPathItemByKey } from './route-cache.js'; import { RouteCache, callGetStaticPaths, findPathItemByKey } from './route-cache.js';
import { routeIsFallback } from '../redirects/helpers.js';
interface GetParamsAndPropsOptions { interface GetParamsAndPropsOptions {
mod: ComponentInstance; mod: ComponentInstance | undefined;
route?: RouteData | undefined; route?: RouteData | undefined;
routeCache: RouteCache; routeCache: RouteCache;
pathname: string; pathname: string;
@ -26,11 +27,13 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise
// This is a dynamic route, start getting the params // This is a dynamic route, start getting the params
const params = getRouteParams(route, pathname) ?? {}; const params = getRouteParams(route, pathname) ?? {};
if (routeIsRedirect(route)) { if (routeIsRedirect(route) || routeIsFallback(route)) {
return [params, {}]; return [params, {}];
} }
if (mod) {
validatePrerenderEndpointCollision(route, mod, params); validatePrerenderEndpointCollision(route, mod, params);
}
// During build, the route cache should already be populated. // During build, the route cache should already be populated.
// During development, the route cache is filled on-demand and may be empty. // During development, the route cache is filled on-demand and may be empty.

View file

@ -16,7 +16,7 @@ import { validateDynamicRouteModule, validateGetStaticPathsResult } from '../rou
import { generatePaginateFunction } from './paginate.js'; import { generatePaginateFunction } from './paginate.js';
interface CallGetStaticPathsOptions { interface CallGetStaticPathsOptions {
mod: ComponentInstance; mod: ComponentInstance | undefined;
route: RouteData; route: RouteData;
routeCache: RouteCache; routeCache: RouteCache;
logger: Logger; logger: Logger;
@ -33,7 +33,9 @@ export async function callGetStaticPaths({
const cached = routeCache.get(route); const cached = routeCache.get(route);
if (cached?.staticPaths) return cached.staticPaths; if (cached?.staticPaths) return cached.staticPaths;
if (mod) {
validateDynamicRouteModule(mod, { ssr, route }); validateDynamicRouteModule(mod, { ssr, route });
}
// No static paths in SSR mode. Return an empty RouteCacheEntry. // No static paths in SSR mode. Return an empty RouteCacheEntry.
if (ssr && !route.prerender) { if (ssr && !route.prerender) {
@ -42,14 +44,16 @@ export async function callGetStaticPaths({
return entry; return entry;
} }
let staticPaths: GetStaticPathsResult = [];
// Add a check here to make TypeScript happy. // Add a check here to make TypeScript happy.
// This is already checked in validateDynamicRouteModule(). // This is already checked in validateDynamicRouteModule().
if (mod) {
if (!mod.getStaticPaths) { if (!mod.getStaticPaths) {
throw new Error('Unexpected Error.'); throw new Error('Unexpected Error.');
} }
if (mod) {
// Calculate your static paths. // Calculate your static paths.
let staticPaths: GetStaticPathsResult = [];
staticPaths = await mod.getStaticPaths({ staticPaths = await mod.getStaticPaths({
// Q: Why the cast? // 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 // A: So users downstream can have nicer typings, we have to make some sacrifice in our internal typings, which necessitate a cast here
@ -58,6 +62,8 @@ export async function callGetStaticPaths({
throw new AstroError(AstroErrorData.GetStaticPathsRemovedRSSHelper); throw new AstroError(AstroErrorData.GetStaticPathsRemovedRSSHelper);
}, },
}); });
}
}
validateGetStaticPathsResult(staticPaths, logger, route); validateGetStaticPathsResult(staticPaths, logger, route);

View file

@ -1,5 +1,5 @@
export { createRouteManifest } from './manifest/create.js'; export { createRouteManifest } from './manifest/create.js';
export { deserializeRouteData, serializeRouteData } from './manifest/serialization.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 { getParams } from './params.js';
export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js'; export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js';

View file

@ -335,11 +335,6 @@ export function createRouteManifest(
const route = `/${segments const route = `/${segments
.map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content))
.join('/')}`.toLowerCase(); .join('/')}`.toLowerCase();
const locale = settings.config.experimental.i18n?.locales.find((currentLocale) => {
if (route.includes(`/${currentLocale}`)) {
return currentLocale;
}
});
routes.push({ routes.push({
route, route,
type: item.isPage ? 'page' : 'endpoint', type: item.isPage ? 'page' : 'endpoint',
@ -350,7 +345,6 @@ export function createRouteManifest(
generate, generate,
pathname: pathname || undefined, pathname: pathname || undefined,
prerender, 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}".` `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, // the routes array was already sorted by priority,
// pushing to the front of the list ensure that injected routes // pushing to the front of the list ensure that injected routes
@ -432,7 +421,6 @@ export function createRouteManifest(
generate, generate,
pathname: pathname || void 0, pathname: pathname || void 0,
prerender: prerenderInjected ?? prerender, prerender: prerenderInjected ?? prerender,
locale,
}); });
}); });
@ -460,11 +448,6 @@ export function createRouteManifest(
.map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content))
.join('/')}`.toLowerCase(); .join('/')}`.toLowerCase();
const locale = settings.config.experimental.i18n?.locales.find((currentLocale) => {
if (route.includes(`/${currentLocale}`)) {
return currentLocale;
}
});
const routeData: RouteData = { const routeData: RouteData = {
type: 'redirect', type: 'redirect',
route, route,
@ -477,7 +460,6 @@ export function createRouteManifest(
prerender: false, prerender: false,
redirect: to, redirect: to,
redirectRoute: routes.find((r) => r.route === to), redirectRoute: routes.find((r) => r.route === to),
locale,
}; };
const lastSegmentIsDynamic = (r: RouteData) => !!r.segments.at(-1)?.at(-1)?.dynamic; const lastSegmentIsDynamic = (r: RouteData) => !!r.segments.at(-1)?.at(-1)?.dynamic;

View file

@ -32,6 +32,5 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
redirectRoute: rawRouteData.redirectRoute redirectRoute: rawRouteData.redirectRoute
? deserializeRouteData(rawRouteData.redirectRoute) ? deserializeRouteData(rawRouteData.redirectRoute)
: undefined, : undefined,
locale: undefined,
}; };
} }

View file

@ -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 */ /** Find matching route from pathname */
export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { 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[] { export function matchAllRoutes(pathname: string, manifest: ManifestData): RouteData[] {
return manifest.routes.filter((route) => route.pattern.test(pathname)); 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;
}

View file

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

View file

@ -9,9 +9,14 @@ import type {
} from '../@types/astro.js'; } from '../@types/astro.js';
import { AstroErrorData, isAstroError } from '../core/errors/index.js'; import { AstroErrorData, isAstroError } from '../core/errors/index.js';
import { loadMiddleware } from '../core/middleware/loadMiddleware.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 { 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 { isPage } from '../core/util.js';
import { getSortedPreloadedMatches } from '../prerender/routing.js'; import { getSortedPreloadedMatches } from '../prerender/routing.js';
import { isServerLikeOutput } from '../prerender/utils.js'; import { isServerLikeOutput } from '../prerender/utils.js';
@ -23,6 +28,8 @@ import { preload } from './index.js';
import { getComponentMetadata } from './metadata.js'; import { getComponentMetadata } from './metadata.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { getScriptsForURL } from './scripts.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'); const clientLocalsSymbol = Symbol.for('astro.locals');
@ -51,14 +58,9 @@ export async function matchRoute(
pipeline: DevPipeline pipeline: DevPipeline
): Promise<MatchedRoute | undefined> { ): Promise<MatchedRoute | undefined> {
const env = pipeline.getEnvironment(); const env = pipeline.getEnvironment();
const config = pipeline.getConfig();
const { routeCache, logger } = env; const { routeCache, logger } = env;
let matches = matchAllRoutes(pathname, manifestData); 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({ const preloadedMatches = await getSortedPreloadedMatches({
pipeline, pipeline,
matches, matches,
@ -163,16 +165,65 @@ export async function handleRoute({
const config = pipeline.getConfig(); const config = pipeline.getConfig();
const moduleLoader = pipeline.getModuleLoader(); const moduleLoader = pipeline.getModuleLoader();
const { logger } = env; const { logger } = env;
if (!matchedRoute) { if (!matchedRoute && !config.experimental.i18n) {
return handle404Response(origin, incomingRequest, incomingResponse); return handle404Response(origin, incomingRequest, incomingResponse);
} }
const filePath: URL | undefined = matchedRoute.filePath;
const { route, preloadedComponent } = matchedRoute;
const buildingToSSR = isServerLikeOutput(config); const buildingToSSR = isServerLikeOutput(config);
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 (!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. // Headers are only available when using SSR.
const request = createRequest({ request = createRequest({
url, url,
headers: buildingToSSR ? incomingRequest.headers : new Headers(), headers: buildingToSSR ? incomingRequest.headers : new Headers(),
method: incomingRequest.method, method: incomingRequest.method,
@ -188,7 +239,7 @@ export async function handleRoute({
if (value) incomingResponse.setHeader(name, value); if (value) incomingResponse.setHeader(name, value);
} }
const options: SSROptions = { options = {
env, env,
filePath, filePath,
preload: preloadedComponent, preload: preloadedComponent,
@ -196,18 +247,18 @@ export async function handleRoute({
request, request,
route, route,
}; };
const middleware = await loadMiddleware(moduleLoader, settings.config.srcDir);
if (middleware) { if (middleware) {
options.middleware = middleware; options.middleware = middleware;
} }
const mod = options.preload;
mod = options.preload;
const { scripts, links, styles, metadata } = await getScriptsAndStyles({ const { scripts, links, styles, metadata } = await getScriptsAndStyles({
pipeline, pipeline,
filePath: options.filePath, filePath: options.filePath,
}); });
const renderContext = await createRenderContext({ renderContext = await createRenderContext({
request: options.request, request: options.request,
pathname: options.pathname, pathname: options.pathname,
scripts, scripts,
@ -218,15 +269,29 @@ export async function handleRoute({
mod, mod,
env, env,
}); });
const onRequest = options.middleware?.onRequest as MiddlewareEndpointHandler | undefined; }
const onRequest = middleware?.onRequest as MiddlewareEndpointHandler | undefined;
if (config.experimental.i18n) {
const i18Middleware = createI18nMiddleware(config, logger);
if (i18Middleware) {
if (onRequest) { if (onRequest) {
pipeline.setMiddlewareFunction(sequence(i18Middleware, onRequest));
} else {
pipeline.setMiddlewareFunction(i18Middleware);
}
} else if (onRequest) {
pipeline.setMiddlewareFunction(onRequest);
}
} else if (onRequest) {
pipeline.setMiddlewareFunction(onRequest); pipeline.setMiddlewareFunction(onRequest);
} }
let response = await pipeline.renderRoute(renderContext, mod); let response = await pipeline.renderRoute(renderContext, mod);
if (response.status === 404 && has404Route(manifestData)) { if (response.status === 404 && has404Route(manifestData)) {
const fourOhFourRoute = await matchRoute('/404', manifestData, pipeline); const fourOhFourRoute = await matchRoute('/404', manifestData, pipeline);
if (fourOhFourRoute?.route !== options.route) if (options && fourOhFourRoute?.route !== options.route)
return handleRoute({ return handleRoute({
...options, ...options,
matchedRoute: fourOhFourRoute, matchedRoute: fourOhFourRoute,

View file

@ -377,10 +377,9 @@ describe('Development Routing', () => {
expect(await response2.text()).includes('Hola mundo'); 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'); const response = await fixture.fetch('/it/start');
expect(response.status).to.equal(200); expect(response.status).to.equal(404);
expect(await response.text()).includes('Hello');
}); });
it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { 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'); 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'); const response = await fixture.fetch('/new-site/it/start');
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
expect(await response.text()).includes('Hello'); expect(await response.text()).includes('Hello');

View file

@ -7,10 +7,7 @@ export default defineConfig({
defaultLocale: 'en', defaultLocale: 'en',
locales: [ locales: [
'en', 'pt', 'it' 'en', 'pt', 'it'
], ]
fallback: {
'pt-BR': ['pt']
}
} }
} }
}) })

View file

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

View file

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

View file

@ -0,0 +1,18 @@
---
export function getStaticPaths() {
return [
{params: {id: '1'}, props: { content: "Hello world" }},
{params: {id: '2'}, props: { content: "Eat Something" }},
{params: {id: '3'}, props: { content: "How are you?" }},
];
}
const { content } = Astro.props;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

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

View file

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

View file

@ -0,0 +1,18 @@
---
export function getStaticPaths() {
return [
{params: {id: '1'}, props: { content: "Hola mundo" }},
{params: {id: '2'}, props: { content: "Eat Something" }},
{params: {id: '3'}, props: { content: "How are you?" }},
];
}
const { content } = Astro.props;
---
<html>
<head>
<title>Astro</title>
</head>
<body>
{content}
</body>
</html>

View file

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

View file

@ -6,10 +6,7 @@ export default defineConfig({
defaultLocale: 'en', defaultLocale: 'en',
locales: [ locales: [
'en', 'pt', 'it' 'en', 'pt', 'it'
], ]
fallback: {
'pt-BR': ['pt']
}
} }
}, },
base: "/new-site" base: "/new-site"

View file

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

View file

@ -2767,6 +2767,12 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../.. version: link:../../..
packages/astro/test/fixtures/i18n-routing-fallback:
dependencies:
astro:
specifier: workspace:*
version: link:../../..
packages/astro/test/fixtures/import-ts-with-js: packages/astro/test/fixtures/import-ts-with-js:
dependencies: dependencies:
astro: astro: