feat(i18n): default locale system for dev (#8628)
This commit is contained in:
parent
18223d9cde
commit
9f3f110268
23 changed files with 356 additions and 5 deletions
|
@ -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 & {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -32,5 +32,6 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
|
|||
redirectRoute: rawRouteData.redirectRoute
|
||||
? deserializeRouteData(rawRouteData.redirectRoute)
|
||||
: undefined,
|
||||
locale: undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -91,4 +91,6 @@ export default class DevPipeline extends Pipeline {
|
|||
async #handleEndpointResult(_: Request, response: Response): Promise<Response> {
|
||||
return response;
|
||||
}
|
||||
|
||||
async handleFallback() {}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
16
packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
vendored
Normal file
16
packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
8
packages/astro/test/fixtures/i18n-routing-base/package.json
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing-base/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/i18n-routing-base",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro
vendored
Normal 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>
|
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello
|
||||
</body>
|
||||
</html>
|
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello
|
||||
</body>
|
||||
</html>
|
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro
vendored
Normal 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>
|
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hola
|
||||
</body>
|
||||
</html>
|
16
packages/astro/test/fixtures/i18n-routing/astro.config.mjs
vendored
Normal file
16
packages/astro/test/fixtures/i18n-routing/astro.config.mjs
vendored
Normal 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"
|
||||
})
|
8
packages/astro/test/fixtures/i18n-routing/package.json
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/i18n-routing",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
18
packages/astro/test/fixtures/i18n-routing/src/pages/en/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing/src/pages/en/blog/[id].astro
vendored
Normal 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>
|
8
packages/astro/test/fixtures/i18n-routing/src/pages/en/start.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing/src/pages/en/start.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello
|
||||
</body>
|
||||
</html>
|
8
packages/astro/test/fixtures/i18n-routing/src/pages/index.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello
|
||||
</body>
|
||||
</html>
|
18
packages/astro/test/fixtures/i18n-routing/src/pages/pt/blog/[id].astro
vendored
Normal file
18
packages/astro/test/fixtures/i18n-routing/src/pages/pt/blog/[id].astro
vendored
Normal 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>
|
8
packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro
vendored
Normal file
8
packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
Hola
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue