astro-intl 1.0.4 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -2
- package/dist/__tests__/routing.test.d.ts +1 -0
- package/dist/__tests__/routing.test.js +265 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -3
- package/dist/middleware.d.ts +3 -0
- package/dist/middleware.js +43 -2
- package/dist/routing.d.ts +12 -0
- package/dist/routing.js +162 -0
- package/dist/store.d.ts +2 -1
- package/dist/store.js +36 -1
- package/dist/types/index.d.ts +8 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -13,6 +13,9 @@ Sistema de internacionalización simple y type-safe para Astro, inspirado en nex
|
|
|
13
13
|
- 🛡️ **Concurrency-safe**: Usa `AsyncLocalStorage` en SSR para aislar requests concurrentes
|
|
14
14
|
- 🌍 **Multi-runtime**: Compatible con Node.js, Cloudflare Workers y Deno
|
|
15
15
|
- ⚙️ **Default locale configurable**: Define tu locale por defecto desde las opciones
|
|
16
|
+
- 🗺️ **Routing localizado**: Define URLs traducidas por locale (`/es/sobre-nosotros` en vez de `/es/about`)
|
|
17
|
+
- 🔄 **Rewrites automáticos**: El middleware reescribe URLs traducidas a rutas canónicas del filesystem
|
|
18
|
+
- 🔗 **Generación de URLs**: `path()` y `switchLocalePath()` para construir y transformar URLs localizadas
|
|
16
19
|
|
|
17
20
|
## 📦 Instalación
|
|
18
21
|
|
|
@@ -249,7 +252,104 @@ const t = getTranslations<Messages>('nav');
|
|
|
249
252
|
</nav>
|
|
250
253
|
```
|
|
251
254
|
|
|
252
|
-
##
|
|
255
|
+
## �️ Routing Localizado
|
|
256
|
+
|
|
257
|
+
### Definir rutas traducidas
|
|
258
|
+
|
|
259
|
+
Crea un mapa de rutas con URLs traducidas por locale:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// src/i18n/routing.ts
|
|
263
|
+
export const routing = {
|
|
264
|
+
locales: ["en", "es"],
|
|
265
|
+
defaultLocale: "en",
|
|
266
|
+
routes: {
|
|
267
|
+
home: { en: "/", es: "/" },
|
|
268
|
+
about: { en: "/about", es: "/sobre-nosotros" },
|
|
269
|
+
blog: { en: "/blog/[slug]", es: "/blog/[slug]" },
|
|
270
|
+
shop: { en: "/shop/[category]/[id]", es: "/tienda/[category]/[id]" },
|
|
271
|
+
},
|
|
272
|
+
} as const;
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Con Middleware (recomendado)
|
|
276
|
+
|
|
277
|
+
Pasa las rutas al middleware. Este reescribe automáticamente URLs traducidas a las rutas canónicas del filesystem:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
// src/middleware.ts
|
|
281
|
+
import "@/i18n/request";
|
|
282
|
+
import { createIntlMiddleware } from "astro-intl/middleware";
|
|
283
|
+
import { routing } from "@/i18n/routing";
|
|
284
|
+
|
|
285
|
+
export const onRequest = createIntlMiddleware(routing);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Cuando un usuario visita `/es/sobre-nosotros`, el middleware lo reescribe a `/es/about` — que mapea a tu archivo `[lang]/about.astro`. Sin páginas duplicadas.
|
|
289
|
+
|
|
290
|
+
### Sin Middleware
|
|
291
|
+
|
|
292
|
+
Configura las rutas via las opciones de la integración:
|
|
293
|
+
|
|
294
|
+
```js
|
|
295
|
+
// astro.config.mjs
|
|
296
|
+
import { defineConfig } from "astro/config";
|
|
297
|
+
import astroIntl from "astro-intl";
|
|
298
|
+
|
|
299
|
+
export default defineConfig({
|
|
300
|
+
integrations: [
|
|
301
|
+
astroIntl({
|
|
302
|
+
defaultLocale: "en",
|
|
303
|
+
locales: ["en", "es"],
|
|
304
|
+
routes: {
|
|
305
|
+
about: { en: "/about", es: "/sobre-nosotros" },
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Sin middleware no hay rewrites automáticos. Crea wrappers ligeros para cada ruta traducida:
|
|
313
|
+
|
|
314
|
+
```astro
|
|
315
|
+
---
|
|
316
|
+
// src/pages/[lang]/sobre-nosotros.astro
|
|
317
|
+
export { default } from "./about.astro";
|
|
318
|
+
export { getStaticPaths } from "./about.astro";
|
|
319
|
+
---
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Generar URLs con `path()`
|
|
323
|
+
|
|
324
|
+
```astro
|
|
325
|
+
---
|
|
326
|
+
import { path } from "astro-intl/routing";
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
<a href={path("about")}>About</a>
|
|
330
|
+
<!-- locale "en" → /en/about -->
|
|
331
|
+
<!-- locale "es" → /es/sobre-nosotros -->
|
|
332
|
+
|
|
333
|
+
<a href={path("shop", { locale: "es", params: { category: "ropa", id: "42" } })}>
|
|
334
|
+
Ver producto
|
|
335
|
+
</a>
|
|
336
|
+
<!-- → /es/tienda/ropa/42 -->
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Cambiar locale con `switchLocalePath()`
|
|
340
|
+
|
|
341
|
+
```astro
|
|
342
|
+
---
|
|
343
|
+
import { switchLocalePath } from "astro-intl/routing";
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
<a href={switchLocalePath(Astro.url.pathname, "en")}>English</a>
|
|
347
|
+
<a href={switchLocalePath(Astro.url.pathname, "es")}>Español</a>
|
|
348
|
+
<!-- En /en/about → /es/sobre-nosotros -->
|
|
349
|
+
<!-- En /es/tienda/ropa/42 → /en/shop/ropa/42 -->
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## � API Reference
|
|
253
353
|
|
|
254
354
|
### `astroIntl(options?)`
|
|
255
355
|
|
|
@@ -260,6 +360,8 @@ Configura la integración en `astro.config.mjs`.
|
|
|
260
360
|
- `defaultLocale?: string` - Locale por defecto cuando la URL no tiene prefijo de idioma (default: `"en"`)
|
|
261
361
|
- `enabled?: boolean` - Habilitar/deshabilitar la integración (default: `true`)
|
|
262
362
|
- `messages?: MessagesConfig` - Mensajes de traducción estáticos o dinámicos
|
|
363
|
+
- `locales?: string[]` - Lista de locales soportados
|
|
364
|
+
- `routes?: RoutesMap` - Mapa de rutas traducidas por locale
|
|
263
365
|
|
|
264
366
|
### `setRequestLocale(url, getConfig?)`
|
|
265
367
|
|
|
@@ -345,6 +447,40 @@ Obtiene el locale actual configurado.
|
|
|
345
447
|
|
|
346
448
|
**Retorna:** `string` - El código del locale (ej: `'es'`, `'en'`)
|
|
347
449
|
|
|
450
|
+
### `createIntlMiddleware(options)`
|
|
451
|
+
|
|
452
|
+
Crea un middleware de Astro que llama automáticamente a `setRequestLocale` en cada request. Importar desde `astro-intl/middleware`.
|
|
453
|
+
|
|
454
|
+
**Opciones:**
|
|
455
|
+
|
|
456
|
+
- `locales: string[]` - Lista de locales soportados
|
|
457
|
+
- `defaultLocale?: string` - Locale por defecto (default: `"en"`)
|
|
458
|
+
- `routes?: RoutesMap` - Mapa de rutas traducidas. Cuando se proporciona, el middleware reescribe URLs traducidas a sus rutas canónicas del filesystem
|
|
459
|
+
|
|
460
|
+
### `path(routeKey, options?)`
|
|
461
|
+
|
|
462
|
+
Genera una URL localizada para una ruta nombrada. Importar desde `astro-intl/routing`.
|
|
463
|
+
|
|
464
|
+
**Parámetros:**
|
|
465
|
+
|
|
466
|
+
- `routeKey: string` - Nombre de la ruta (clave del mapa de `routes`)
|
|
467
|
+
- `options?.locale` - Locale destino (default: locale actual)
|
|
468
|
+
- `options?.params` - `Record<string, string>` para sustituir `[param]` en el template
|
|
469
|
+
- `options?.encode` - Codificar params con `encodeURIComponent` (default: `true`)
|
|
470
|
+
|
|
471
|
+
**Retorna:** `string` - URL localizada (ej: `"/es/sobre-nosotros"`)
|
|
472
|
+
|
|
473
|
+
### `switchLocalePath(currentPath, nextLocale)`
|
|
474
|
+
|
|
475
|
+
Convierte la URL actual a su equivalente en otro locale. Importar desde `astro-intl/routing`.
|
|
476
|
+
|
|
477
|
+
**Parámetros:**
|
|
478
|
+
|
|
479
|
+
- `currentPath: string | URL` - Ruta actual (pathname, URL string o URL object)
|
|
480
|
+
- `nextLocale: string` - Locale destino
|
|
481
|
+
|
|
482
|
+
**Retorna:** `string` - URL equivalente en el nuevo locale. Preserva query strings y hashes. Si no matchea ningún template, hace fallback a intercambiar el prefijo del locale.
|
|
483
|
+
|
|
348
484
|
---
|
|
349
485
|
|
|
350
486
|
## 🚀 Desarrollo (para contribuidores)
|
|
@@ -388,9 +524,11 @@ packages/integration/
|
|
|
388
524
|
│ ├── store.ts # Estado por request (AsyncLocalStorage + fallback)
|
|
389
525
|
│ ├── translations.ts # getTranslations y getTranslationsReact
|
|
390
526
|
│ ├── react.ts # Factory de t.rich() para React
|
|
527
|
+
│ ├── routing.ts # path(), switchLocalePath() — generación de URLs localizadas
|
|
528
|
+
│ ├── middleware.ts # createIntlMiddleware() con rewrites de rutas traducidas
|
|
391
529
|
│ ├── index.ts # Entry point público + integración de Astro
|
|
392
530
|
│ └── types/
|
|
393
|
-
│ └── index.ts # Tipos TypeScript
|
|
531
|
+
│ └── index.ts # Tipos TypeScript (incluye RoutesMap)
|
|
394
532
|
├── dist/ # Archivos compilados (generados)
|
|
395
533
|
│ ├── *.js # JavaScript compilado
|
|
396
534
|
│ └── *.d.ts # Declaraciones de tipos
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { __resetRequestConfig, __setIntlConfig, setRequestLocale } from "../core.js";
|
|
3
|
+
import { path, switchLocalePath, templateToRegex } from "../routing.js";
|
|
4
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
5
|
+
const ROUTES = {
|
|
6
|
+
home: { en: "/", es: "/" },
|
|
7
|
+
about: { en: "/about", es: "/sobre-nosotros" },
|
|
8
|
+
post: { en: "/blog/[slug]", es: "/blog/[slug]" },
|
|
9
|
+
shop: { en: "/shop/[category]/[id]", es: "/tienda/[category]/[id]" },
|
|
10
|
+
};
|
|
11
|
+
function setupConfig(opts) {
|
|
12
|
+
__setIntlConfig({
|
|
13
|
+
defaultLocale: "en",
|
|
14
|
+
locales: opts?.locales ?? ["en", "es"],
|
|
15
|
+
routes: opts?.routes ?? ROUTES,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function setLocale(locale) {
|
|
19
|
+
const url = new URL(`https://example.com/${locale}/page`);
|
|
20
|
+
const config = { locale, messages: {} };
|
|
21
|
+
await setRequestLocale(url, () => config);
|
|
22
|
+
}
|
|
23
|
+
// ─── templateToRegex ─────────────────────────────────────────────────
|
|
24
|
+
describe("templateToRegex()", () => {
|
|
25
|
+
it("converts template without params", () => {
|
|
26
|
+
const { regex, paramNames } = templateToRegex("/about");
|
|
27
|
+
expect(paramNames).toEqual([]);
|
|
28
|
+
expect(regex.test("/about")).toBe(true);
|
|
29
|
+
expect(regex.test("/other")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
it("converts template with one param", () => {
|
|
32
|
+
const { regex, paramNames } = templateToRegex("/blog/[slug]");
|
|
33
|
+
expect(paramNames).toEqual(["slug"]);
|
|
34
|
+
expect(regex.test("/blog/hello-world")).toBe(true);
|
|
35
|
+
expect(regex.test("/blog/")).toBe(false);
|
|
36
|
+
expect(regex.test("/blog/a/b")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it("converts template with multiple params", () => {
|
|
39
|
+
const { regex, paramNames } = templateToRegex("/shop/[category]/[id]");
|
|
40
|
+
expect(paramNames).toEqual(["category", "id"]);
|
|
41
|
+
expect(regex.test("/shop/clothing/42")).toBe(true);
|
|
42
|
+
expect(regex.test("/shop/clothing")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it("escapes special regex characters in template", () => {
|
|
45
|
+
const { regex } = templateToRegex("/file.html");
|
|
46
|
+
expect(regex.test("/file.html")).toBe(true);
|
|
47
|
+
expect(regex.test("/fileXhtml")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
it("caches compiled regex", () => {
|
|
50
|
+
const a = templateToRegex("/blog/[slug]");
|
|
51
|
+
const b = templateToRegex("/blog/[slug]");
|
|
52
|
+
expect(a).toBe(b);
|
|
53
|
+
});
|
|
54
|
+
it("matches root path", () => {
|
|
55
|
+
const { regex, paramNames } = templateToRegex("/");
|
|
56
|
+
expect(paramNames).toEqual([]);
|
|
57
|
+
expect(regex.test("/")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
// ─── path() ──────────────────────────────────────────────────────────
|
|
61
|
+
describe("path()", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
__resetRequestConfig();
|
|
64
|
+
});
|
|
65
|
+
it("generates simple path without params", async () => {
|
|
66
|
+
setupConfig();
|
|
67
|
+
await setLocale("en");
|
|
68
|
+
expect(path("about", { locale: "es" })).toBe("/es/sobre-nosotros");
|
|
69
|
+
});
|
|
70
|
+
it("generates path with params", async () => {
|
|
71
|
+
setupConfig();
|
|
72
|
+
await setLocale("en");
|
|
73
|
+
expect(path("post", { locale: "en", params: { slug: "hello-world" } })).toBe("/en/blog/hello-world");
|
|
74
|
+
});
|
|
75
|
+
it("generates path with multiple params", async () => {
|
|
76
|
+
setupConfig();
|
|
77
|
+
await setLocale("en");
|
|
78
|
+
expect(path("shop", { locale: "es", params: { category: "ropa", id: "42" } })).toBe("/es/tienda/ropa/42");
|
|
79
|
+
});
|
|
80
|
+
it("uses current locale when not specified", async () => {
|
|
81
|
+
setupConfig();
|
|
82
|
+
await setLocale("en");
|
|
83
|
+
expect(path("about", {})).toBe("/en/about");
|
|
84
|
+
});
|
|
85
|
+
it("uses current locale when options is empty", async () => {
|
|
86
|
+
setupConfig();
|
|
87
|
+
await setLocale("es");
|
|
88
|
+
expect(path("about")).toBe("/es/sobre-nosotros");
|
|
89
|
+
});
|
|
90
|
+
it("throws if routeKey does not exist", async () => {
|
|
91
|
+
setupConfig();
|
|
92
|
+
await setLocale("en");
|
|
93
|
+
expect(() => path("nonexistent", { locale: "en" })).toThrow(/Unknown route key/);
|
|
94
|
+
});
|
|
95
|
+
it("throws if locale has no template for route", async () => {
|
|
96
|
+
setupConfig({
|
|
97
|
+
routes: { about: { en: "/about" } },
|
|
98
|
+
locales: ["en", "es"],
|
|
99
|
+
});
|
|
100
|
+
await setLocale("en");
|
|
101
|
+
expect(() => path("about", { locale: "es" })).toThrow(/no template for locale/);
|
|
102
|
+
});
|
|
103
|
+
it("throws if routes not configured", async () => {
|
|
104
|
+
__setIntlConfig({ defaultLocale: "en", locales: ["en", "es"] });
|
|
105
|
+
await setLocale("en");
|
|
106
|
+
expect(() => path("about", { locale: "en" })).toThrow(/No routes configured/);
|
|
107
|
+
});
|
|
108
|
+
it("throws if params are missing", async () => {
|
|
109
|
+
setupConfig();
|
|
110
|
+
await setLocale("en");
|
|
111
|
+
expect(() => path("post", { locale: "en" })).toThrow(/Missing params/);
|
|
112
|
+
});
|
|
113
|
+
it("throws for invalid locale", async () => {
|
|
114
|
+
setupConfig();
|
|
115
|
+
await setLocale("en");
|
|
116
|
+
expect(() => path("about", { locale: "fr" })).toThrow(/Invalid locale/);
|
|
117
|
+
});
|
|
118
|
+
it("encodes params by default", async () => {
|
|
119
|
+
setupConfig();
|
|
120
|
+
await setLocale("en");
|
|
121
|
+
expect(path("post", { locale: "en", params: { slug: "hello world" } })).toBe("/en/blog/hello%20world");
|
|
122
|
+
});
|
|
123
|
+
it("skips encoding when encode is false", async () => {
|
|
124
|
+
setupConfig();
|
|
125
|
+
await setLocale("en");
|
|
126
|
+
expect(path("post", { locale: "en", params: { slug: "hello world" }, encode: false })).toBe("/en/blog/hello world");
|
|
127
|
+
});
|
|
128
|
+
it("handles root path template", async () => {
|
|
129
|
+
setupConfig();
|
|
130
|
+
await setLocale("en");
|
|
131
|
+
expect(path("home", { locale: "es" })).toBe("/es/");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// ─── switchLocalePath() ──────────────────────────────────────────────
|
|
135
|
+
describe("switchLocalePath()", () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
__resetRequestConfig();
|
|
138
|
+
});
|
|
139
|
+
it("switches locale on simple route", () => {
|
|
140
|
+
setupConfig();
|
|
141
|
+
expect(switchLocalePath("/en/about", "es")).toBe("/es/sobre-nosotros");
|
|
142
|
+
});
|
|
143
|
+
it("switches locale on route with params", () => {
|
|
144
|
+
setupConfig();
|
|
145
|
+
expect(switchLocalePath("/en/blog/my-post", "es")).toBe("/es/blog/my-post");
|
|
146
|
+
});
|
|
147
|
+
it("switches locale with multiple params", () => {
|
|
148
|
+
setupConfig();
|
|
149
|
+
expect(switchLocalePath("/en/shop/clothing/42", "es")).toBe("/es/tienda/clothing/42");
|
|
150
|
+
});
|
|
151
|
+
it("fallback: just swaps prefix when no route matches", () => {
|
|
152
|
+
setupConfig();
|
|
153
|
+
expect(switchLocalePath("/en/unknown/page", "es")).toBe("/es/unknown/page");
|
|
154
|
+
});
|
|
155
|
+
it("handles full URL input", () => {
|
|
156
|
+
setupConfig();
|
|
157
|
+
expect(switchLocalePath("https://example.com/en/about", "es")).toBe("/es/sobre-nosotros");
|
|
158
|
+
});
|
|
159
|
+
it("handles URL object input", () => {
|
|
160
|
+
setupConfig();
|
|
161
|
+
const url = new URL("https://example.com/en/about");
|
|
162
|
+
expect(switchLocalePath(url, "es")).toBe("/es/sobre-nosotros");
|
|
163
|
+
});
|
|
164
|
+
it("handles root path", () => {
|
|
165
|
+
setupConfig();
|
|
166
|
+
expect(switchLocalePath("/en/", "es")).toBe("/es/");
|
|
167
|
+
});
|
|
168
|
+
it("handles root path without trailing slash", () => {
|
|
169
|
+
setupConfig();
|
|
170
|
+
expect(switchLocalePath("/en", "es")).toBe("/es/");
|
|
171
|
+
});
|
|
172
|
+
it("preserves query params", () => {
|
|
173
|
+
setupConfig();
|
|
174
|
+
expect(switchLocalePath("/en/about?ref=nav", "es")).toBe("/es/sobre-nosotros?ref=nav");
|
|
175
|
+
});
|
|
176
|
+
it("preserves hash", () => {
|
|
177
|
+
setupConfig();
|
|
178
|
+
expect(switchLocalePath("/en/about#section", "es")).toBe("/es/sobre-nosotros#section");
|
|
179
|
+
});
|
|
180
|
+
it("preserves query + hash", () => {
|
|
181
|
+
setupConfig();
|
|
182
|
+
expect(switchLocalePath("/en/about?x=1#section", "es")).toBe("/es/sobre-nosotros?x=1#section");
|
|
183
|
+
});
|
|
184
|
+
it("works without routes configured (fallback only)", () => {
|
|
185
|
+
__setIntlConfig({ defaultLocale: "en", locales: ["en", "es"] });
|
|
186
|
+
expect(switchLocalePath("/en/about", "es")).toBe("/es/about");
|
|
187
|
+
});
|
|
188
|
+
it("prepends locale when path has no locale prefix", () => {
|
|
189
|
+
setupConfig();
|
|
190
|
+
expect(switchLocalePath("/about", "es")).toBe("/es/about");
|
|
191
|
+
});
|
|
192
|
+
it("throws for invalid nextLocale", () => {
|
|
193
|
+
setupConfig();
|
|
194
|
+
expect(() => switchLocalePath("/en/about", "fr")).toThrow(/Invalid locale/);
|
|
195
|
+
});
|
|
196
|
+
it("switches from es to en", () => {
|
|
197
|
+
setupConfig();
|
|
198
|
+
expect(switchLocalePath("/es/sobre-nosotros", "en")).toBe("/en/about");
|
|
199
|
+
});
|
|
200
|
+
it("switches es route with params to en", () => {
|
|
201
|
+
setupConfig();
|
|
202
|
+
expect(switchLocalePath("/es/tienda/ropa/42", "en")).toBe("/en/shop/ropa/42");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
// ─── detectRouteConflicts (via __setIntlConfig) ──────────────────────
|
|
206
|
+
describe("detectRouteConflicts", () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
__resetRequestConfig();
|
|
209
|
+
});
|
|
210
|
+
it("throws on exact duplicate templates", () => {
|
|
211
|
+
expect(() => __setIntlConfig({
|
|
212
|
+
locales: ["en"],
|
|
213
|
+
routes: {
|
|
214
|
+
blog: { en: "/blog/[slug]" },
|
|
215
|
+
news: { en: "/blog/[slug]" },
|
|
216
|
+
},
|
|
217
|
+
})).toThrow(/Duplicate route template/);
|
|
218
|
+
});
|
|
219
|
+
it("warns on structurally equivalent templates", () => {
|
|
220
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
221
|
+
__setIntlConfig({
|
|
222
|
+
locales: ["en"],
|
|
223
|
+
routes: {
|
|
224
|
+
blog: { en: "/blog/[slug]" },
|
|
225
|
+
news: { en: "/blog/[article]" },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Route conflict detected"));
|
|
229
|
+
warnSpy.mockRestore();
|
|
230
|
+
});
|
|
231
|
+
it("does not warn for distinct templates", () => {
|
|
232
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
233
|
+
__setIntlConfig({
|
|
234
|
+
locales: ["en"],
|
|
235
|
+
routes: {
|
|
236
|
+
blog: { en: "/blog/[slug]" },
|
|
237
|
+
about: { en: "/about" },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
241
|
+
warnSpy.mockRestore();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ─── Template validation ─────────────────────────────────────────────
|
|
245
|
+
describe("template validation", () => {
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
__resetRequestConfig();
|
|
248
|
+
});
|
|
249
|
+
it("throws on unbalanced brackets", async () => {
|
|
250
|
+
setupConfig({
|
|
251
|
+
routes: { bad: { en: "/blog/[slug", es: "/blog/[slug]" } },
|
|
252
|
+
locales: ["en", "es"],
|
|
253
|
+
});
|
|
254
|
+
await setLocale("en");
|
|
255
|
+
expect(() => path("bad", { locale: "en" })).toThrow(/Unbalanced brackets/);
|
|
256
|
+
});
|
|
257
|
+
it("throws on invalid param names", async () => {
|
|
258
|
+
setupConfig({
|
|
259
|
+
routes: { bad: { en: "/blog/[slug-name]", es: "/blog/[slug]" } },
|
|
260
|
+
locales: ["en", "es"],
|
|
261
|
+
});
|
|
262
|
+
await setLocale("en");
|
|
263
|
+
expect(() => path("bad", { locale: "en" })).toThrow(/Param names must match/);
|
|
264
|
+
});
|
|
265
|
+
});
|
package/dist/core.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { setRequestLocale, runWithLocale, getLocale, getLocales, isValidLocale,
|
|
|
2
2
|
export { getTranslations, getTranslationsReact } from "./translations.js";
|
|
3
3
|
export { getNestedValue, type DotPaths } from "./interpolation.js";
|
|
4
4
|
export { sanitizeLocale, sanitizeHtml, escapeRegExp } from "./sanitize.js";
|
|
5
|
+
export { path, switchLocalePath, templateToRegex } from "./routing.js";
|
package/dist/core.js
CHANGED
|
@@ -6,3 +6,5 @@ export { getTranslations, getTranslationsReact } from "./translations.js";
|
|
|
6
6
|
export { getNestedValue } from "./interpolation.js";
|
|
7
7
|
// ─── Sanitisation utilities ─────────────────────────────────────────
|
|
8
8
|
export { sanitizeLocale, sanitizeHtml, escapeRegExp } from "./sanitize.js";
|
|
9
|
+
// ─── Routing utilities ──────────────────────────────────────────────
|
|
10
|
+
export { path, switchLocalePath, templateToRegex } from "./routing.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { AstroIntegration } from "astro";
|
|
2
|
-
import type { MessagesConfig } from "./types/index.js";
|
|
3
|
-
import { setRequestLocale as _setRequestLocale, runWithLocale as _runWithLocale, getLocale as _getLocale, getLocales as _getLocales, isValidLocale as _isValidLocale, getMessages as _getMessages, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, defineRequestConfig as _defineRequestConfig, __resetRequestConfig as _resetRequestConfig } from "./core.js";
|
|
2
|
+
import type { MessagesConfig, RoutesMap } from "./types/index.js";
|
|
3
|
+
import { setRequestLocale as _setRequestLocale, runWithLocale as _runWithLocale, getLocale as _getLocale, getLocales as _getLocales, isValidLocale as _isValidLocale, getMessages as _getMessages, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, defineRequestConfig as _defineRequestConfig, __resetRequestConfig as _resetRequestConfig, path as _path, switchLocalePath as _switchLocalePath } from "./core.js";
|
|
4
4
|
export type AstroIntlOptions = {
|
|
5
5
|
enabled?: boolean;
|
|
6
6
|
defaultLocale?: string;
|
|
7
7
|
locales?: string[];
|
|
8
8
|
messages?: MessagesConfig;
|
|
9
|
+
routes?: RoutesMap;
|
|
9
10
|
};
|
|
10
11
|
export default function astroIntl(options?: AstroIntlOptions): AstroIntegration;
|
|
11
12
|
export declare const setRequestLocale: typeof _setRequestLocale;
|
|
@@ -18,5 +19,7 @@ export declare const getTranslations: typeof _getTranslations;
|
|
|
18
19
|
export declare const getTranslationsReact: typeof _getTranslationsReact;
|
|
19
20
|
export declare const defineRequestConfig: typeof _defineRequestConfig;
|
|
20
21
|
export declare const __resetRequestConfig: typeof _resetRequestConfig;
|
|
21
|
-
export
|
|
22
|
+
export declare const path: typeof _path;
|
|
23
|
+
export declare const switchLocalePath: typeof _switchLocalePath;
|
|
24
|
+
export type { RequestConfig, Primitive, GetRequestConfigFn, MessagesConfig, IntlConfig, RoutesMap, ExtractParams, ParamsForRoute, } from "./types/index.js";
|
|
22
25
|
export type { DotPaths } from "./core.js";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { setRequestLocale as _setRequestLocale, runWithLocale as _runWithLocale, getLocale as _getLocale, getLocales as _getLocales, isValidLocale as _isValidLocale, getMessages as _getMessages, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, defineRequestConfig as _defineRequestConfig, __resetRequestConfig as _resetRequestConfig, __setConfigMessages, __setIntlConfig, } from "./core.js";
|
|
1
|
+
import { setRequestLocale as _setRequestLocale, runWithLocale as _runWithLocale, getLocale as _getLocale, getLocales as _getLocales, isValidLocale as _isValidLocale, getMessages as _getMessages, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, defineRequestConfig as _defineRequestConfig, __resetRequestConfig as _resetRequestConfig, __setConfigMessages, __setIntlConfig, path as _path, switchLocalePath as _switchLocalePath, } from "./core.js";
|
|
2
2
|
export default function astroIntl(options = {}) {
|
|
3
|
-
const { enabled = true, defaultLocale, locales, messages } = options;
|
|
4
|
-
if (defaultLocale || locales) {
|
|
3
|
+
const { enabled = true, defaultLocale, locales, messages, routes } = options;
|
|
4
|
+
if (defaultLocale || locales || routes) {
|
|
5
5
|
__setIntlConfig({
|
|
6
6
|
...(defaultLocale && { defaultLocale }),
|
|
7
7
|
...(locales && { locales }),
|
|
8
|
+
...(routes && { routes }),
|
|
8
9
|
});
|
|
9
10
|
}
|
|
10
11
|
if (messages) {
|
|
@@ -40,3 +41,5 @@ export const getTranslations = _getTranslations;
|
|
|
40
41
|
export const getTranslationsReact = _getTranslationsReact;
|
|
41
42
|
export const defineRequestConfig = _defineRequestConfig;
|
|
42
43
|
export const __resetRequestConfig = _resetRequestConfig;
|
|
44
|
+
export const path = _path;
|
|
45
|
+
export const switchLocalePath = _switchLocalePath;
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { RoutesMap } from "./types/index.js";
|
|
1
2
|
export type IntlMiddlewareOptions = {
|
|
2
3
|
locales: readonly string[];
|
|
3
4
|
defaultLocale?: string;
|
|
5
|
+
routes?: RoutesMap;
|
|
4
6
|
};
|
|
5
7
|
export declare function createIntlMiddleware(options: IntlMiddlewareOptions): (context: {
|
|
6
8
|
url: URL;
|
|
7
9
|
request: Request;
|
|
10
|
+
rewrite: (path: string | URL) => Promise<Response>;
|
|
8
11
|
}, next: () => Promise<Response>) => Promise<Response>;
|
package/dist/middleware.js
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { setRequestLocale, __setIntlConfig } from "./store.js";
|
|
2
|
+
import { templateToRegex } from "./routing.js";
|
|
3
|
+
// ─── Route rewrite helper ────────────────────────────────────────────
|
|
4
|
+
// Given a pathname like /es/sobre-nosotros, checks if it matches a
|
|
5
|
+
// translated route template. If so, returns the canonical (defaultLocale)
|
|
6
|
+
// path that maps to the filesystem, e.g. /es/about.
|
|
7
|
+
// Returns null when no rewrite is needed.
|
|
8
|
+
function resolveTranslatedRoute(pathname, lang, routes, defaultLocale) {
|
|
9
|
+
const restPath = "/" + pathname.split("/").slice(2).join("/");
|
|
10
|
+
for (const routeKey of Object.keys(routes)) {
|
|
11
|
+
const entry = routes[routeKey];
|
|
12
|
+
const localTemplate = entry[lang];
|
|
13
|
+
if (!localTemplate)
|
|
14
|
+
continue;
|
|
15
|
+
const { regex, paramNames } = templateToRegex(localTemplate);
|
|
16
|
+
const match = restPath.match(regex);
|
|
17
|
+
if (!match)
|
|
18
|
+
continue;
|
|
19
|
+
// If this locale's template is the same as defaultLocale's, no rewrite needed
|
|
20
|
+
const canonicalTemplate = entry[defaultLocale];
|
|
21
|
+
if (!canonicalTemplate || canonicalTemplate === localTemplate)
|
|
22
|
+
return null;
|
|
23
|
+
// Extract params and substitute into the canonical template
|
|
24
|
+
const params = {};
|
|
25
|
+
paramNames.forEach((name, idx) => {
|
|
26
|
+
params[name] = match[idx + 1];
|
|
27
|
+
});
|
|
28
|
+
let canonical = canonicalTemplate;
|
|
29
|
+
canonical = canonical.replace(/\[(\w+)\]/g, (_m, name) => params[name] ?? `[${name}]`);
|
|
30
|
+
return `/${lang}${canonical}`;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
2
34
|
export function createIntlMiddleware(options) {
|
|
3
|
-
const { locales, defaultLocale } = options;
|
|
35
|
+
const { locales, defaultLocale: _defaultLocale, routes } = options;
|
|
36
|
+
const resolvedDefaultLocale = _defaultLocale ?? "en";
|
|
4
37
|
__setIntlConfig({
|
|
5
38
|
locales: [...locales],
|
|
6
|
-
...(
|
|
39
|
+
...(resolvedDefaultLocale && { defaultLocale: resolvedDefaultLocale }),
|
|
40
|
+
...(routes && { routes }),
|
|
7
41
|
});
|
|
8
42
|
return async (context, next) => {
|
|
9
43
|
const [, lang] = context.url.pathname.split("/");
|
|
@@ -11,6 +45,13 @@ export function createIntlMiddleware(options) {
|
|
|
11
45
|
return next();
|
|
12
46
|
}
|
|
13
47
|
await setRequestLocale(context.url);
|
|
48
|
+
// Rewrite translated routes to their canonical filesystem paths
|
|
49
|
+
if (routes) {
|
|
50
|
+
const rewrittenPath = resolveTranslatedRoute(context.url.pathname, lang, routes, resolvedDefaultLocale);
|
|
51
|
+
if (rewrittenPath) {
|
|
52
|
+
return context.rewrite(rewrittenPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
14
55
|
return next();
|
|
15
56
|
};
|
|
16
57
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type CompiledTemplate = {
|
|
2
|
+
regex: RegExp;
|
|
3
|
+
paramNames: string[];
|
|
4
|
+
};
|
|
5
|
+
export declare function templateToRegex(template: string): CompiledTemplate;
|
|
6
|
+
export declare function path(routeKey: string, options?: {
|
|
7
|
+
locale?: string;
|
|
8
|
+
params?: Record<string, string>;
|
|
9
|
+
encode?: boolean;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function switchLocalePath(currentPath: string | URL, nextLocale: string): string;
|
|
12
|
+
export {};
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { getRoutes, getDefaultLocale, getLocales, getLocale, isValidLocale } from "./store.js";
|
|
2
|
+
const regexCache = new Map();
|
|
3
|
+
// ─── Template validation ─────────────────────────────────────────────
|
|
4
|
+
const VALID_PARAM_REGEX = /^\w+$/;
|
|
5
|
+
const BRACKET_OPEN = /\[/g;
|
|
6
|
+
const BRACKET_CLOSE = /\]/g;
|
|
7
|
+
function validateTemplate(template, routeKey, locale) {
|
|
8
|
+
const opens = (template.match(BRACKET_OPEN) || []).length;
|
|
9
|
+
const closes = (template.match(BRACKET_CLOSE) || []).length;
|
|
10
|
+
if (opens !== closes) {
|
|
11
|
+
throw new Error(`[astro-intl] Invalid route template for "${routeKey}" (${locale}): "${template}". ` +
|
|
12
|
+
`Unbalanced brackets.`);
|
|
13
|
+
}
|
|
14
|
+
const params = [...template.matchAll(/\[([^\]]*)\]/g)];
|
|
15
|
+
for (const match of params) {
|
|
16
|
+
if (!VALID_PARAM_REGEX.test(match[1])) {
|
|
17
|
+
throw new Error(`[astro-intl] Invalid param name "[${match[1]}]" in route "${routeKey}" (${locale}): "${template}". ` +
|
|
18
|
+
`Param names must match /\\w+/.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ─── templateToRegex ─────────────────────────────────────────────────
|
|
23
|
+
export function templateToRegex(template) {
|
|
24
|
+
const cached = regexCache.get(template);
|
|
25
|
+
if (cached)
|
|
26
|
+
return cached;
|
|
27
|
+
const paramNames = [];
|
|
28
|
+
const pattern = template
|
|
29
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
30
|
+
.replace(/\\\[(\w+)\\\]/g, (_match, name) => {
|
|
31
|
+
paramNames.push(name);
|
|
32
|
+
return "([^/]+)";
|
|
33
|
+
});
|
|
34
|
+
const compiled = {
|
|
35
|
+
regex: new RegExp(`^${pattern}$`),
|
|
36
|
+
paramNames,
|
|
37
|
+
};
|
|
38
|
+
regexCache.set(template, compiled);
|
|
39
|
+
return compiled;
|
|
40
|
+
}
|
|
41
|
+
// ─── substituteParams ────────────────────────────────────────────────
|
|
42
|
+
function substituteParams(template, params, encode) {
|
|
43
|
+
let result = template;
|
|
44
|
+
const missing = [];
|
|
45
|
+
result = result.replace(/\[(\w+)\]/g, (_match, name) => {
|
|
46
|
+
if (!(name in params)) {
|
|
47
|
+
missing.push(name);
|
|
48
|
+
return `[${name}]`;
|
|
49
|
+
}
|
|
50
|
+
return encode ? encodeURIComponent(params[name]) : params[name];
|
|
51
|
+
});
|
|
52
|
+
if (missing.length > 0) {
|
|
53
|
+
throw new Error(`[astro-intl] Missing params for route template "${template}": ${missing.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
// ─── path() ──────────────────────────────────────────────────────────
|
|
58
|
+
export function path(routeKey, options = {}) {
|
|
59
|
+
const routes = getRoutes();
|
|
60
|
+
if (!routes) {
|
|
61
|
+
throw new Error("[astro-intl] No routes configured. Add routes to your astro-intl config.");
|
|
62
|
+
}
|
|
63
|
+
const routeEntry = routes[routeKey];
|
|
64
|
+
if (!routeEntry) {
|
|
65
|
+
throw new Error(`[astro-intl] Unknown route key: "${routeKey}". ` +
|
|
66
|
+
`Available keys: ${Object.keys(routes).join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
const locale = options.locale ?? getLocale();
|
|
69
|
+
if (getLocales().length > 0 && !isValidLocale(locale)) {
|
|
70
|
+
throw new Error(`[astro-intl] Invalid locale "${locale}" for path(). ` +
|
|
71
|
+
`Configured locales: ${getLocales().join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
const template = routeEntry[locale];
|
|
74
|
+
if (!template) {
|
|
75
|
+
throw new Error(`[astro-intl] Route "${routeKey}" has no template for locale "${locale}". ` +
|
|
76
|
+
`Available locales for this route: ${Object.keys(routeEntry).join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
validateTemplate(template, routeKey, locale);
|
|
79
|
+
const encode = options.encode !== false;
|
|
80
|
+
const resolvedPath = substituteParams(template, options.params ?? {}, encode);
|
|
81
|
+
return `/${locale}${resolvedPath}`;
|
|
82
|
+
}
|
|
83
|
+
// ─── switchLocalePath() ──────────────────────────────────────────────
|
|
84
|
+
export function switchLocalePath(currentPath, nextLocale) {
|
|
85
|
+
const locales = getLocales();
|
|
86
|
+
if (locales.length > 0 && !isValidLocale(nextLocale)) {
|
|
87
|
+
throw new Error(`[astro-intl] Invalid locale "${nextLocale}" for switchLocalePath(). ` +
|
|
88
|
+
`Configured locales: ${locales.join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
// Parse input
|
|
91
|
+
let pathname;
|
|
92
|
+
let suffix = "";
|
|
93
|
+
if (currentPath instanceof URL) {
|
|
94
|
+
pathname = currentPath.pathname;
|
|
95
|
+
suffix = (currentPath.search || "") + (currentPath.hash || "");
|
|
96
|
+
}
|
|
97
|
+
else if (currentPath.includes("://")) {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(currentPath);
|
|
100
|
+
pathname = url.pathname;
|
|
101
|
+
suffix = (url.search || "") + (url.hash || "");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
pathname = currentPath;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const qIdx = currentPath.indexOf("?");
|
|
109
|
+
const hIdx = currentPath.indexOf("#");
|
|
110
|
+
const splitIdx = qIdx >= 0 && hIdx >= 0 ? Math.min(qIdx, hIdx) : qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : -1;
|
|
111
|
+
if (splitIdx >= 0) {
|
|
112
|
+
pathname = currentPath.slice(0, splitIdx);
|
|
113
|
+
suffix = currentPath.slice(splitIdx);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
pathname = currentPath;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Detect current locale from pathname
|
|
120
|
+
const segments = pathname.split("/");
|
|
121
|
+
const currentLocaleSegment = segments[1] || "";
|
|
122
|
+
const defaultLocale = getDefaultLocale();
|
|
123
|
+
const currentLocale = locales.length > 0 && locales.includes(currentLocaleSegment)
|
|
124
|
+
? currentLocaleSegment
|
|
125
|
+
: currentLocaleSegment === defaultLocale
|
|
126
|
+
? defaultLocale
|
|
127
|
+
: null;
|
|
128
|
+
// No locale detected in path → prepend nextLocale
|
|
129
|
+
if (!currentLocale) {
|
|
130
|
+
return `/${nextLocale}${pathname}${suffix}`;
|
|
131
|
+
}
|
|
132
|
+
// Extract rest of path after locale segment
|
|
133
|
+
const restPath = "/" + segments.slice(2).join("/");
|
|
134
|
+
const routes = getRoutes();
|
|
135
|
+
// Try to match against route templates
|
|
136
|
+
if (routes) {
|
|
137
|
+
for (const routeKey of Object.keys(routes)) {
|
|
138
|
+
const routeEntry = routes[routeKey];
|
|
139
|
+
const currentTemplate = routeEntry[currentLocale];
|
|
140
|
+
if (!currentTemplate)
|
|
141
|
+
continue;
|
|
142
|
+
const { regex, paramNames } = templateToRegex(currentTemplate);
|
|
143
|
+
const match = restPath.match(regex);
|
|
144
|
+
if (match) {
|
|
145
|
+
const nextTemplate = routeEntry[nextLocale];
|
|
146
|
+
if (!nextTemplate) {
|
|
147
|
+
// No template for nextLocale → fallback
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
// Extract params from match
|
|
151
|
+
const params = {};
|
|
152
|
+
paramNames.forEach((name, idx) => {
|
|
153
|
+
params[name] = match[idx + 1];
|
|
154
|
+
});
|
|
155
|
+
const nextPath = substituteParams(nextTemplate, params, false);
|
|
156
|
+
return `/${nextLocale}${nextPath}${suffix}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Fallback: just swap locale prefix
|
|
161
|
+
return `/${nextLocale}${restPath}${suffix}`;
|
|
162
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { GetRequestConfigFn, MessagesConfig, IntlConfig, RequestConfig } from "./types/index.js";
|
|
1
|
+
import type { GetRequestConfigFn, MessagesConfig, IntlConfig, RequestConfig, RoutesMap } from "./types/index.js";
|
|
2
2
|
export declare function __setIntlConfig(config: Partial<IntlConfig>): void;
|
|
3
3
|
export declare function getDefaultLocale(): string;
|
|
4
|
+
export declare function getRoutes(): RoutesMap | undefined;
|
|
4
5
|
export declare function getLocales(): string[];
|
|
5
6
|
export declare function isValidLocale(locale: string): boolean;
|
|
6
7
|
export declare function defineRequestConfig(fn: (locale: string) => Promise<RequestConfig> | RequestConfig): GetRequestConfigFn;
|
package/dist/store.js
CHANGED
|
@@ -37,10 +37,17 @@ export function __setIntlConfig(config) {
|
|
|
37
37
|
if (config.locales) {
|
|
38
38
|
intlConfig = { ...intlConfig, locales: config.locales };
|
|
39
39
|
}
|
|
40
|
+
if (config.routes) {
|
|
41
|
+
intlConfig = { ...intlConfig, routes: config.routes };
|
|
42
|
+
detectRouteConflicts(config.routes);
|
|
43
|
+
}
|
|
40
44
|
}
|
|
41
45
|
export function getDefaultLocale() {
|
|
42
46
|
return intlConfig.defaultLocale;
|
|
43
47
|
}
|
|
48
|
+
export function getRoutes() {
|
|
49
|
+
return intlConfig.routes;
|
|
50
|
+
}
|
|
44
51
|
export function getLocales() {
|
|
45
52
|
return intlConfig.locales;
|
|
46
53
|
}
|
|
@@ -60,7 +67,35 @@ export function __resetRequestConfig() {
|
|
|
60
67
|
registeredGetRequestConfig = null;
|
|
61
68
|
configMessages = null;
|
|
62
69
|
fallbackState = null;
|
|
63
|
-
intlConfig = { defaultLocale: "en", locales: [] };
|
|
70
|
+
intlConfig = { defaultLocale: "en", locales: [], routes: undefined };
|
|
71
|
+
}
|
|
72
|
+
// ─── Route conflict detection ────────────────────────────────────────
|
|
73
|
+
function normalizeTemplate(template) {
|
|
74
|
+
return template.replace(/\[\w+\]/g, "[*]");
|
|
75
|
+
}
|
|
76
|
+
function detectRouteConflicts(routes) {
|
|
77
|
+
const keys = Object.keys(routes);
|
|
78
|
+
for (let i = 0; i < keys.length; i++) {
|
|
79
|
+
for (let j = i + 1; j < keys.length; j++) {
|
|
80
|
+
const a = routes[keys[i]];
|
|
81
|
+
const b = routes[keys[j]];
|
|
82
|
+
for (const locale of Object.keys(a)) {
|
|
83
|
+
if (!b[locale])
|
|
84
|
+
continue;
|
|
85
|
+
if (a[locale] === b[locale]) {
|
|
86
|
+
throw new Error(`[astro-intl] Duplicate route template for locale "${locale}": ` +
|
|
87
|
+
`"${keys[i]}" and "${keys[j]}" both use "${a[locale]}". ` +
|
|
88
|
+
`Each routeKey must have a unique template per locale.`);
|
|
89
|
+
}
|
|
90
|
+
if (normalizeTemplate(a[locale]) === normalizeTemplate(b[locale])) {
|
|
91
|
+
console.warn(`[astro-intl] ⚠️ Route conflict detected for locale "${locale}":\n` +
|
|
92
|
+
` "${keys[i]}" (${a[locale]})\n` +
|
|
93
|
+
` "${keys[j]}" (${b[locale]})\n` +
|
|
94
|
+
` Both templates match the same pattern. "${keys[i]}" will take priority.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
64
99
|
}
|
|
65
100
|
// ─── Resolve messages from MessagesConfig ───────────────────────────
|
|
66
101
|
async function resolveMessages(locale, source) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -7,7 +7,15 @@ export type GetRequestConfigFn = (locale: string) => Promise<RequestConfig> | Re
|
|
|
7
7
|
export type MessagesConfig = Record<string, Record<string, unknown> | (() => Promise<{
|
|
8
8
|
default: Record<string, unknown>;
|
|
9
9
|
} | Record<string, unknown>>)>;
|
|
10
|
+
export type RoutesMap = {
|
|
11
|
+
[routeKey: string]: {
|
|
12
|
+
[locale: string]: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type ExtractParams<T extends string> = T extends `${string}[${infer P}]${infer Rest}` ? P | ExtractParams<Rest> : never;
|
|
16
|
+
export type ParamsForRoute<Template extends string> = [ExtractParams<Template>] extends [never] ? Record<string, never> : Record<ExtractParams<Template>, string>;
|
|
10
17
|
export type IntlConfig = {
|
|
11
18
|
defaultLocale: string;
|
|
12
19
|
locales: string[];
|
|
20
|
+
routes?: RoutesMap;
|
|
13
21
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-intl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sistema de internacionalización simple y type-safe para Astro, inspirado en next-intl",
|
|
6
6
|
"keywords": [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"internationalization",
|
|
13
13
|
"intl",
|
|
14
14
|
"translations",
|
|
15
|
-
"typescript"
|
|
15
|
+
"typescript",
|
|
16
|
+
"accessibility"
|
|
16
17
|
],
|
|
17
18
|
"author": "Erick Cruz <erickj.cruzs@gmail.com>",
|
|
18
19
|
"license": "MIT",
|
|
@@ -41,6 +42,10 @@
|
|
|
41
42
|
"./middleware": {
|
|
42
43
|
"types": "./dist/middleware.d.ts",
|
|
43
44
|
"import": "./dist/middleware.js"
|
|
45
|
+
},
|
|
46
|
+
"./routing": {
|
|
47
|
+
"types": "./dist/routing.d.ts",
|
|
48
|
+
"import": "./dist/routing.js"
|
|
44
49
|
}
|
|
45
50
|
},
|
|
46
51
|
"peerDependencies": {
|