astro-intl 1.0.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/README.md +295 -0
- package/dist/__tests__/core.test.d.ts +1 -0
- package/dist/__tests__/core.test.js +206 -0
- package/dist/__tests__/integration.test.d.ts +1 -0
- package/dist/__tests__/integration.test.js +261 -0
- package/dist/__tests__/react.test.d.ts +1 -0
- package/dist/__tests__/react.test.js +150 -0
- package/dist/core.d.ts +14 -0
- package/dist/core.js +94 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +20 -0
- package/dist/react.d.ts +5 -0
- package/dist/react.js +44 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +1 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# astro-intl
|
|
2
|
+
|
|
3
|
+
Sistema de internacionalización simple y type-safe para Astro, inspirado en next-intl.
|
|
4
|
+
|
|
5
|
+
## ✨ Características
|
|
6
|
+
|
|
7
|
+
- 🔒 **Type-safe**: Autocompletado y validación de claves de traducción con TypeScript
|
|
8
|
+
- 🎯 **API simple**: Inspirada en next-intl, fácil de usar
|
|
9
|
+
- ⚛️ **Soporte React**: Funciones específicas para componentes React con `t.rich()`
|
|
10
|
+
- 🎨 **Markup en traducciones**: Inserta HTML en strings con `t.markup()`
|
|
11
|
+
- 📁 **Namespaces**: Organiza traducciones por secciones
|
|
12
|
+
- 🌐 **Detección automática de locale**: Extrae el idioma desde la URL
|
|
13
|
+
|
|
14
|
+
## 📦 Instalación
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install astro-intl
|
|
18
|
+
# o
|
|
19
|
+
pnpm add astro-intl
|
|
20
|
+
# o
|
|
21
|
+
yarn add astro-intl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## ⚙️ Configuración
|
|
25
|
+
|
|
26
|
+
Agrega la integración en tu `astro.config.mjs`:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { defineConfig } from "astro/config";
|
|
30
|
+
import astroIntl from "astro-intl";
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
integrations: [
|
|
34
|
+
astroIntl({
|
|
35
|
+
enabled: true, // opcional, por defecto es true
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 🎯 Uso
|
|
42
|
+
|
|
43
|
+
### Estructura de archivos de traducción
|
|
44
|
+
|
|
45
|
+
Primero, crea tus archivos de traducción:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// src/i18n/es.json
|
|
49
|
+
{
|
|
50
|
+
"welcome": "Bienvenido",
|
|
51
|
+
"nav": {
|
|
52
|
+
"home": "Inicio",
|
|
53
|
+
"about": "Acerca de"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/i18n/en.json
|
|
58
|
+
{
|
|
59
|
+
"welcome": "Welcome",
|
|
60
|
+
"nav": {
|
|
61
|
+
"home": "Home",
|
|
62
|
+
"about": "About"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/i18n/index.ts
|
|
67
|
+
import es from './es.json';
|
|
68
|
+
import en from './en.json';
|
|
69
|
+
|
|
70
|
+
export const ui = { es, en };
|
|
71
|
+
export type Messages = typeof es;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### En componentes Astro
|
|
75
|
+
|
|
76
|
+
```astro
|
|
77
|
+
---
|
|
78
|
+
import { setRequestLocale, getTranslations } from 'astro-intl';
|
|
79
|
+
import { ui } from '../i18n';
|
|
80
|
+
|
|
81
|
+
// Configurar el locale para esta petición
|
|
82
|
+
await setRequestLocale(Astro.url, async (locale) => ({
|
|
83
|
+
locale,
|
|
84
|
+
messages: ui[locale as keyof typeof ui]
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// Obtener función de traducción
|
|
88
|
+
const t = getTranslations();
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
<h1>{t('welcome')}</h1>
|
|
92
|
+
<nav>
|
|
93
|
+
<a href="/">{t('nav.home')}</a>
|
|
94
|
+
<a href="/about">{t('nav.about')}</a>
|
|
95
|
+
</nav>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Traducciones con markup (HTML en strings)
|
|
99
|
+
|
|
100
|
+
```astro
|
|
101
|
+
---
|
|
102
|
+
// src/i18n/es.json
|
|
103
|
+
// { "terms": "Acepto los <link>términos y condiciones</link>" }
|
|
104
|
+
|
|
105
|
+
const t = getTranslations();
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
<p set:html={t.markup('terms', {
|
|
109
|
+
link: (chunks) => `<a href="/terms">${chunks}</a>`
|
|
110
|
+
})} />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### En componentes React
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { getTranslationsReact } from "astro-intl";
|
|
117
|
+
|
|
118
|
+
export function MyComponent() {
|
|
119
|
+
const t = getTranslationsReact();
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div>
|
|
123
|
+
<h1>{t("welcome")}</h1>
|
|
124
|
+
<nav>
|
|
125
|
+
<a href="/">{t("nav.home")}</a>
|
|
126
|
+
</nav>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Traducciones con componentes React (rich text)
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { getTranslationsReact } from "astro-intl";
|
|
136
|
+
|
|
137
|
+
export function MyComponent() {
|
|
138
|
+
const t = getTranslationsReact();
|
|
139
|
+
|
|
140
|
+
// src/i18n/es.json
|
|
141
|
+
// { "terms": "Acepto los <link>términos y condiciones</link>" }
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<p>
|
|
145
|
+
{t.rich("terms", {
|
|
146
|
+
link: (chunks) => <a href="/terms">{chunks}</a>,
|
|
147
|
+
})}
|
|
148
|
+
</p>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Type-safety con TypeScript
|
|
154
|
+
|
|
155
|
+
```astro
|
|
156
|
+
---
|
|
157
|
+
import { setRequestLocale, getTranslations } from 'astro-intl';
|
|
158
|
+
import { ui, type Messages } from '../i18n';
|
|
159
|
+
|
|
160
|
+
await setRequestLocale(Astro.url, async (locale) => ({
|
|
161
|
+
locale,
|
|
162
|
+
messages: ui[locale as keyof typeof ui]
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Tipado fuerte con autocompletado
|
|
166
|
+
const t = getTranslations<Messages>();
|
|
167
|
+
|
|
168
|
+
// TypeScript autocompletará las rutas válidas:
|
|
169
|
+
// t('nav.home') ✓
|
|
170
|
+
// t('nav.invalid') ✗ Error de TypeScript
|
|
171
|
+
---
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Usar namespaces
|
|
175
|
+
|
|
176
|
+
```astro
|
|
177
|
+
---
|
|
178
|
+
// Obtener solo un namespace específico
|
|
179
|
+
const t = getTranslations<Messages>('nav');
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
<nav>
|
|
183
|
+
<a href="/">{t('home')}</a> <!-- En lugar de t('nav.home') -->
|
|
184
|
+
<a href="/about">{t('about')}</a>
|
|
185
|
+
</nav>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## 📚 API Reference
|
|
189
|
+
|
|
190
|
+
### `setRequestLocale(url, getConfig)`
|
|
191
|
+
|
|
192
|
+
Configura el locale para la petición actual.
|
|
193
|
+
|
|
194
|
+
**Parámetros:**
|
|
195
|
+
|
|
196
|
+
- `url: URL` - El objeto URL de Astro (`Astro.url`)
|
|
197
|
+
- `getConfig: (locale: string) => RequestConfig | Promise<RequestConfig>` - Función que retorna la configuración
|
|
198
|
+
|
|
199
|
+
**Ejemplo:**
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
await setRequestLocale(Astro.url, async (locale) => ({
|
|
203
|
+
locale,
|
|
204
|
+
messages: ui[locale],
|
|
205
|
+
}));
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### `getTranslations<T>(namespace?)`
|
|
209
|
+
|
|
210
|
+
Obtiene la función de traducción para componentes Astro.
|
|
211
|
+
|
|
212
|
+
**Parámetros:**
|
|
213
|
+
|
|
214
|
+
- `namespace?: string` - Namespace opcional para obtener solo un subconjunto de traducciones
|
|
215
|
+
|
|
216
|
+
**Retorna:** Función `t(key)` con método `t.markup(key, tags)`
|
|
217
|
+
|
|
218
|
+
### `getTranslationsReact<T>(namespace?)`
|
|
219
|
+
|
|
220
|
+
Obtiene la función de traducción para componentes React.
|
|
221
|
+
|
|
222
|
+
**Parámetros:**
|
|
223
|
+
|
|
224
|
+
- `namespace?: string` - Namespace opcional
|
|
225
|
+
|
|
226
|
+
**Retorna:** Función `t(key)` con método `t.rich(key, tags)`
|
|
227
|
+
|
|
228
|
+
### `getLocale()`
|
|
229
|
+
|
|
230
|
+
Obtiene el locale actual configurado.
|
|
231
|
+
|
|
232
|
+
**Retorna:** `string` - El código del locale (ej: 'es', 'en')
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🚀 Desarrollo (para contribuidores)
|
|
237
|
+
|
|
238
|
+
### Compilar el paquete
|
|
239
|
+
|
|
240
|
+
Antes de usar el paquete en el playground o en cualquier proyecto, debes compilarlo:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npm run build
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Esto generará los archivos JavaScript y las declaraciones de tipos (`.d.ts`) en la carpeta `dist/`.
|
|
247
|
+
|
|
248
|
+
### Modo desarrollo
|
|
249
|
+
|
|
250
|
+
Para compilar automáticamente cuando hagas cambios:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npm run dev
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Después de compilar
|
|
257
|
+
|
|
258
|
+
Si estás trabajando en un monorepo con pnpm workspaces, después de compilar ejecuta:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
pnpm install
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Esto actualizará los enlaces simbólicos y los tipos estarán disponibles en los proyectos que usen el paquete.
|
|
265
|
+
|
|
266
|
+
## 📦 Estructura del Paquete
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
packages/integration/
|
|
270
|
+
├── src/
|
|
271
|
+
│ ├── core.ts # Lógica principal
|
|
272
|
+
│ ├── react.ts # Integración con React
|
|
273
|
+
│ ├── index.ts # Exports públicos
|
|
274
|
+
│ └── types/
|
|
275
|
+
│ └── index.ts # Tipos TypeScript
|
|
276
|
+
├── dist/ # Archivos compilados (generados)
|
|
277
|
+
│ ├── *.js # JavaScript compilado
|
|
278
|
+
│ └── *.d.ts # Declaraciones de tipos
|
|
279
|
+
├── package.json
|
|
280
|
+
└── tsconfig.json
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## 🔧 Configuración TypeScript
|
|
284
|
+
|
|
285
|
+
El paquete usa:
|
|
286
|
+
|
|
287
|
+
- `module: "Node16"` para soporte ESM completo
|
|
288
|
+
- `declaration: true` para generar archivos `.d.ts`
|
|
289
|
+
- Imports con extensión `.js` para compatibilidad ESM
|
|
290
|
+
|
|
291
|
+
## 📝 Notas Importantes
|
|
292
|
+
|
|
293
|
+
1. **Siempre compila antes de probar**: Los cambios en `src/` no se reflejan hasta que ejecutes `npm run build`
|
|
294
|
+
2. **Archivos dist/ en .gitignore**: Los archivos compilados no se suben a git, se generan en cada instalación
|
|
295
|
+
3. **Extensiones .js en imports**: Aunque el código fuente es TypeScript, los imports deben usar `.js` para compatibilidad con Node16/ESM
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { setRequestLocale, getLocale, getTranslations, getNestedValue, __resetRequestConfig, } from "../core.js";
|
|
3
|
+
describe("core.ts", () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
__resetRequestConfig();
|
|
6
|
+
});
|
|
7
|
+
describe("getNestedValue", () => {
|
|
8
|
+
it("should get nested values from object", () => {
|
|
9
|
+
const obj = {
|
|
10
|
+
user: {
|
|
11
|
+
name: "John",
|
|
12
|
+
address: {
|
|
13
|
+
city: "New York",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
expect(getNestedValue(obj, "user.name")).toBe("John");
|
|
18
|
+
expect(getNestedValue(obj, "user.address.city")).toBe("New York");
|
|
19
|
+
});
|
|
20
|
+
it("should return undefined for non-existent paths", () => {
|
|
21
|
+
const obj = { user: { name: "John" } };
|
|
22
|
+
expect(getNestedValue(obj, "user.age")).toBeUndefined();
|
|
23
|
+
expect(getNestedValue(obj, "nonexistent.path")).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
it("should block prototype pollution attempts", () => {
|
|
26
|
+
const obj = { user: { name: "John" } };
|
|
27
|
+
expect(getNestedValue(obj, "__proto__")).toBeUndefined();
|
|
28
|
+
expect(getNestedValue(obj, "constructor")).toBeUndefined();
|
|
29
|
+
expect(getNestedValue(obj, "prototype")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("setRequestLocale", () => {
|
|
33
|
+
it("should set locale from URL pathname", async () => {
|
|
34
|
+
const url = new URL("https://example.com/es/about");
|
|
35
|
+
const config = {
|
|
36
|
+
locale: "es",
|
|
37
|
+
messages: { greeting: "Hola" },
|
|
38
|
+
};
|
|
39
|
+
await setRequestLocale(url, () => config);
|
|
40
|
+
expect(getLocale()).toBe("es");
|
|
41
|
+
});
|
|
42
|
+
it("should default to 'en' when no locale in URL", async () => {
|
|
43
|
+
const url = new URL("https://example.com/");
|
|
44
|
+
const config = {
|
|
45
|
+
locale: "en",
|
|
46
|
+
messages: { greeting: "Hello" },
|
|
47
|
+
};
|
|
48
|
+
await setRequestLocale(url, () => config);
|
|
49
|
+
expect(getLocale()).toBe("en");
|
|
50
|
+
});
|
|
51
|
+
it("should accept async config function", async () => {
|
|
52
|
+
const url = new URL("https://example.com/fr/home");
|
|
53
|
+
const config = {
|
|
54
|
+
locale: "fr",
|
|
55
|
+
messages: { greeting: "Bonjour" },
|
|
56
|
+
};
|
|
57
|
+
await setRequestLocale(url, async () => {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
setTimeout(() => resolve(config), 10);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(getLocale()).toBe("fr");
|
|
63
|
+
});
|
|
64
|
+
it("should sanitize and validate locale", async () => {
|
|
65
|
+
const url = new URL("https://example.com/pt-BR/home");
|
|
66
|
+
const config = {
|
|
67
|
+
locale: "pt-BR",
|
|
68
|
+
messages: { greeting: "Olá" },
|
|
69
|
+
};
|
|
70
|
+
await setRequestLocale(url, () => config);
|
|
71
|
+
expect(getLocale()).toBe("pt-BR");
|
|
72
|
+
});
|
|
73
|
+
it("should throw error for invalid locale format", async () => {
|
|
74
|
+
const url = new URL("https://example.com/invalid@locale/home");
|
|
75
|
+
const config = {
|
|
76
|
+
locale: "invalid@locale",
|
|
77
|
+
messages: {},
|
|
78
|
+
};
|
|
79
|
+
await expect(setRequestLocale(url, () => config)).rejects.toThrow(/Invalid locale/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("getLocale", () => {
|
|
83
|
+
it("should throw error when called before setRequestLocale", () => {
|
|
84
|
+
expect(() => getLocale()).toThrow(/No request config found/);
|
|
85
|
+
});
|
|
86
|
+
it("should return current locale after setRequestLocale", async () => {
|
|
87
|
+
const url = new URL("https://example.com/de/page");
|
|
88
|
+
await setRequestLocale(url, () => ({
|
|
89
|
+
locale: "de",
|
|
90
|
+
messages: {},
|
|
91
|
+
}));
|
|
92
|
+
expect(getLocale()).toBe("de");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("getTranslations", () => {
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
const url = new URL("https://example.com/en/home");
|
|
98
|
+
await setRequestLocale(url, () => ({
|
|
99
|
+
locale: "en",
|
|
100
|
+
messages: {
|
|
101
|
+
common: {
|
|
102
|
+
greeting: "Hello",
|
|
103
|
+
farewell: "Goodbye",
|
|
104
|
+
nested: {
|
|
105
|
+
deep: "Deep value",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
home: {
|
|
109
|
+
title: "Welcome Home",
|
|
110
|
+
description: "This is the home page",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
it("should throw error when called before setRequestLocale", () => {
|
|
116
|
+
__resetRequestConfig();
|
|
117
|
+
expect(() => getTranslations()).toThrow(/No request config found/);
|
|
118
|
+
});
|
|
119
|
+
it("should get translations without namespace", () => {
|
|
120
|
+
const t = getTranslations();
|
|
121
|
+
expect(t("common.greeting")).toBe("Hello");
|
|
122
|
+
expect(t("home.title")).toBe("Welcome Home");
|
|
123
|
+
});
|
|
124
|
+
it("should get translations with namespace", () => {
|
|
125
|
+
const t = getTranslations("common");
|
|
126
|
+
expect(t("greeting")).toBe("Hello");
|
|
127
|
+
expect(t("farewell")).toBe("Goodbye");
|
|
128
|
+
});
|
|
129
|
+
it("should handle nested keys", () => {
|
|
130
|
+
const t = getTranslations("common");
|
|
131
|
+
expect(t("nested.deep")).toBe("Deep value");
|
|
132
|
+
});
|
|
133
|
+
it("should return key when translation not found", () => {
|
|
134
|
+
const t = getTranslations("common");
|
|
135
|
+
expect(t("nonexistent")).toBe("nonexistent");
|
|
136
|
+
});
|
|
137
|
+
describe("t.markup", () => {
|
|
138
|
+
it("should interpolate HTML tags", async () => {
|
|
139
|
+
const url = new URL("https://example.com/en/home");
|
|
140
|
+
await setRequestLocale(url, () => ({
|
|
141
|
+
locale: "en",
|
|
142
|
+
messages: {
|
|
143
|
+
text: "Click <link>here</link> to continue",
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
const t = getTranslations();
|
|
147
|
+
const result = t.markup("text", {
|
|
148
|
+
link: (chunks) => `<a href="/test">${chunks}</a>`,
|
|
149
|
+
});
|
|
150
|
+
expect(result).toBe('Click <a href="/test">here</a> to continue');
|
|
151
|
+
});
|
|
152
|
+
it("should handle multiple tags", async () => {
|
|
153
|
+
const url = new URL("https://example.com/en/home");
|
|
154
|
+
await setRequestLocale(url, () => ({
|
|
155
|
+
locale: "en",
|
|
156
|
+
messages: {
|
|
157
|
+
text: "Text with <bold>bold</bold> and <italic>italic</italic>",
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
const t = getTranslations();
|
|
161
|
+
const result = t.markup("text", {
|
|
162
|
+
bold: (chunks) => `<strong>${chunks}</strong>`,
|
|
163
|
+
italic: (chunks) => `<em>${chunks}</em>`,
|
|
164
|
+
});
|
|
165
|
+
expect(result).toBe("Text with <strong>bold</strong> and <em>italic</em>");
|
|
166
|
+
});
|
|
167
|
+
it("should sanitize dangerous HTML", async () => {
|
|
168
|
+
const url = new URL("https://example.com/en/home");
|
|
169
|
+
await setRequestLocale(url, () => ({
|
|
170
|
+
locale: "en",
|
|
171
|
+
messages: {
|
|
172
|
+
dangerous: "<script>alert('xss')</script>Safe text",
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
const t = getTranslations();
|
|
176
|
+
const result = t.markup("dangerous", {});
|
|
177
|
+
expect(result).not.toContain("<script>");
|
|
178
|
+
expect(result).toContain("Safe text");
|
|
179
|
+
});
|
|
180
|
+
it("should remove event handlers", async () => {
|
|
181
|
+
const url = new URL("https://example.com/en/home");
|
|
182
|
+
await setRequestLocale(url, () => ({
|
|
183
|
+
locale: "en",
|
|
184
|
+
messages: {
|
|
185
|
+
text: '<div onclick="alert()">Click</div>',
|
|
186
|
+
},
|
|
187
|
+
}));
|
|
188
|
+
const t = getTranslations();
|
|
189
|
+
const result = t.markup("text", {});
|
|
190
|
+
expect(result).not.toContain("onclick");
|
|
191
|
+
});
|
|
192
|
+
it("should remove javascript: URIs", async () => {
|
|
193
|
+
const url = new URL("https://example.com/en/home");
|
|
194
|
+
await setRequestLocale(url, () => ({
|
|
195
|
+
locale: "en",
|
|
196
|
+
messages: {
|
|
197
|
+
text: '<a href="javascript:alert()">Link</a>',
|
|
198
|
+
},
|
|
199
|
+
}));
|
|
200
|
+
const t = getTranslations();
|
|
201
|
+
const result = t.markup("text", {});
|
|
202
|
+
expect(result).not.toContain("javascript:");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { setRequestLocale, getLocale, getTranslations, getTranslationsReact, __resetRequestConfig, } from "../index.js";
|
|
3
|
+
describe("Integration Tests", () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
__resetRequestConfig();
|
|
6
|
+
});
|
|
7
|
+
describe("Complete workflow", () => {
|
|
8
|
+
it("should handle complete translation workflow", async () => {
|
|
9
|
+
const url = new URL("https://example.com/es/products");
|
|
10
|
+
const getRequestConfig = (locale) => {
|
|
11
|
+
const messages = {
|
|
12
|
+
en: {
|
|
13
|
+
common: {
|
|
14
|
+
greeting: "Hello",
|
|
15
|
+
welcome: "Welcome to our site",
|
|
16
|
+
},
|
|
17
|
+
products: {
|
|
18
|
+
title: "Our Products",
|
|
19
|
+
description: "Browse our amazing products",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
es: {
|
|
23
|
+
common: {
|
|
24
|
+
greeting: "Hola",
|
|
25
|
+
welcome: "Bienvenido a nuestro sitio",
|
|
26
|
+
},
|
|
27
|
+
products: {
|
|
28
|
+
title: "Nuestros Productos",
|
|
29
|
+
description: "Explora nuestros increíbles productos",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
locale,
|
|
35
|
+
messages: messages[locale],
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
await setRequestLocale(url, getRequestConfig);
|
|
39
|
+
expect(getLocale()).toBe("es");
|
|
40
|
+
const tCommon = getTranslations("common");
|
|
41
|
+
expect(tCommon("greeting")).toBe("Hola");
|
|
42
|
+
expect(tCommon("welcome")).toBe("Bienvenido a nuestro sitio");
|
|
43
|
+
const tProducts = getTranslations("products");
|
|
44
|
+
expect(tProducts("title")).toBe("Nuestros Productos");
|
|
45
|
+
expect(tProducts("description")).toBe("Explora nuestros increíbles productos");
|
|
46
|
+
});
|
|
47
|
+
it("should handle locale switching", async () => {
|
|
48
|
+
const getRequestConfig = (locale) => {
|
|
49
|
+
const messages = {
|
|
50
|
+
en: { greeting: "Hello" },
|
|
51
|
+
fr: { greeting: "Bonjour" },
|
|
52
|
+
de: { greeting: "Guten Tag" },
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
locale,
|
|
56
|
+
messages: messages[locale],
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const urlEn = new URL("https://example.com/en/page");
|
|
60
|
+
await setRequestLocale(urlEn, getRequestConfig);
|
|
61
|
+
expect(getLocale()).toBe("en");
|
|
62
|
+
expect(getTranslations()("greeting")).toBe("Hello");
|
|
63
|
+
const urlFr = new URL("https://example.com/fr/page");
|
|
64
|
+
await setRequestLocale(urlFr, getRequestConfig);
|
|
65
|
+
expect(getLocale()).toBe("fr");
|
|
66
|
+
expect(getTranslations()("greeting")).toBe("Bonjour");
|
|
67
|
+
const urlDe = new URL("https://example.com/de/page");
|
|
68
|
+
await setRequestLocale(urlDe, getRequestConfig);
|
|
69
|
+
expect(getLocale()).toBe("de");
|
|
70
|
+
expect(getTranslations()("greeting")).toBe("Guten Tag");
|
|
71
|
+
});
|
|
72
|
+
it("should work with deeply nested translations", async () => {
|
|
73
|
+
const url = new URL("https://example.com/en/page");
|
|
74
|
+
await setRequestLocale(url, () => ({
|
|
75
|
+
locale: "en",
|
|
76
|
+
messages: {
|
|
77
|
+
app: {
|
|
78
|
+
navigation: {
|
|
79
|
+
header: {
|
|
80
|
+
menu: {
|
|
81
|
+
home: "Home",
|
|
82
|
+
about: "About Us",
|
|
83
|
+
contact: "Contact",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
const t = getTranslations("app");
|
|
91
|
+
expect(t("navigation.header.menu.home")).toBe("Home");
|
|
92
|
+
expect(t("navigation.header.menu.about")).toBe("About Us");
|
|
93
|
+
expect(t("navigation.header.menu.contact")).toBe("Contact");
|
|
94
|
+
});
|
|
95
|
+
it("should handle markup with complex HTML", async () => {
|
|
96
|
+
const url = new URL("https://example.com/en/page");
|
|
97
|
+
await setRequestLocale(url, () => ({
|
|
98
|
+
locale: "en",
|
|
99
|
+
messages: {
|
|
100
|
+
legal: "By clicking continue, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>.",
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
const t = getTranslations();
|
|
104
|
+
const result = t.markup("legal", {
|
|
105
|
+
terms: (chunks) => `<a href="/terms" class="link">${chunks}</a>`,
|
|
106
|
+
privacy: (chunks) => `<a href="/privacy" class="link">${chunks}</a>`,
|
|
107
|
+
});
|
|
108
|
+
expect(result).toContain('<a href="/terms" class="link">Terms of Service</a>');
|
|
109
|
+
expect(result).toContain('<a href="/privacy" class="link">Privacy Policy</a>');
|
|
110
|
+
});
|
|
111
|
+
it("should handle React translations", async () => {
|
|
112
|
+
const url = new URL("https://example.com/en/page");
|
|
113
|
+
await setRequestLocale(url, () => ({
|
|
114
|
+
locale: "en",
|
|
115
|
+
messages: {
|
|
116
|
+
notification: "You have <count>5</count> new messages",
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
const t = getTranslationsReact();
|
|
120
|
+
const result = t.rich("notification", {
|
|
121
|
+
count: (chunks) => `<span class="badge">${chunks}</span>`,
|
|
122
|
+
});
|
|
123
|
+
expect(result).toEqual(["You have ", '<span class="badge">5</span>', " new messages"]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("Error handling", () => {
|
|
127
|
+
it("should throw descriptive error when accessing translations before setup", () => {
|
|
128
|
+
expect(() => getLocale()).toThrow("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
129
|
+
expect(() => getTranslations()).toThrow("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
130
|
+
expect(() => getTranslationsReact()).toThrow("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
131
|
+
});
|
|
132
|
+
it("should handle missing translations gracefully", async () => {
|
|
133
|
+
const url = new URL("https://example.com/en/page");
|
|
134
|
+
await setRequestLocale(url, () => ({
|
|
135
|
+
locale: "en",
|
|
136
|
+
messages: {
|
|
137
|
+
existing: "This exists",
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
const t = getTranslations();
|
|
141
|
+
expect(t("existing")).toBe("This exists");
|
|
142
|
+
expect(t("missing")).toBe("missing");
|
|
143
|
+
expect(t("deeply.nested.missing")).toBe("deeply.nested.missing");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("Security", () => {
|
|
147
|
+
it("should prevent XSS attacks in markup", async () => {
|
|
148
|
+
const url = new URL("https://example.com/en/page");
|
|
149
|
+
await setRequestLocale(url, () => ({
|
|
150
|
+
locale: "en",
|
|
151
|
+
messages: {
|
|
152
|
+
malicious: '<script>alert("XSS")</script><img src=x onerror="alert(1)">Safe text',
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
155
|
+
const t = getTranslations();
|
|
156
|
+
const result = t.markup("malicious", {});
|
|
157
|
+
expect(result).not.toContain("<script>");
|
|
158
|
+
expect(result).not.toContain("onerror");
|
|
159
|
+
expect(result).toContain("Safe text");
|
|
160
|
+
});
|
|
161
|
+
it("should prevent prototype pollution", async () => {
|
|
162
|
+
const url = new URL("https://example.com/en/page");
|
|
163
|
+
await setRequestLocale(url, () => ({
|
|
164
|
+
locale: "en",
|
|
165
|
+
messages: {
|
|
166
|
+
safe: "Safe value",
|
|
167
|
+
},
|
|
168
|
+
}));
|
|
169
|
+
const t = getTranslations();
|
|
170
|
+
expect(t("__proto__")).toBe("__proto__");
|
|
171
|
+
expect(t("constructor")).toBe("constructor");
|
|
172
|
+
expect(t("prototype")).toBe("prototype");
|
|
173
|
+
});
|
|
174
|
+
it("should sanitize javascript: URIs", async () => {
|
|
175
|
+
const url = new URL("https://example.com/en/page");
|
|
176
|
+
await setRequestLocale(url, () => ({
|
|
177
|
+
locale: "en",
|
|
178
|
+
messages: {
|
|
179
|
+
link: '<a href="javascript:alert(1)">Click me</a>',
|
|
180
|
+
},
|
|
181
|
+
}));
|
|
182
|
+
const t = getTranslations();
|
|
183
|
+
const result = t.markup("link", {});
|
|
184
|
+
expect(result).not.toContain("javascript:");
|
|
185
|
+
});
|
|
186
|
+
it("should sanitize data: URIs", async () => {
|
|
187
|
+
const url = new URL("https://example.com/en/page");
|
|
188
|
+
await setRequestLocale(url, () => ({
|
|
189
|
+
locale: "en",
|
|
190
|
+
messages: {
|
|
191
|
+
image: '<img src="data:text/html,<script>alert(1)</script>">',
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
const t = getTranslations();
|
|
195
|
+
const result = t.markup("image", {});
|
|
196
|
+
expect(result).not.toContain("data:");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe("Locale validation", () => {
|
|
200
|
+
it("should accept valid BCP-47 language tags", async () => {
|
|
201
|
+
const validLocales = ["en", "es", "pt-BR", "zh-CN", "en-US", "fr-CA"];
|
|
202
|
+
for (const locale of validLocales) {
|
|
203
|
+
const url = new URL(`https://example.com/${locale}/page`);
|
|
204
|
+
await setRequestLocale(url, (loc) => ({
|
|
205
|
+
locale: loc,
|
|
206
|
+
messages: {},
|
|
207
|
+
}));
|
|
208
|
+
expect(getLocale()).toBe(locale);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
it("should reject invalid locale formats", async () => {
|
|
212
|
+
const invalidLocales = ["invalid@locale", "123", "en_US", "en-", "-en", "en--US"];
|
|
213
|
+
for (const locale of invalidLocales) {
|
|
214
|
+
const url = new URL(`https://example.com/${locale}/page`);
|
|
215
|
+
await expect(setRequestLocale(url, () => ({
|
|
216
|
+
locale,
|
|
217
|
+
messages: {},
|
|
218
|
+
}))).rejects.toThrow(/Invalid locale/);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
describe("Performance", () => {
|
|
223
|
+
it("should handle large message objects efficiently", async () => {
|
|
224
|
+
const largeMessages = {};
|
|
225
|
+
for (let i = 0; i < 1000; i++) {
|
|
226
|
+
largeMessages[`key${i}`] = `Value ${i}`;
|
|
227
|
+
}
|
|
228
|
+
const url = new URL("https://example.com/en/page");
|
|
229
|
+
await setRequestLocale(url, () => ({
|
|
230
|
+
locale: "en",
|
|
231
|
+
messages: largeMessages,
|
|
232
|
+
}));
|
|
233
|
+
const t = getTranslations();
|
|
234
|
+
expect(t("key500")).toBe("Value 500");
|
|
235
|
+
expect(t("key999")).toBe("Value 999");
|
|
236
|
+
});
|
|
237
|
+
it("should handle multiple namespace access efficiently", async () => {
|
|
238
|
+
const url = new URL("https://example.com/en/page");
|
|
239
|
+
await setRequestLocale(url, () => ({
|
|
240
|
+
locale: "en",
|
|
241
|
+
messages: {
|
|
242
|
+
ns1: { key: "value1" },
|
|
243
|
+
ns2: { key: "value2" },
|
|
244
|
+
ns3: { key: "value3" },
|
|
245
|
+
ns4: { key: "value4" },
|
|
246
|
+
ns5: { key: "value5" },
|
|
247
|
+
},
|
|
248
|
+
}));
|
|
249
|
+
const t1 = getTranslations("ns1");
|
|
250
|
+
const t2 = getTranslations("ns2");
|
|
251
|
+
const t3 = getTranslations("ns3");
|
|
252
|
+
const t4 = getTranslations("ns4");
|
|
253
|
+
const t5 = getTranslations("ns5");
|
|
254
|
+
expect(t1("key")).toBe("value1");
|
|
255
|
+
expect(t2("key")).toBe("value2");
|
|
256
|
+
expect(t3("key")).toBe("value3");
|
|
257
|
+
expect(t4("key")).toBe("value4");
|
|
258
|
+
expect(t5("key")).toBe("value5");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createGetTranslationsReact } from "../react.js";
|
|
3
|
+
describe("react.ts", () => {
|
|
4
|
+
const ui = {
|
|
5
|
+
en: {
|
|
6
|
+
common: {
|
|
7
|
+
greeting: "Hello",
|
|
8
|
+
farewell: "Goodbye",
|
|
9
|
+
nested: {
|
|
10
|
+
deep: "Deep value",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
home: {
|
|
14
|
+
title: "Welcome Home",
|
|
15
|
+
withTags: "Click <link>here</link> to continue",
|
|
16
|
+
multipleTags: "Text with <bold>bold</bold> and <italic>italic</italic>",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
es: {
|
|
20
|
+
common: {
|
|
21
|
+
greeting: "Hola",
|
|
22
|
+
farewell: "Adiós",
|
|
23
|
+
nested: {
|
|
24
|
+
deep: "Valor profundo",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
home: {
|
|
28
|
+
title: "Bienvenido a Casa",
|
|
29
|
+
withTags: "Haz clic <link>aquí</link> para continuar",
|
|
30
|
+
multipleTags: "Texto con <bold>negrita</bold> y <italic>cursiva</italic>",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
describe("createGetTranslationsReact", () => {
|
|
35
|
+
it("should create translation function for default locale", () => {
|
|
36
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
37
|
+
const t = getT("en", "common");
|
|
38
|
+
expect(t("greeting")).toBe("Hello");
|
|
39
|
+
expect(t("farewell")).toBe("Goodbye");
|
|
40
|
+
});
|
|
41
|
+
it("should create translation function for non-default locale", () => {
|
|
42
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
43
|
+
const t = getT("es", "common");
|
|
44
|
+
expect(t("greeting")).toBe("Hola");
|
|
45
|
+
expect(t("farewell")).toBe("Adiós");
|
|
46
|
+
});
|
|
47
|
+
it("should handle nested keys", () => {
|
|
48
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
49
|
+
const t = getT("en", "common");
|
|
50
|
+
expect(t("nested.deep")).toBe("Deep value");
|
|
51
|
+
});
|
|
52
|
+
it("should return key when translation not found", () => {
|
|
53
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
54
|
+
const t = getT("en", "common");
|
|
55
|
+
expect(t("nonexistent")).toBe("nonexistent");
|
|
56
|
+
});
|
|
57
|
+
it("should fallback to default locale when locale not found", () => {
|
|
58
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
59
|
+
const t = getT("fr", "common");
|
|
60
|
+
expect(t("greeting")).toBe("Hello");
|
|
61
|
+
});
|
|
62
|
+
it("should work with different namespaces", () => {
|
|
63
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
64
|
+
const tCommon = getT("en", "common");
|
|
65
|
+
const tHome = getT("en", "home");
|
|
66
|
+
expect(tCommon("greeting")).toBe("Hello");
|
|
67
|
+
expect(tHome("title")).toBe("Welcome Home");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("t.rich", () => {
|
|
71
|
+
it("should interpolate React components", () => {
|
|
72
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
73
|
+
const t = getT("en", "home");
|
|
74
|
+
const result = t.rich("withTags", {
|
|
75
|
+
link: (chunks) => `<a>${chunks}</a>`,
|
|
76
|
+
});
|
|
77
|
+
expect(result).toEqual(["Click ", "<a>here</a>", " to continue"]);
|
|
78
|
+
});
|
|
79
|
+
it("should handle multiple tags", () => {
|
|
80
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
81
|
+
const t = getT("en", "home");
|
|
82
|
+
const result = t.rich("multipleTags", {
|
|
83
|
+
bold: (chunks) => `<strong>${chunks}</strong>`,
|
|
84
|
+
italic: (chunks) => `<em>${chunks}</em>`,
|
|
85
|
+
});
|
|
86
|
+
expect(result).toEqual(["Text with ", "<strong>bold</strong>", " and ", "<em>italic</em>"]);
|
|
87
|
+
});
|
|
88
|
+
it("should handle text without tags", () => {
|
|
89
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
90
|
+
const t = getT("en", "common");
|
|
91
|
+
const result = t.rich("greeting", {});
|
|
92
|
+
expect(result).toEqual(["Hello"]);
|
|
93
|
+
});
|
|
94
|
+
it("should work with Spanish locale", () => {
|
|
95
|
+
const getT = createGetTranslationsReact(ui, "en");
|
|
96
|
+
const t = getT("es", "home");
|
|
97
|
+
const result = t.rich("withTags", {
|
|
98
|
+
link: (chunks) => `<a>${chunks}</a>`,
|
|
99
|
+
});
|
|
100
|
+
expect(result).toEqual(["Haz clic ", "<a>aquí</a>", " para continuar"]);
|
|
101
|
+
});
|
|
102
|
+
it("should handle nested tags correctly", () => {
|
|
103
|
+
const customUi = {
|
|
104
|
+
en: {
|
|
105
|
+
test: {
|
|
106
|
+
nested: "Start <outer>outer <inner>inner</inner> outer</outer> end",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const getT = createGetTranslationsReact(customUi, "en");
|
|
111
|
+
const t = getT("en", "test");
|
|
112
|
+
const result = t.rich("nested", {
|
|
113
|
+
outer: (chunks) => `[${chunks}]`,
|
|
114
|
+
inner: (chunks) => `{${chunks}}`,
|
|
115
|
+
});
|
|
116
|
+
expect(result).toEqual(["Start ", "[outer {inner} outer]", " end"]);
|
|
117
|
+
});
|
|
118
|
+
it("should handle adjacent tags", () => {
|
|
119
|
+
const customUi = {
|
|
120
|
+
en: {
|
|
121
|
+
test: {
|
|
122
|
+
adjacent: "<tag1>First</tag1><tag2>Second</tag2>",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const getT = createGetTranslationsReact(customUi, "en");
|
|
127
|
+
const t = getT("en", "test");
|
|
128
|
+
const result = t.rich("adjacent", {
|
|
129
|
+
tag1: (chunks) => `[${chunks}]`,
|
|
130
|
+
tag2: (chunks) => `{${chunks}}`,
|
|
131
|
+
});
|
|
132
|
+
expect(result).toEqual(["[First]", "{Second}"]);
|
|
133
|
+
});
|
|
134
|
+
it("should handle empty chunks", () => {
|
|
135
|
+
const customUi = {
|
|
136
|
+
en: {
|
|
137
|
+
test: {
|
|
138
|
+
empty: "Text <tag></tag> more text",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const getT = createGetTranslationsReact(customUi, "en");
|
|
143
|
+
const t = getT("en", "test");
|
|
144
|
+
const result = t.rich("empty", {
|
|
145
|
+
tag: (chunks) => `[${chunks}]`,
|
|
146
|
+
});
|
|
147
|
+
expect(result).toEqual(["Text ", "[]", " more text"]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RequestConfig } from "./types/index.js";
|
|
2
|
+
export declare function __resetRequestConfig(): void;
|
|
3
|
+
export type DotPaths<T> = T extends object ? {
|
|
4
|
+
[K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${DotPaths<T[K]>}` : `${K}`;
|
|
5
|
+
}[keyof T & string] : never;
|
|
6
|
+
export declare function getNestedValue(obj: Record<string, unknown>, path: string): unknown;
|
|
7
|
+
export declare function setRequestLocale(url: URL, getConfig: (locale: string) => Promise<RequestConfig> | RequestConfig): Promise<void>;
|
|
8
|
+
export declare function getLocale(): string;
|
|
9
|
+
export declare function getTranslations<T extends Record<string, unknown> = Record<string, unknown>>(namespace?: string): ((key: DotPaths<T>) => string) & {
|
|
10
|
+
markup: (key: DotPaths<T>, tags: Record<string, (chunks: string) => string>) => string;
|
|
11
|
+
};
|
|
12
|
+
export declare function getTranslationsReact<T extends Record<string, unknown> = Record<string, unknown>>(namespace?: string): ((key: DotPaths<T>) => string) & {
|
|
13
|
+
rich: (key: DotPaths<T>, tags: Record<string, (chunks: string) => import("react").ReactNode>) => import("react").ReactNode[];
|
|
14
|
+
};
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createGetTranslationsReact } from "./react.js";
|
|
2
|
+
// --- Security helpers ---
|
|
3
|
+
const LOCALE_REGEX = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
4
|
+
function sanitizeLocale(locale) {
|
|
5
|
+
const trimmed = locale.trim();
|
|
6
|
+
if (!LOCALE_REGEX.test(trimmed)) {
|
|
7
|
+
throw new Error(`[astro-intl] Invalid locale "${trimmed}". Locale must be a valid BCP-47 language tag (e.g. "en", "es", "pt-BR").`);
|
|
8
|
+
}
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
function escapeRegExp(str) {
|
|
12
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
}
|
|
14
|
+
const DANGEROUS_HTML_REGEX = /<\s*\/?\s*(script|iframe|object|embed|form|input|textarea|button|select|meta|link|base|applet|style)\b[^>]*>/gi;
|
|
15
|
+
const EVENT_HANDLER_REGEX = /\s+on\w+\s*=\s*["'][^"']*["']/gi;
|
|
16
|
+
const JAVASCRIPT_URI_REGEX = /\b(href|src|action)\s*=\s*["']\s*javascript\s*:/gi;
|
|
17
|
+
const DATA_URI_REGEX = /\b(href|src|action)\s*=\s*["']\s*data\s*:/gi;
|
|
18
|
+
function sanitizeHtml(html) {
|
|
19
|
+
return html
|
|
20
|
+
.replace(DANGEROUS_HTML_REGEX, "")
|
|
21
|
+
.replace(EVENT_HANDLER_REGEX, "")
|
|
22
|
+
.replace(JAVASCRIPT_URI_REGEX, "")
|
|
23
|
+
.replace(DATA_URI_REGEX, "");
|
|
24
|
+
}
|
|
25
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
26
|
+
// Global context para almacenar la configuración del request actual
|
|
27
|
+
let globalRequestConfig = null;
|
|
28
|
+
// Función para resetear el config (solo para testing)
|
|
29
|
+
export function __resetRequestConfig() {
|
|
30
|
+
globalRequestConfig = null;
|
|
31
|
+
}
|
|
32
|
+
export function getNestedValue(obj, path) {
|
|
33
|
+
return path.split(".").reduce((acc, key) => {
|
|
34
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
35
|
+
return undefined;
|
|
36
|
+
if (acc && typeof acc === "object") {
|
|
37
|
+
return acc[key];
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}, obj);
|
|
41
|
+
}
|
|
42
|
+
// Configurar el request actual
|
|
43
|
+
export async function setRequestLocale(url, getConfig) {
|
|
44
|
+
const [, lang] = url.pathname.split("/");
|
|
45
|
+
const locale = sanitizeLocale(lang || "en");
|
|
46
|
+
const config = await getConfig(locale);
|
|
47
|
+
globalRequestConfig = {
|
|
48
|
+
locale: config.locale,
|
|
49
|
+
messages: config.messages,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Obtener el locale actual
|
|
53
|
+
export function getLocale() {
|
|
54
|
+
if (!globalRequestConfig) {
|
|
55
|
+
throw new Error("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
56
|
+
}
|
|
57
|
+
return globalRequestConfig.locale;
|
|
58
|
+
}
|
|
59
|
+
// Obtener traducciones sin pasar locale
|
|
60
|
+
export function getTranslations(namespace) {
|
|
61
|
+
if (!globalRequestConfig) {
|
|
62
|
+
throw new Error("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
63
|
+
}
|
|
64
|
+
const messages = namespace
|
|
65
|
+
? globalRequestConfig.messages[namespace]
|
|
66
|
+
: globalRequestConfig.messages;
|
|
67
|
+
function t(key) {
|
|
68
|
+
const value = getNestedValue(messages, key);
|
|
69
|
+
return typeof value === "string" ? value : key;
|
|
70
|
+
}
|
|
71
|
+
const markup = function (key, tags) {
|
|
72
|
+
let str = t(key);
|
|
73
|
+
for (const [tag, fn] of Object.entries(tags)) {
|
|
74
|
+
const escaped = escapeRegExp(tag);
|
|
75
|
+
const regex = new RegExp(`<${escaped}>(.*?)</${escaped}>`, "g");
|
|
76
|
+
str = str.replace(regex, (_match, chunks) => fn(chunks));
|
|
77
|
+
}
|
|
78
|
+
str = sanitizeHtml(str);
|
|
79
|
+
return str;
|
|
80
|
+
};
|
|
81
|
+
Object.assign(t, { markup });
|
|
82
|
+
return t;
|
|
83
|
+
}
|
|
84
|
+
// Obtener traducciones para React
|
|
85
|
+
export function getTranslationsReact(namespace) {
|
|
86
|
+
if (!globalRequestConfig) {
|
|
87
|
+
throw new Error("[astro-intl] No request config found. Did you call setRequestLocale()?");
|
|
88
|
+
}
|
|
89
|
+
const messages = namespace
|
|
90
|
+
? globalRequestConfig.messages[namespace]
|
|
91
|
+
: globalRequestConfig.messages;
|
|
92
|
+
const ui = { [globalRequestConfig.locale]: { default: messages } };
|
|
93
|
+
return createGetTranslationsReact(ui, globalRequestConfig.locale)(globalRequestConfig.locale, "default");
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
import { setRequestLocale as _setRequestLocale, getLocale as _getLocale, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, __resetRequestConfig as _resetRequestConfig } from "./core.js";
|
|
3
|
+
export type MyIntegrationOptions = {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
};
|
|
6
|
+
export default function myIntegration(options?: MyIntegrationOptions): AstroIntegration;
|
|
7
|
+
export declare const setRequestLocale: typeof _setRequestLocale;
|
|
8
|
+
export declare const getLocale: typeof _getLocale;
|
|
9
|
+
export declare const getTranslations: typeof _getTranslations;
|
|
10
|
+
export declare const getTranslationsReact: typeof _getTranslationsReact;
|
|
11
|
+
export declare const __resetRequestConfig: typeof _resetRequestConfig;
|
|
12
|
+
export type { RequestConfig } from "./types/index.js";
|
|
13
|
+
export type { DotPaths } from "./core.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { setRequestLocale as _setRequestLocale, getLocale as _getLocale, getTranslations as _getTranslations, getTranslationsReact as _getTranslationsReact, __resetRequestConfig as _resetRequestConfig, } from "./core.js";
|
|
2
|
+
export default function myIntegration(options = {}) {
|
|
3
|
+
const { enabled = true } = options;
|
|
4
|
+
return {
|
|
5
|
+
name: "astro-intl",
|
|
6
|
+
hooks: {
|
|
7
|
+
"astro:config:setup": ({ logger }) => {
|
|
8
|
+
if (!enabled)
|
|
9
|
+
return;
|
|
10
|
+
logger.info("[astro-intl] loaded");
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// Exportar API
|
|
16
|
+
export const setRequestLocale = _setRequestLocale;
|
|
17
|
+
export const getLocale = _getLocale;
|
|
18
|
+
export const getTranslations = _getTranslations;
|
|
19
|
+
export const getTranslationsReact = _getTranslationsReact;
|
|
20
|
+
export const __resetRequestConfig = _resetRequestConfig;
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type DotPaths } from "./core.js";
|
|
3
|
+
export declare function createGetTranslationsReact<UI extends Record<string, Record<string, unknown>>, DefaultLocale extends keyof UI>(ui: UI, defaultLocale: DefaultLocale): <N extends keyof UI[DefaultLocale]>(lang: string | undefined, namespace: N) => ((key: DotPaths<UI[DefaultLocale][N]>) => string) & {
|
|
4
|
+
rich: (key: DotPaths<UI[DefaultLocale][N]>, tags: Record<string, (chunks: string) => ReactNode>) => ReactNode[];
|
|
5
|
+
};
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getNestedValue } from "./core.js";
|
|
2
|
+
function escapeRegExp(str) {
|
|
3
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
export function createGetTranslationsReact(ui, defaultLocale) {
|
|
6
|
+
return function getTranslationsReact(lang, namespace) {
|
|
7
|
+
const resolvedLang = lang && lang in ui ? lang : defaultLocale;
|
|
8
|
+
const messages = ui[resolvedLang][namespace];
|
|
9
|
+
function t(key) {
|
|
10
|
+
const value = getNestedValue(messages, key);
|
|
11
|
+
return typeof value === "string" ? value : key;
|
|
12
|
+
}
|
|
13
|
+
const rich = function (key, tags) {
|
|
14
|
+
const str = t(key);
|
|
15
|
+
// Función recursiva para procesar tags anidados
|
|
16
|
+
function processString(input) {
|
|
17
|
+
const tagNames = Object.keys(tags).map(escapeRegExp);
|
|
18
|
+
const regex = new RegExp(`<(${tagNames.join("|")})>(.*?)<\\/(\\1)>`, "g");
|
|
19
|
+
const result = [];
|
|
20
|
+
let lastIndex = 0;
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = regex.exec(input)) !== null) {
|
|
23
|
+
if (match.index > lastIndex) {
|
|
24
|
+
result.push(input.slice(lastIndex, match.index));
|
|
25
|
+
}
|
|
26
|
+
const [, tag, chunks] = match;
|
|
27
|
+
// Procesar recursivamente el contenido del tag
|
|
28
|
+
const processedChunks = processString(chunks);
|
|
29
|
+
const chunksAsString = processedChunks
|
|
30
|
+
.map((chunk) => (typeof chunk === "string" ? chunk : ""))
|
|
31
|
+
.join("");
|
|
32
|
+
result.push(tags[tag](chunksAsString));
|
|
33
|
+
lastIndex = match.index + match[0].length;
|
|
34
|
+
}
|
|
35
|
+
if (lastIndex < input.length)
|
|
36
|
+
result.push(input.slice(lastIndex));
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
return processString(str);
|
|
40
|
+
};
|
|
41
|
+
Object.assign(t, { rich });
|
|
42
|
+
return t;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-intl",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Sistema de internacionalización simple y type-safe para Astro, inspirado en next-intl",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"astro",
|
|
8
|
+
"astro-integration",
|
|
9
|
+
"i18n",
|
|
10
|
+
"internationalization",
|
|
11
|
+
"intl",
|
|
12
|
+
"translations",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"author": "Erick Cruz <erickj.cruzs@gmail.com>",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:ui": "vitest --ui",
|
|
21
|
+
"test:coverage": "vitest run --coverage",
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
24
|
+
"lint": "eslint . --max-warnings 0",
|
|
25
|
+
"lint:fix": "eslint . --fix",
|
|
26
|
+
"format": "prettier --write .",
|
|
27
|
+
"format:check": "prettier --check ."
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"astro": "^4 || ^5"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"react": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://github.com/ErickCSS/astro-intl"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/ErickCSS/astro-intl/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/ErickCSS/astro-intl#readme",
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^9.17.0",
|
|
61
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
62
|
+
"@testing-library/react": "^16.3.2",
|
|
63
|
+
"@types/react": "^18.3.18",
|
|
64
|
+
"@vitest/ui": "^4.0.18",
|
|
65
|
+
"astro": "^5.17.3",
|
|
66
|
+
"eslint": "^9.17.0",
|
|
67
|
+
"happy-dom": "^20.7.0",
|
|
68
|
+
"prettier": "^3.4.2",
|
|
69
|
+
"react": "^19.2.4",
|
|
70
|
+
"react-dom": "^19.2.4",
|
|
71
|
+
"typescript": "^5.9.3",
|
|
72
|
+
"typescript-eslint": "^8.19.1",
|
|
73
|
+
"vitest": "^4.0.18"
|
|
74
|
+
}
|
|
75
|
+
}
|