feat(i18n): default locale system for dev (#8628)

This commit is contained in:
Emanuele Stoppa 2023-09-22 16:57:36 +02:00
parent 18223d9cde
commit 9f3f110268
23 changed files with 356 additions and 5 deletions

View file

@ -1337,6 +1337,7 @@ export interface AstroUserConfig {
* @name experimental.i18n
* @type {object}
* @version 3.*.*
* @type {object}
* @description
*
* Allows to configure the beaviour of the i18n routing
@ -2206,6 +2207,7 @@ export interface RouteData {
prerender: boolean;
redirect?: RedirectConfig;
redirectRoute?: RouteData;
locale: string | undefined;
}
export type RedirectRouteData = RouteData & {

View file

@ -276,7 +276,7 @@ export const AstroConfigSchema = z.object({
.object({
defaultLocale: z.string(),
locales: z.string().array(),
fallback: z.record(z.string(), z.string().array()).optional(),
fallback: z.record(z.string(), z.string().array()).optional().default({}),
detectBrowserLanguage: z.boolean().optional().default(false),
})
.optional()

View file

@ -1,5 +1,5 @@
export { createRouteManifest } from './manifest/create.js';
export { deserializeRouteData, serializeRouteData } from './manifest/serialization.js';
export { matchAllRoutes, matchRoute } from './match.js';
export { matchAllRoutes, matchRoute, matchDefaultLocaleRoutes } from './match.js';
export { getParams } from './params.js';
export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js';

View file

@ -335,6 +335,11 @@ 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',
@ -345,6 +350,7 @@ export function createRouteManifest(
generate,
pathname: pathname || undefined,
prerender,
locale,
});
}
});
@ -407,6 +413,11 @@ 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
@ -421,6 +432,7 @@ export function createRouteManifest(
generate,
pathname: pathname || void 0,
prerender: prerenderInjected ?? prerender,
locale,
});
});
@ -448,6 +460,11 @@ 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,
@ -460,6 +477,7 @@ 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;

View file

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

View file

@ -1,4 +1,4 @@
import type { ManifestData, RouteData } from '../../@types/astro.js';
import type { AstroConfig, ManifestData, RouteData } from '../../@types/astro.js';
/** Find matching route from pathname */
export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined {
@ -9,3 +9,59 @@ 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;
}

View file

@ -91,4 +91,6 @@ export default class DevPipeline extends Pipeline {
async #handleEndpointResult(_: Request, response: Response): Promise<Response> {
return response;
}
async handleFallback() {}
}

View file

@ -11,7 +11,7 @@ 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 { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js';
import { matchAllRoutes, matchDefaultLocaleRoutes } from '../core/routing/index.js';
import { isPage } from '../core/util.js';
import { getSortedPreloadedMatches } from '../prerender/routing.js';
import { isServerLikeOutput } from '../prerender/utils.js';
@ -51,8 +51,14 @@ export async function matchRoute(
pipeline: DevPipeline
): Promise<MatchedRoute | undefined> {
const env = pipeline.getEnvironment();
const config = pipeline.getConfig();
const { routeCache, logger } = env;
const 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({
pipeline,
matches,

View file

@ -339,4 +339,102 @@ describe('Development Routing', () => {
expect(await response.text()).includes('html: 1');
});
});
describe('i18n routing', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
fixture = await loadFixture({
root: './fixtures/i18n-routing/',
});
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('should render the en locale', async () => {
const response = await fixture.fetch('/en/start');
expect(response.status).to.equal(200);
expect(await response.text()).includes('Hello');
const response2 = await fixture.fetch('/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('/pt/start');
expect(response.status).to.equal(200);
expect(await response.text()).includes('Hola');
const response2 = await fixture.fetch('/pt/blog/1');
expect(response2.status).to.equal(200);
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 () => {
const response = await fixture.fetch('/it/start');
expect(response.status).to.equal(200);
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 () => {
const response = await fixture.fetch('/fr/start');
expect(response.status).to.equal(404);
});
});
describe('i18n routing, with base', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
fixture = await loadFixture({
root: './fixtures/i18n-routing-base/',
});
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 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(200);
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 () => {
const response = await fixture.fetch('/new-site/fr/start');
expect(response.status).to.equal(404);
});
});
});

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: {
'pt-BR': ['pt']
}
}
}
})

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing-base",
"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

@ -0,0 +1,16 @@
import { defineConfig} from "astro/config";
export default defineConfig({
experimental: {
i18n: {
defaultLocale: 'en',
locales: [
'en', 'pt', 'it'
],
fallback: {
'pt-BR': ['pt']
}
}
},
base: "/new-site"
})

View file

@ -0,0 +1,8 @@
{
"name": "@test/i18n-routing",
"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>