Skip to content

Commit b9e2e38

Browse files
committed
refactor: port to unit tests
1 parent 4d49632 commit b9e2e38

File tree

7 files changed

+1425
-2603
lines changed

7 files changed

+1425
-2603
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
2+
import type { APIContext } from '../types/public/context.js';
3+
import { getPathByLocale } from './index.js';
4+
5+
/**
6+
* Fallback routing decision types
7+
*/
8+
export type FallbackRouteResult =
9+
| { type: 'none' } // No fallback needed
10+
| { type: 'redirect'; pathname: string } // Redirect to fallback locale
11+
| { type: 'rewrite'; pathname: string }; // Rewrite to fallback locale
12+
13+
/**
14+
* Options for computing fallback routes.
15+
* Uses types from APIContext and SSRManifest to ensure type safety.
16+
*/
17+
export interface ComputeFallbackRouteOptions {
18+
/** Pathname from url.pathname */
19+
pathname: APIContext['url']['pathname'];
20+
/** Response status code */
21+
responseStatus: number;
22+
/** Current locale from APIContext */
23+
currentLocale: APIContext['currentLocale'];
24+
/** Fallback configuration from i18n manifest */
25+
fallback: NonNullable<SSRManifestI18n['fallback']>;
26+
/** Fallback type from i18n manifest */
27+
fallbackType: SSRManifestI18n['fallbackType'];
28+
/** Locales from i18n manifest */
29+
locales: SSRManifestI18n['locales'];
30+
/** Default locale from i18n manifest */
31+
defaultLocale: SSRManifestI18n['defaultLocale'];
32+
/** Routing strategy from i18n manifest */
33+
strategy: SSRManifestI18n['strategy'];
34+
/** Base path from manifest */
35+
base: SSRManifest['base'];
36+
}
37+
38+
/**
39+
* Compute fallback route for failed responses.
40+
* Pure function - no APIContext, no Response objects, no URL objects.
41+
*
42+
* This function determines whether a failed request should be redirected or rewritten
43+
* to a fallback locale based on the i18n configuration.
44+
*/
45+
export function computeFallbackRoute(options: ComputeFallbackRouteOptions): FallbackRouteResult {
46+
const {
47+
pathname,
48+
responseStatus,
49+
fallback,
50+
fallbackType,
51+
locales,
52+
defaultLocale,
53+
strategy,
54+
base,
55+
} = options;
56+
57+
// Only apply fallback for 3xx+ status codes
58+
if (responseStatus < 300) {
59+
return { type: 'none' };
60+
}
61+
62+
// No fallback configured
63+
if (!fallback || Object.keys(fallback).length === 0) {
64+
return { type: 'none' };
65+
}
66+
67+
// Extract locale from pathname
68+
const segments = pathname.split('/');
69+
const urlLocale = segments.find((segment) => {
70+
for (const locale of locales) {
71+
if (typeof locale === 'string') {
72+
if (locale === segment) {
73+
return true;
74+
}
75+
} else if (locale.path === segment) {
76+
return true;
77+
}
78+
}
79+
return false;
80+
});
81+
82+
// No locale found in pathname
83+
if (!urlLocale) {
84+
return { type: 'none' };
85+
}
86+
87+
// Check if this locale has a fallback configured
88+
const fallbackKeys = Object.keys(fallback);
89+
if (!fallbackKeys.includes(urlLocale)) {
90+
return { type: 'none' };
91+
}
92+
93+
// Get the fallback locale
94+
const fallbackLocale = fallback[urlLocale];
95+
96+
// Get the path for the fallback locale (handles granular locales)
97+
const pathFallbackLocale = getPathByLocale(fallbackLocale, locales);
98+
99+
let newPathname: string;
100+
101+
// If fallback is to the default locale and strategy is prefix-other-locales,
102+
// remove the locale prefix (default locale has no prefix)
103+
if (pathFallbackLocale === defaultLocale && strategy === 'pathname-prefix-other-locales') {
104+
if (pathname.includes(`${base}`)) {
105+
newPathname = pathname.replace(`/${urlLocale}`, ``);
106+
} else {
107+
newPathname = pathname.replace(`/${urlLocale}`, `/`);
108+
}
109+
} else {
110+
// Replace the current locale with the fallback locale
111+
newPathname = pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`);
112+
}
113+
114+
return {
115+
type: fallbackType,
116+
pathname: newPathname,
117+
};
118+
}
Lines changed: 80 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
1-
import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
1+
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
2+
import type { SSRManifest } from '../core/app/types.js';
3+
import { shouldAppendForwardSlash } from '../core/build/util.js';
24
import { REROUTE_DIRECTIVE_HEADER, ROUTE_TYPE_HEADER } from '../core/constants.js';
3-
import { isRequestServerIsland, requestIs404Or500 } from '../core/routing/match.js';
45
import type { MiddlewareHandler } from '../types/public/common.js';
5-
import type { APIContext } from '../types/public/context.js';
6-
import {
7-
type MiddlewarePayload,
8-
normalizeTheLocale,
9-
notFound,
10-
redirectToDefaultLocale,
11-
redirectToFallback,
12-
requestHasLocale,
13-
} from './index.js';
6+
import { computeFallbackRoute } from './fallback.js';
7+
import { I18nRouter, type I18nRouterContext } from './router.js';
148

159
export function createI18nMiddleware(
1610
i18n: SSRManifest['i18n'],
@@ -19,148 +13,102 @@ export function createI18nMiddleware(
1913
format: SSRManifest['buildFormat'],
2014
): MiddlewareHandler {
2115
if (!i18n) return (_, next) => next();
22-
const payload: MiddlewarePayload = {
23-
...i18n,
24-
trailingSlash,
25-
base,
26-
format,
27-
domains: {},
28-
};
29-
const _redirectToDefaultLocale = redirectToDefaultLocale(payload);
30-
const _noFoundForNonLocaleRoute = notFound(payload);
31-
const _requestHasLocale = requestHasLocale(payload.locales);
32-
const _redirectToFallback = redirectToFallback(payload);
33-
34-
const prefixAlways = (context: APIContext, response: Response): Response | undefined => {
35-
const url = context.url;
36-
if (url.pathname === base + '/' || url.pathname === base) {
37-
return _redirectToDefaultLocale(context);
38-
}
39-
40-
// Astro can't know where the default locale is supposed to be, so it returns a 404.
41-
else if (!_requestHasLocale(context)) {
42-
return _noFoundForNonLocaleRoute(context, response);
43-
}
44-
45-
return undefined;
46-
};
47-
48-
const prefixOtherLocales = (context: APIContext, response: Response): Response | undefined => {
49-
let pathnameContainsDefaultLocale = false;
50-
const url = context.url;
51-
for (const segment of url.pathname.split('/')) {
52-
if (normalizeTheLocale(segment) === normalizeTheLocale(i18n.defaultLocale)) {
53-
pathnameContainsDefaultLocale = true;
54-
break;
55-
}
56-
}
57-
if (pathnameContainsDefaultLocale) {
58-
const newLocation = url.pathname.replace(`/${i18n.defaultLocale}`, '');
59-
response.headers.set('Location', newLocation);
60-
return _noFoundForNonLocaleRoute(context);
61-
}
6216

63-
return undefined;
64-
};
17+
// Create router once during middleware initialization
18+
const i18nRouter = new I18nRouter({
19+
strategy: i18n.strategy,
20+
defaultLocale: i18n.defaultLocale,
21+
locales: i18n.locales,
22+
base,
23+
domains: i18n.domainLookupTable
24+
? Object.keys(i18n.domainLookupTable).reduce(
25+
(acc, domain) => {
26+
const locale = i18n.domainLookupTable[domain];
27+
if (!acc[domain]) {
28+
acc[domain] = [];
29+
}
30+
acc[domain].push(locale);
31+
return acc;
32+
},
33+
{} as Record<string, string[]>,
34+
)
35+
: undefined,
36+
});
6537

6638
return async (context, next) => {
6739
const response = await next();
68-
const type = response.headers.get(ROUTE_TYPE_HEADER);
40+
const typeHeader = response.headers.get(ROUTE_TYPE_HEADER);
6941

7042
// This is case where we are internally rendering a 404/500, so we need to bypass checks that were done already
7143
const isReroute = response.headers.get(REROUTE_DIRECTIVE_HEADER);
7244
if (isReroute === 'no' && typeof i18n.fallback === 'undefined') {
7345
return response;
7446
}
75-
// If the route we're processing is not a page, then we ignore it
76-
if (type !== 'page' && type !== 'fallback') {
77-
return response;
78-
}
7947

80-
// 404 and 500 are **known** routes (users can have their custom pages), so we need to let them be
81-
if (requestIs404Or500(context.request, base)) {
82-
return response;
83-
}
84-
85-
// This is a case where the rendering phase belongs to a server island. Server island are
86-
// special routes, and should be exhempt from i18n routing
87-
if (isRequestServerIsland(context.request, base)) {
48+
// If the route we're processing is not a page, then we ignore it
49+
if (typeHeader !== 'page' && typeHeader !== 'fallback') {
8850
return response;
8951
}
9052

91-
const { currentLocale } = context;
92-
switch (i18n.strategy) {
93-
// NOTE: theoretically, we should never hit this code path
94-
case 'manual': {
95-
return response;
96-
}
97-
case 'domains-prefix-other-locales': {
98-
if (localeHasntDomain(i18n, currentLocale)) {
99-
const result = prefixOtherLocales(context, response);
100-
if (result) {
101-
return result;
102-
}
53+
// Build context for router (typeHeader is guaranteed to be 'page' | 'fallback' here)
54+
const routerContext: I18nRouterContext = {
55+
currentLocale: context.currentLocale,
56+
currentDomain: context.url.hostname,
57+
routeType: typeHeader as 'page' | 'fallback',
58+
isReroute: isReroute === 'yes',
59+
};
60+
61+
// Step 1: Apply routing strategy
62+
const routeDecision = i18nRouter.match(context.url.pathname, routerContext);
63+
64+
switch (routeDecision.type) {
65+
case 'redirect': {
66+
// Apply trailing slash if needed
67+
let location = routeDecision.location;
68+
if (shouldAppendForwardSlash(trailingSlash, format)) {
69+
location = appendForwardSlash(location);
10370
}
104-
break;
71+
return context.redirect(location, routeDecision.status);
10572
}
106-
case 'pathname-prefix-other-locales': {
107-
const result = prefixOtherLocales(context, response);
108-
if (result) {
109-
return result;
73+
case 'notFound': {
74+
const notFoundRes = new Response(response.body, {
75+
status: 404,
76+
headers: response.headers,
77+
});
78+
notFoundRes.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
79+
if (routeDecision.location) {
80+
notFoundRes.headers.set('Location', routeDecision.location);
11081
}
111-
break;
112-
}
113-
114-
case 'domains-prefix-always-no-redirect': {
115-
if (localeHasntDomain(i18n, currentLocale)) {
116-
const result = _noFoundForNonLocaleRoute(context, response);
117-
if (result) {
118-
return result;
119-
}
120-
}
121-
break;
122-
}
123-
124-
case 'pathname-prefix-always-no-redirect': {
125-
const result = _noFoundForNonLocaleRoute(context, response);
126-
if (result) {
127-
return result;
128-
}
129-
break;
82+
return notFoundRes;
13083
}
84+
case 'continue':
85+
break; // Continue to fallback check
86+
}
13187

132-
case 'pathname-prefix-always': {
133-
const result = prefixAlways(context, response);
134-
if (result) {
135-
return result;
136-
}
137-
break;
138-
}
139-
case 'domains-prefix-always': {
140-
if (localeHasntDomain(i18n, currentLocale)) {
141-
const result = prefixAlways(context, response);
142-
if (result) {
143-
return result;
144-
}
145-
}
146-
break;
88+
// Step 2: Apply fallback logic (if configured)
89+
if (i18n.fallback && i18n.fallbackType) {
90+
const fallbackDecision = computeFallbackRoute({
91+
pathname: context.url.pathname,
92+
responseStatus: response.status,
93+
currentLocale: context.currentLocale,
94+
fallback: i18n.fallback,
95+
fallbackType: i18n.fallbackType,
96+
locales: i18n.locales,
97+
defaultLocale: i18n.defaultLocale,
98+
strategy: i18n.strategy,
99+
base,
100+
});
101+
102+
switch (fallbackDecision.type) {
103+
case 'redirect':
104+
return context.redirect(fallbackDecision.pathname + context.url.search);
105+
case 'rewrite':
106+
return await context.rewrite(fallbackDecision.pathname + context.url.search);
107+
case 'none':
108+
break;
147109
}
148110
}
149111

150-
return _redirectToFallback(context, response);
112+
return response;
151113
};
152114
}
153-
154-
/**
155-
* Checks if the current locale doesn't belong to a configured domain
156-
* @param i18n
157-
* @param currentLocale
158-
*/
159-
function localeHasntDomain(i18n: SSRManifestI18n, currentLocale: string | undefined) {
160-
for (const domainLocale of Object.values(i18n.domainLookupTable)) {
161-
if (domainLocale === currentLocale) {
162-
return false;
163-
}
164-
}
165-
return true;
166-
}

0 commit comments

Comments
 (0)