astro-intl 1.0.3 → 1.1.0
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/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 +37 -4
- package/dist/types/index.d.ts +8 -0
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -78,9 +113,7 @@ async function resolveMessages(locale, source) {
|
|
|
78
113
|
// ─── setRequestLocale ───────────────────────────────────────────────
|
|
79
114
|
export async function setRequestLocale(url, getConfig) {
|
|
80
115
|
const [, lang] = url.pathname.split("/");
|
|
81
|
-
if (!lang)
|
|
82
|
-
return false;
|
|
83
|
-
if (intlConfig.locales.length > 0 && !intlConfig.locales.includes(lang)) {
|
|
116
|
+
if (lang && intlConfig.locales.length > 0 && !intlConfig.locales.includes(lang)) {
|
|
84
117
|
return false;
|
|
85
118
|
}
|
|
86
119
|
const locale = sanitizeLocale(lang || intlConfig.defaultLocale);
|
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
|
};
|