Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/injected-routes-i18n-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a bug where page routes added with `injectRoute()` returned a 404 when `i18n.routing.prefixDefaultLocale` was `true` and the route path had no locale prefix
9 changes: 8 additions & 1 deletion packages/astro/src/core/i18n/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,21 @@ export async function finalizeI18n(
// raw request URL again, so locale checks use the same path as routing.
const url = state.url;
const currentLocale = state.computeCurrentLocale();
const isPrerendered = state.routeData!.prerender;
const routeData = state.routeData!;
const isPrerendered = routeData.prerender;

// Build context for router (responseRouteType is guaranteed to be 'page' | 'fallback' here)
const routerContext: I18nRouterContext = {
currentLocale,
currentDomain: url.hostname,
routeType: state.responseRouteType,
isReroute: false,
// Routes injected by integrations live in a URL space the site author
// can't move under a locale prefix (#18223). Injected routes whose first
// segment is dynamic (e.g. `/[locale]/...`) are treated as locale-aware
// and still have their locale validated.
allowUnprefixedPath:
routeData.origin === 'external' && !routeData.segments?.[0]?.some((part) => part.dynamic),
};

// Step 1: Apply routing strategy
Expand Down
13 changes: 9 additions & 4 deletions packages/astro/src/i18n/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface I18nRouterContext {
routeType?: 'page' | 'fallback';
/** Whether this is a reroute from response headers */
isReroute: boolean;
/**
* Whether a non-root path without a locale segment is served instead of
* returning 404 under the `prefix-always` strategies.
*/
allowUnprefixedPath?: boolean;
}

/**
Expand Down Expand Up @@ -146,7 +151,7 @@ export class I18nRouter {
* Strategy: pathname-prefix-always
* All locales must have a prefix, including the default locale.
*/
private matchPrefixAlways(pathname: string, _context: I18nRouterContext): I18nRouterMatch {
private matchPrefixAlways(pathname: string, context: I18nRouterContext): I18nRouterMatch {
const isRoot = pathname === this.#base + '/' || pathname === this.#base;

if (isRoot) {
Expand All @@ -160,7 +165,7 @@ export class I18nRouter {
}

// Check if pathname has a locale
if (!pathHasLocale(pathname, this.#locales)) {
if (!context.allowUnprefixedPath && !pathHasLocale(pathname, this.#locales)) {
return { type: 'notFound' };
}

Expand Down Expand Up @@ -199,7 +204,7 @@ export class I18nRouter {
*/
private matchPrefixAlwaysNoRedirect(
pathname: string,
_context: I18nRouterContext,
context: I18nRouterContext,
): I18nRouterMatch {
const isRoot = pathname === this.#base + '/' || pathname === this.#base;

Expand All @@ -209,7 +214,7 @@ export class I18nRouter {
}

// Non-root paths must have a locale
if (!pathHasLocale(pathname, this.#locales)) {
if (!context.allowUnprefixedPath && !pathHasLocale(pathname, this.#locales)) {
return { type: 'notFound' };
}

Expand Down
51 changes: 51 additions & 0 deletions packages/astro/test/units/i18n/i18n-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,57 @@ describe('createI18nMiddleware', () => {
assert.equal(await result.text(), 'en page');
});

it('passes through a non-locale-prefixed path for a route injected by an integration', async () => {
const ctx = createMockAPIContext({ url: 'http://localhost/_admin/settings' });
getFetchStateFromAPIContext(ctx).routeData!.origin = 'external';
const next = async () => makePageResponse('admin page');

const result = await callHandler(handler, ctx, next);

assert.equal(result.status, 200);
assert.equal(await result.text(), 'admin page');
});

it('returns null-body 404 for an invalid locale on an injected [locale] route', async () => {
const ctx = createMockAPIContext({ url: 'http://localhost/xx/page' });
const state = getFetchStateFromAPIContext(ctx);
state.routeData!.origin = 'external';
state.routeData!.segments = [
[{ content: 'locale', dynamic: true, spread: false }],
[{ content: 'page', dynamic: false, spread: false }],
];
const next = async () => makePageResponse('should not render');

const result = await callHandler(handler, ctx, next);

assert.equal(result.status, 404);
assert.equal(result.body, null);
});

it('redirects root / to /{defaultLocale}/ for an injected route', async () => {
const ctx = createMockAPIContext({ url: 'http://localhost/' });
const state = getFetchStateFromAPIContext(ctx);
state.routeData!.origin = 'external';
state.routeData!.segments = [];
const next = async () => makePageResponse('root');

const result = await callHandler(handler, ctx, next);

assert.equal(result.status, 302);
assert.ok(result.headers.get('Location')?.includes('/en'));
});

it('returns null-body 404 for a non-locale-prefixed path of a project route', async () => {
const ctx = createMockAPIContext({ url: 'http://localhost/_admin/settings' });
getFetchStateFromAPIContext(ctx).routeData!.origin = 'project';
const next = async () => makePageResponse('should not render');

const result = await callHandler(handler, ctx, next);

assert.equal(result.status, 404);
assert.equal(result.body, null);
});

it('redirects root / to /{defaultLocale}/', async () => {
const ctx = createMockAPIContext({ url: 'http://localhost/' });
const next = async () => makePageResponse('root');
Expand Down
Loading