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/dry-cars-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes the composable `i18n()` handler from `astro/fetch` and `astro/hono` returning an empty 404 for paths without a locale prefix. It now renders the custom 404 page, matching `astro()`.
10 changes: 6 additions & 4 deletions packages/astro/src/core/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { FetchState as BaseFetchState } from './fetch-state.js';
import type { AstroFetchState } from './fetch-state.js';
export type { AstroFetchState };
import { handleCache } from '../cache/handler.js';
import { finalizeI18n, getI18n } from '../i18n/handler.js';
import { handleI18nWithErrorFallback } from '../i18n/error-fallback.js';
import { getI18n } from '../i18n/handler.js';
import { getAmbientManifest } from '../manifest/ambient.js';
import { handleMiddlewareWithErrorFallback } from '../middleware/astro-middleware.js';
import { handlePagesWithErrorFallback } from '../pages/handler.js';
Expand Down Expand Up @@ -109,13 +110,14 @@ export function actions(state: FetchState): Promise<Response | undefined> | unde
/**
* Post-processes a response against the manifest's i18n configuration.
* Handles locale redirects, 404s for invalid locales, and fallback
* routing. Returns the response unmodified if i18n is not configured
* (or the routing strategy is `manual`).
* routing. A null-body 404 produced for an invalid locale path renders
* the 404 error page. Returns the response unmodified if i18n is not
* configured (or the routing strategy is `manual`).
*/
export function i18n(state: FetchState, response: Response): Promise<Response> {
const compiled = getI18n(state.manifest);
if (!compiled) return Promise.resolve(response);
return finalizeI18n(compiled, state, response);
return handleI18nWithErrorFallback(compiled, state, response);
}

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/astro/src/core/i18n/error-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { REROUTABLE_STATUS_CODES } from '../constants.js';
import { renderErrorFromState } from '../errors/handler.js';
import type { FetchState } from '../fetch/fetch-state.js';
import { type CompiledI18n, finalizeI18n } from './handler.js';

/**
* Runs {@link finalizeI18n} and, when it replaces the response with a
* null-body 404/500 (e.g. a locale-less path under `pathname-prefix-always`),
* renders the error page for it — the reroute `handleRequest` performs after
* `finalizeI18n` on the standard path. Responses passed through unchanged
* are returned as-is.
*
* Used by the composable `astro/fetch` `i18n()` entry point, where there is
* no surrounding `handleRequest` to supply this fallback. Kept out of
* `handler.ts` because that module is reachable from the client-importable
* `astro:i18n` virtual module, which must not pull in error-page rendering.
*/
export async function handleI18nWithErrorFallback(
compiled: CompiledI18n,
state: FetchState,
response: Response,
): Promise<Response> {
const result = await finalizeI18n(compiled, state, response);
if (
result !== response &&
result.body === null &&
REROUTABLE_STATUS_CODES.includes(result.status) &&
!state.skipErrorReroute
) {
return renderErrorFromState(state, state.request, {
...state.renderOptions,
response: result,
status: result.status as 404 | 500,
error: result.status === 500 ? null : undefined,
pathname: state.pathname,
});
}
return result;
}
35 changes: 34 additions & 1 deletion packages/astro/test/units/hono/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it } from 'node:test';
import { Hono } from 'hono';
import { FetchState } from '../../../dist/core/fetch/fetch-state.js';
import { setAmbientManifest } from '../../../dist/core/manifest/ambient.js';
import { astro, getFetchState } from '../../../dist/core/hono/index.js';
import { astro, getFetchState, i18n, pages } from '../../../dist/core/hono/index.js';
import { createComponent, render } from '../../../dist/runtime/server/index.js';
import { createPage, createTestApp } from '../mocks.ts';

Expand Down Expand Up @@ -105,3 +105,36 @@ describe('getFetchState()', () => {
assert.match(await response.text(), /<h1>set via getFetchState<\/h1>/);
});
});

describe('i18n() Hono middleware', () => {
it('renders the custom 404 page for a path without a locale under pathname-prefix-always', async () => {
const notFoundPage = createComponent(() => render`<h1>Custom 404</h1>`);
const hono = createHonoApp(
createTestApp(
[
createPage(page, { route: '/' }),
createPage(page, { route: '/about' }),
createPage(notFoundPage, { route: '/404' }),
],
{
i18n: {
defaultLocale: 'en',
locales: ['en', 'fr'],
strategy: 'pathname-prefix-always',
fallbackType: 'rewrite',
fallback: undefined,
domains: {},
domainLookupTable: {},
},
},
),
);
hono.use(i18n());
hono.use(pages());

const response = await hono.fetch(new Request('http://example.com/about'));

assert.equal(response.status, 404);
assert.match(await response.text(), /<h1>Custom 404<\/h1>/);
});
});
Loading