@astroscope/i18n 0.2.3 → 0.3.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 CHANGED
@@ -21,11 +21,12 @@ i18n for Astro + React islands — automatic tree-shaking, parallel loading, any
21
21
  ## Features
22
22
 
23
23
  - **Per-chunk translation loading** — each island gets only its translations
24
- - **ICU MessageFormat** support via `@messageformat/core`
24
+ - **Unicode MessageFormat 2** (MF2) support via `messageformat` v4
25
+ - **Built-in formatters** — `:number`, `:integer`, `:percent`, `:currency`, `:date`, `:time`, `:datetime`, `:unit`
25
26
  - **Babel-based extraction** — robust AST parsing, source maps, production stripping
26
27
  - **Manifest fallbacks** — missing translations automatically use extracted fallbacks
27
28
  - **Full TypeScript support**
28
- - **Tiny client runtime** — no heavy i18n framework
29
+ - **Tiny client runtime** — ~8KB gzipped for translations
29
30
 
30
31
  ## Installation
31
32
 
@@ -123,7 +124,7 @@ Inject translations into the page for hydrated components:
123
124
 
124
125
  ```astro
125
126
  ---
126
- import { I18nScript } from '@astroscope/i18n/components';
127
+ import { I18nScript } from '@astroscope/i18n/astro';
127
128
  ---
128
129
  <html>
129
130
  <head>
@@ -140,25 +141,27 @@ import { I18nScript } from '@astroscope/i18n/components';
140
141
  ```astro
141
142
  ---
142
143
  // In .astro files
143
- import { t } from '@astroscope/i18n/t';
144
+ import { t } from '@astroscope/i18n/translate';
144
145
  ---
145
146
  <h1>{t('checkout.title', 'Order Summary')}</h1>
146
147
  ```
147
148
 
148
149
  ```tsx
149
150
  // In React components
150
- import { t } from '@astroscope/i18n/t';
151
+ import { t } from '@astroscope/i18n/translate';
151
152
 
152
153
  export function CheckoutSummary() {
153
154
  return (
154
155
  <div>
155
156
  <h1>{t('checkout.title', 'Order Summary')}</h1>
156
- <p>{t('checkout.tax', 'Includes {tax} VAT', { tax: '19%' })}</p>
157
+ <p>{t('checkout.tax', 'Includes {$tax} VAT', { tax: '19%' })}</p>
157
158
  </div>
158
159
  );
159
160
  }
160
161
  ```
161
162
 
163
+ > **Note:** Variables use `{$name}` syntax (with `$` prefix) per MessageFormat 2 specification.
164
+
162
165
  ### 6. Use i18n-aware client directives
163
166
 
164
167
  **The problem:** With standard `client:*` directives, the translation chunk loads *after* the component module. This delays hydration while translations are fetched sequentially.
@@ -179,28 +182,90 @@ import Cart from '../components/Cart';
179
182
 
180
183
  ### `t(key, fallback, values?)`
181
184
 
182
- Translate a key with optional interpolation values.
185
+ Translate a key with optional interpolation values. The fallback is used when a translation is missing and also serves as an example for translators. Uses [MessageFormat 2](https://github.com/unicode-org/message-format-wg) syntax.
183
186
 
184
187
  ```ts
185
- // simple
188
+ // simple text
186
189
  t('checkout.title', 'Order Summary')
187
190
 
188
- // with variables
189
- t('checkout.tax', 'Includes {tax} VAT', { tax: '19%' })
191
+ // with variables (note the $ prefix)
192
+ t('checkout.tax', 'Includes {$tax} VAT', { tax: '19%' })
193
+
194
+ // with pluralization (MF2 syntax)
195
+ t('cart.items', `.input {$count :number}
196
+ .match $count
197
+ one {{{$count} item}}
198
+ * {{{$count} items}}`, { count: 5 })
199
+
200
+ // with number formatting
201
+ t('stats.value', '{$value :number minimumFractionDigits=2}', { value: 1234.5 })
202
+
203
+ // with percentage
204
+ t('stats.ratio', '{$value :percent}', { value: 0.856 })
190
205
 
191
- // with pluralization (ICU MessageFormat)
192
- t('cart.items', '{count, plural, one {# item} other {# items}}', { count: 5 })
206
+ // with date/time formatting
207
+ t('event.date', '{$date :date style=long}', { date: new Date() })
208
+ t('event.time', '{$time :time style=short}', { time: new Date() })
209
+
210
+ // with currency (translator controls currency)
211
+ t('product.price', '{$price :currency currency=EUR}', { price: 99.99 })
212
+
213
+ // with currency (code controls currency via wrapped value)
214
+ t('product.price', '{$price :currency}', {
215
+ price: { valueOf: () => 99.99, options: { currency: 'EUR' } }
216
+ })
217
+
218
+ // with units
219
+ t('distance', '{$value :unit unit=kilometer}', { value: 42 })
193
220
 
194
221
  // with metadata object (for extraction tooling)
195
222
  t('cart.total', {
196
- fallback: 'Total: {amount}',
223
+ example: 'Total: {$amount}',
197
224
  description: 'Cart total price',
198
225
  variables: {
199
- amount: { fallback: '$0.00', description: 'Formatted price' }
226
+ amount: { example: '$0.00', description: 'Formatted price' }
200
227
  }
201
228
  }, { amount: '$49.99' })
202
229
  ```
203
230
 
231
+ ### `rich(key, fallback, components, values?)`
232
+
233
+ Translate with embedded components using MF2 markup syntax. Returns an array of strings and JSX elements that can be rendered directly.
234
+
235
+ ```tsx
236
+ import { rich } from '@astroscope/i18n/translate';
237
+
238
+ // basic link
239
+ rich('tos', 'Read our {#link}Terms of Service{/link}', {
240
+ link: (children) => <a href="/tos">{children}</a>
241
+ })
242
+ // Returns: ['Read our ', <a href="/tos">Terms of Service</a>]
243
+
244
+ // multiple tags
245
+ rich('legal', 'Read our {#tos}Terms{/tos} and {#privacy}Privacy Policy{/privacy}', {
246
+ tos: (children) => <a href="/tos">{children}</a>,
247
+ privacy: (children) => <a href="/privacy">{children}</a>,
248
+ })
249
+
250
+ // with variables
251
+ rich('greeting', 'Hello {$name}, check your {#inbox}messages{/inbox}', {
252
+ inbox: (children) => <a href="/inbox">{children}</a>,
253
+ }, { name: 'Alice' })
254
+
255
+ // nested tags
256
+ rich('highlight', 'This is {#bold}very {#em}important{/em}{/bold}', {
257
+ bold: (children) => <strong>{children}</strong>,
258
+ em: (children) => <em>{children}</em>,
259
+ })
260
+
261
+ // standalone (self-closing) tags
262
+ rich('install', 'Click {#icon/} to install', {
263
+ icon: () => <DownloadIcon />,
264
+ })
265
+ ```
266
+
267
+ The same code works in both Astro templates and React islands — `rich()` is JSX-runtime agnostic.
268
+
204
269
  ### `i18n` singleton
205
270
 
206
271
  ```ts
@@ -256,11 +321,74 @@ export function App() {
256
321
  3. **SSR** — Middleware provides translations to `t()`, merging manifest fallbacks for missing keys
257
322
  4. **Client** — Custom directives load only the translations needed by each chunk
258
323
 
259
- The same `import { t } from '@astroscope/i18n/t'` works everywhere — bundler picks the correct implementation via conditional exports (`browser` vs `default`).
324
+ The same `import { t } from '@astroscope/i18n/translate'` works everywhere — bundler picks the correct implementation via conditional exports (`browser` vs `default`).
260
325
 
261
326
  ### Client bundle
262
327
 
263
- Translation chunks are served as raw ICU MessageFormat strings and compiled on the browser on first use. This keeps chunk sizes minimal but includes `@messageformat/core` (~15KB gzipped) in your client bundle. Compiled messages are cached for subsequent renders.
328
+ Translation chunks are served as raw MessageFormat 2 strings and compiled on the browser on first use. This keeps chunk sizes minimal — the `messageformat` runtime is ~8KB gzipped. Compiled messages are cached for subsequent renders.
329
+
330
+ > **Future:** Once browsers ship native `Intl.MessageFormat`, this 8KB runtime will be replaced by the built-in API with zero bundle cost.
331
+
332
+ ## MessageFormat 2 Syntax
333
+
334
+ This library uses [Unicode MessageFormat 2](https://github.com/unicode-org/message-format-wg) (MF2), the modern standard for internationalization.
335
+
336
+ ### Basic syntax
337
+
338
+ ```
339
+ Simple text
340
+ Hello {$name}
341
+ ```
342
+
343
+ ### Pluralization
344
+
345
+ ```
346
+ .input {$count :number}
347
+ .match $count
348
+ one {{{$count} item}}
349
+ * {{{$count} items}}
350
+ ```
351
+
352
+ ### Selection (gender, etc.)
353
+
354
+ ```
355
+ .input {$gender :string}
356
+ .match $gender
357
+ male {{He liked it}}
358
+ female {{She liked it}}
359
+ * {{They liked it}}
360
+ ```
361
+
362
+ ### Built-in formatters
363
+
364
+ | Formatter | Description | Example |
365
+ |-----------|-------------|---------|
366
+ | `:number` | Locale-aware number | `{$n :number}` → "1,234.56" |
367
+ | `:integer` | Integer (no decimals) | `{$n :integer}` → "1,235" |
368
+ | `:percent` | Percentage | `{$n :percent}` → "85.6%" |
369
+ | `:currency` | Currency | `{$n :currency currency=EUR}` → "€99.99" |
370
+ | `:date` | Date | `{$d :date style=long}` → "January 26, 2026" |
371
+ | `:time` | Time | `{$d :time style=short}` → "3:45 PM" |
372
+ | `:datetime` | Date + time | `{$d :datetime dateStyle=medium timeStyle=short}` |
373
+ | `:unit` | Units | `{$n :unit unit=kilometer}` → "42 km" |
374
+
375
+ ### Currency and unit options
376
+
377
+ For `:currency` and `:unit`, the required option (`currency` or `unit`) can be:
378
+
379
+ 1. **Hardcoded in translation** (translator controls):
380
+ ```
381
+ {$price :currency currency=EUR}
382
+ ```
383
+ Code passes plain number: `{ price: 99.99 }`
384
+
385
+ 2. **Provided by code** (for dynamic currency/unit):
386
+ ```
387
+ {$price :currency}
388
+ ```
389
+ Code passes wrapped value: `{ price: { valueOf: () => 99.99, options: { currency: 'EUR' } } }`
390
+
391
+ > **Note:** If both translation and code specify the option, **translation wins**.
264
392
 
265
393
  ## Configuration
266
394
 
@@ -0,0 +1,44 @@
1
+ // src/shared/compiler.ts
2
+ import { MessageFormat } from "messageformat";
3
+ import { DraftFunctions } from "messageformat/functions";
4
+ var mfOptions = { functions: DraftFunctions };
5
+ var cache = /* @__PURE__ */ new Map();
6
+ function getOrCreateMessageFormat(locale, message) {
7
+ const cacheKey = `${locale}:${message}`;
8
+ const cached = cache.get(cacheKey);
9
+ if (cached) {
10
+ return cached;
11
+ }
12
+ const mf = new MessageFormat(locale, message, mfOptions);
13
+ cache.set(cacheKey, mf);
14
+ return mf;
15
+ }
16
+ function compileMessage(locale, message) {
17
+ try {
18
+ const mf = getOrCreateMessageFormat(locale, message);
19
+ return (values) => mf.format(values);
20
+ } catch {
21
+ return () => message;
22
+ }
23
+ }
24
+ function formatMessageToParts(locale, message, values) {
25
+ try {
26
+ const mf = getOrCreateMessageFormat(locale, message);
27
+ return mf.formatToParts(values);
28
+ } catch {
29
+ return [{ type: "literal", value: message }];
30
+ }
31
+ }
32
+ function compileTranslations(locale, raw) {
33
+ const compiled = {};
34
+ for (const [key, message] of Object.entries(raw)) {
35
+ compiled[key] = compileMessage(locale, message);
36
+ }
37
+ return compiled;
38
+ }
39
+
40
+ export {
41
+ compileMessage,
42
+ formatMessageToParts,
43
+ compileTranslations
44
+ };
@@ -23,7 +23,12 @@ function getManifest() {
23
23
  ...key,
24
24
  // make file paths relative to project root for better readability
25
25
  // and avoiding leaking absolute paths from dev or ci environments
26
- file: state.projectRoot ? path.relative(state.projectRoot, key.file) : key.file
26
+ files: key.files.map((fileLocation) => {
27
+ const [filePath, line] = fileLocation.split(":");
28
+ if (!filePath) return fileLocation;
29
+ const relativePath = state.projectRoot ? path.relative(state.projectRoot, filePath) : filePath;
30
+ return line ? `${relativePath}:${line}` : relativePath;
31
+ })
27
32
  }));
28
33
  return { keys, chunks: state.chunkManifest, imports: state.importsManifest };
29
34
  }
@@ -83,18 +88,21 @@ var KeyStore = class {
83
88
  constructor(logger) {
84
89
  this.logger = logger;
85
90
  }
86
- extractedKeys = [];
91
+ /** All key occurrences (may have duplicates) */
92
+ occurrences = [];
87
93
  fileToKeys = /* @__PURE__ */ new Map();
88
94
  filesWithI18n = /* @__PURE__ */ new Set();
89
95
  /**
90
- * Add keys for a file, replacing any existing keys for that file.
96
+ * Add key occurrences for a file, replacing any existing occurrences for that file.
91
97
  */
92
98
  addFileKeys(filename, keys) {
93
- const oldKeys = this.fileToKeys.get(filename);
94
99
  const newKeyStrings = keys.map((k) => k.key);
100
+ const oldKeys = this.fileToKeys.get(filename);
95
101
  if (oldKeys) {
96
- const added = newKeyStrings.filter((k) => !oldKeys.includes(k));
97
- const removed = oldKeys.filter((k) => !newKeyStrings.includes(k));
102
+ const uniqueNew = [...new Set(newKeyStrings)];
103
+ const uniqueOld = [...new Set(oldKeys)];
104
+ const added = uniqueNew.filter((k) => !uniqueOld.includes(k));
105
+ const removed = uniqueOld.filter((k) => !uniqueNew.includes(k));
98
106
  const file = filename.split("/").pop();
99
107
  if (added.length > 0 || removed.length > 0) {
100
108
  const parts = [];
@@ -104,23 +112,47 @@ var KeyStore = class {
104
112
  }
105
113
  }
106
114
  if (oldKeys) {
107
- for (let i = this.extractedKeys.length - 1; i >= 0; i--) {
108
- if (this.extractedKeys[i]?.file === filename) {
109
- this.extractedKeys.splice(i, 1);
115
+ for (let i = this.occurrences.length - 1; i >= 0; i--) {
116
+ if (this.occurrences[i]?.file === filename) {
117
+ this.occurrences.splice(i, 1);
110
118
  }
111
119
  }
112
120
  }
113
121
  this.filesWithI18n.add(filename);
114
122
  if (keys.length > 0) {
115
- this.extractedKeys.push(...keys);
123
+ this.occurrences.push(...keys);
116
124
  }
117
125
  this.fileToKeys.set(filename, newKeyStrings);
118
126
  }
127
+ /**
128
+ * Get deduplicated keys with all file locations merged.
129
+ * Last occurrence's meta wins for each key.
130
+ */
131
+ get extractedKeys() {
132
+ const keyMap = /* @__PURE__ */ new Map();
133
+ for (const occurrence of this.occurrences) {
134
+ const existing = keyMap.get(occurrence.key);
135
+ const fileLocation = `${occurrence.file}:${occurrence.line}`;
136
+ if (existing) {
137
+ if (!existing.files.includes(fileLocation)) {
138
+ existing.files.push(fileLocation);
139
+ }
140
+ existing.meta = occurrence.meta;
141
+ } else {
142
+ keyMap.set(occurrence.key, {
143
+ key: occurrence.key,
144
+ meta: occurrence.meta,
145
+ files: [fileLocation]
146
+ });
147
+ }
148
+ }
149
+ return Array.from(keyMap.values());
150
+ }
119
151
  /**
120
152
  * Get count of unique keys.
121
153
  */
122
154
  get uniqueKeyCount() {
123
- return new Set(this.extractedKeys.map((k) => k.key)).size;
155
+ return new Set(this.occurrences.map((k) => k.key)).size;
124
156
  }
125
157
  /**
126
158
  * Merge another store's data into this one.
@@ -132,7 +164,13 @@ var KeyStore = class {
132
164
  for (const file of other.filesWithI18n) {
133
165
  this.filesWithI18n.add(file);
134
166
  }
135
- this.extractedKeys.push(...other.extractedKeys);
167
+ this.occurrences.push(...other.occurrences);
168
+ }
169
+ /**
170
+ * Internal access to occurrences (for merge)
171
+ */
172
+ get occurrencesList() {
173
+ return this.occurrences;
136
174
  }
137
175
  };
138
176
 
@@ -159,7 +197,7 @@ async function scan(projectRoot, logger) {
159
197
  const results = await Promise.all(
160
198
  files.map(async (file) => {
161
199
  let code = await fs.promises.readFile(file, "utf-8");
162
- if (!code.includes("@astroscope/i18n/t")) {
200
+ if (!code.includes("@astroscope/i18n/translate")) {
163
201
  return { file, keys: null };
164
202
  }
165
203
  if (file.endsWith(".astro")) {
@@ -195,6 +233,10 @@ var MANIFEST_FILE_NAME = "i18n-manifest.json";
195
233
  function i18nVitePlugin(options) {
196
234
  const { logger } = options;
197
235
  const store = new KeyStore(logger);
236
+ const syncGlobalState = () => {
237
+ const state = getGlobalState();
238
+ state.extractedKeys = store.extractedKeys;
239
+ };
198
240
  let isBuild = false;
199
241
  let isSSR = false;
200
242
  let projectRoot = "";
@@ -204,7 +246,6 @@ function i18nVitePlugin(options) {
204
246
  configResolved(config) {
205
247
  const state = getGlobalState();
206
248
  state.projectRoot = config.root;
207
- state.extractedKeys = store.extractedKeys;
208
249
  isBuild = config.command === "build";
209
250
  isSSR = !!config.build.ssr;
210
251
  projectRoot = config.root;
@@ -215,6 +256,7 @@ function i18nVitePlugin(options) {
215
256
  }
216
257
  const result = await scan(projectRoot, logger);
217
258
  store.merge(result);
259
+ syncGlobalState();
218
260
  logger.info(`dev mode: scanned ${result.fileToKeys.size} files, found ${result.uniqueKeyCount} keys`);
219
261
  },
220
262
  resolveId(id) {
@@ -251,7 +293,7 @@ export function getManifest() { return _getManifest(); }
251
293
  if (filename.includes("node_modules")) {
252
294
  return null;
253
295
  }
254
- if (!code.includes("@astroscope/i18n/t")) {
296
+ if (!code.includes("@astroscope/i18n/translate")) {
255
297
  return null;
256
298
  }
257
299
  const result = await extractKeysFromFile({
@@ -261,6 +303,7 @@ export function getManifest() { return _getManifest(); }
261
303
  stripFallbacks: isBuild
262
304
  });
263
305
  store.addFileKeys(filename, result.keys);
306
+ syncGlobalState();
264
307
  if (isBuild && result.code) {
265
308
  return { code: result.code, map: result.map };
266
309
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  compileTranslations
3
- } from "./chunk-KNDYKTHE.js";
3
+ } from "./chunk-33JVAFGQ.js";
4
4
 
5
5
  // src/extraction/hash.ts
6
6
  import { createHash } from "crypto";
@@ -0,0 +1,73 @@
1
+ // src/shared/meta.ts
2
+ function normalizeMeta(meta) {
3
+ return typeof meta === "string" ? { fallback: meta } : meta;
4
+ }
5
+
6
+ // src/shared/rich.ts
7
+ var REACT_ELEMENT_TYPE = /* @__PURE__ */ Symbol.for("react.element");
8
+ var REACT_TRANSITIONAL_ELEMENT_TYPE = /* @__PURE__ */ Symbol.for("react.transitional.element");
9
+ function isReactElement(value) {
10
+ if (typeof value !== "object" || value === null || !("$$typeof" in value)) {
11
+ return false;
12
+ }
13
+ const type = value.$$typeof;
14
+ return type === REACT_ELEMENT_TYPE || type === REACT_TRANSITIONAL_ELEMENT_TYPE;
15
+ }
16
+ function addKeyIfNeeded(element, key) {
17
+ if (isReactElement(element) && element.key === null) {
18
+ return { ...element, key };
19
+ }
20
+ return element;
21
+ }
22
+ function isMarkupPart(part) {
23
+ return part.type === "markup";
24
+ }
25
+ function partsToNodes(parts, components) {
26
+ const result = [];
27
+ const stack = [];
28
+ let keyIndex = 0;
29
+ for (const part of parts) {
30
+ const target = stack.length > 0 ? stack[stack.length - 1].children : result;
31
+ if (isMarkupPart(part)) {
32
+ if (part.kind === "open") {
33
+ stack.push({ name: part.name, children: [] });
34
+ } else if (part.kind === "close") {
35
+ const frame = stack.pop();
36
+ if (!frame) {
37
+ continue;
38
+ }
39
+ const component = components[frame.name];
40
+ const parent = stack.length > 0 ? stack[stack.length - 1].children : result;
41
+ if (component) {
42
+ parent.push(addKeyIfNeeded(component(frame.children), `rich-${keyIndex++}`));
43
+ } else {
44
+ parent.push(...frame.children);
45
+ }
46
+ } else if (part.kind === "standalone") {
47
+ const component = components[part.name];
48
+ if (component) {
49
+ target.push(addKeyIfNeeded(component([]), `rich-${keyIndex++}`));
50
+ }
51
+ }
52
+ } else if (part.type === "text") {
53
+ target.push(part.value);
54
+ } else if (part.type === "bidiIsolation") {
55
+ } else {
56
+ if ("value" in part && part.value !== void 0 && part.value !== null) {
57
+ const stringValue = String(part.value);
58
+ target.push(stringValue);
59
+ }
60
+ }
61
+ }
62
+ while (stack.length > 0) {
63
+ const frame = stack.pop();
64
+ const parent = stack.length > 0 ? stack[stack.length - 1].children : result;
65
+ parent.push(...frame.children);
66
+ }
67
+ return result;
68
+ }
69
+
70
+ export {
71
+ normalizeMeta,
72
+ partsToNodes
73
+ };
@@ -0,0 +1,41 @@
1
+ import { R as RichComponents } from '../rich-CQUhdwBc.js';
2
+ import { a as TranslateFunction, T as TranslationMeta } from '../types-DNRFj1XA.js';
3
+ import '../types-BeDFfFIL.js';
4
+
5
+ declare function getLocale(): string;
6
+ /**
7
+ * Client-side translate function
8
+ *
9
+ * Reads from window.__i18n__.translations (injected per-chunk).
10
+ * In production, translations are always loaded before component code runs
11
+ * via the chunk loader injected by the Vite plugin.
12
+ *
13
+ * Call patterns:
14
+ * - Development: t('key', 'fallback') or t('key', 'fallback', values)
15
+ * - Production: t('key') or t('key', undefined, values)
16
+ *
17
+ * Falls back to meta.fallback (dev) or key (prod) when translation is missing.
18
+ */
19
+ declare const t: TranslateFunction;
20
+ /**
21
+ * Client-side rich text translation function
22
+ *
23
+ * Parses MF2 markup syntax and applies component callbacks to wrap content.
24
+ * Works with any JSX runtime (React, Preact, etc.).
25
+ *
26
+ * @param key - Translation key
27
+ * @param meta - Fallback string or meta object with fallback (used as message template)
28
+ * @param components - Map of tag names to component callbacks
29
+ * @param values - Runtime interpolation values (ICU MessageFormat)
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * rich('tos', 'Read our {#link}Terms{/link}', {
34
+ * link: (children) => <a href="/tos">{children}</a>
35
+ * })
36
+ * // Returns: ['Read our ', <a href="/tos">Terms</a>]
37
+ * ```
38
+ */
39
+ declare function rich<T = unknown>(key: string, meta?: TranslationMeta | string | undefined, components?: RichComponents<T> | undefined, values?: Record<string, unknown> | undefined): (string | T)[];
40
+
41
+ export { getLocale, rich, t };
@@ -1,11 +1,13 @@
1
1
  import {
2
- normalizeMeta
3
- } from "../chunk-EMM6LRO2.js";
2
+ normalizeMeta,
3
+ partsToNodes
4
+ } from "../chunk-VGPVG5JP.js";
4
5
  import {
5
- compileMessage
6
- } from "../chunk-KNDYKTHE.js";
6
+ compileMessage,
7
+ formatMessageToParts
8
+ } from "../chunk-33JVAFGQ.js";
7
9
 
8
- // src/client/t.ts
10
+ // src/client/translate.ts
9
11
  var cache = /* @__PURE__ */ new Map();
10
12
  function getLocale() {
11
13
  return window.__i18n__.locale;
@@ -31,7 +33,14 @@ var t = ((key, meta, values) => {
31
33
  }
32
34
  return compiled(values);
33
35
  });
36
+ function rich(key, meta, components, values) {
37
+ const normalizedMeta = meta ? normalizeMeta(meta) : { fallback: "" };
38
+ const raw = window.__i18n__.translations[key] ?? normalizedMeta.fallback ?? key;
39
+ const parts = formatMessageToParts(window.__i18n__.locale, raw, values);
40
+ return partsToNodes(parts, components ?? {});
41
+ }
34
42
  export {
35
43
  getLocale,
44
+ rich,
36
45
  t
37
46
  };
@@ -1,5 +1,5 @@
1
- import { a as ExtractionManifest } from '../types-DTUho6gi.js';
2
- export { C as ChunkManifest, E as ExtractedKey } from '../types-DTUho6gi.js';
1
+ import { a as ExtractionManifest } from '../types-moTgocy7.js';
2
+ export { C as ChunkManifest, E as ExtractedKey } from '../types-moTgocy7.js';
3
3
  import { AstroIntegrationLogger } from 'astro';
4
4
  import { Plugin } from 'vite';
5
5
  import '../types-DNRFj1XA.js';
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  getManifest,
3
3
  i18nVitePlugin
4
- } from "../chunk-4IUQAJ6R.js";
4
+ } from "../chunk-EXMM4JID.js";
5
5
  import "../chunk-4A5BKHFN.js";
6
6
  export {
7
7
  getManifest,
@@ -1,4 +1,4 @@
1
- import { a as ExtractionManifest } from './types-DTUho6gi.js';
1
+ import { a as ExtractionManifest } from './types-moTgocy7.js';
2
2
  import { T as TranslationMeta, C as CompiledTranslations, R as RawTranslations } from './types-DNRFj1XA.js';
3
3
 
4
4
  /**
@@ -11,6 +11,7 @@ type FallbackBehavior = 'key' | 'fallback' | 'throw' | ((key: string, meta?: Tra
11
11
  type I18nContext = {
12
12
  locale: string;
13
13
  translations: CompiledTranslations;
14
+ rawTranslations: RawTranslations;
14
15
  fallback: FallbackBehavior;
15
16
  };
16
17
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { AstroIntegration, MiddlewareHandler, APIContext } from 'astro';
2
- export { F as FallbackBehavior, I as I18nConfig, i as i18n } from './i18n-BBAAVYz_.js';
2
+ export { F as FallbackBehavior, I as I18nConfig, i as i18n } from './i18n-DaLnNvss.js';
3
3
  import { ExcludePattern } from '@astroscope/excludes';
4
4
  export { R as RawTranslations, T as TranslationMeta } from './types-DNRFj1XA.js';
5
- export { E as ExtractedKey, a as ExtractionManifest } from './types-DTUho6gi.js';
5
+ export { E as ExtractedKey, a as ExtractionManifest } from './types-moTgocy7.js';
6
6
 
7
7
  interface I18nOptions {
8
8
  }
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  i18nVitePlugin
3
- } from "./chunk-4IUQAJ6R.js";
3
+ } from "./chunk-EXMM4JID.js";
4
4
  import {
5
5
  i18n,
6
6
  runWithContext
7
- } from "./chunk-ZKXMIGSF.js";
8
- import "./chunk-KNDYKTHE.js";
7
+ } from "./chunk-MSJHXRJB.js";
8
+ import "./chunk-33JVAFGQ.js";
9
9
  import "./chunk-4A5BKHFN.js";
10
10
 
11
11
  // src/integration/integration.ts
@@ -112,6 +112,7 @@ function createI18nMiddleware(options) {
112
112
  const context = {
113
113
  locale,
114
114
  translations: i18n.getCompiledTranslations(locale),
115
+ rawTranslations: i18n.getTranslations(locale),
115
116
  fallback: i18n.getConfig().fallback
116
117
  };
117
118
  return runWithContext(context, () => next());
@@ -1,7 +1,7 @@
1
- import { a as I18nContext } from './i18n-BBAAVYz_.js';
2
- export { i as i18n } from './i18n-BBAAVYz_.js';
1
+ import { a as I18nContext } from './i18n-DaLnNvss.js';
2
+ export { i as i18n } from './i18n-DaLnNvss.js';
3
3
  export { I as I18nClientState } from './types-DNRFj1XA.js';
4
- import './types-DTUho6gi.js';
4
+ import './types-moTgocy7.js';
5
5
 
6
6
  /**
7
7
  * Get the current i18n context
package/dist/internal.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  getContext,
3
3
  i18n
4
- } from "./chunk-ZKXMIGSF.js";
5
- import "./chunk-KNDYKTHE.js";
4
+ } from "./chunk-MSJHXRJB.js";
5
+ import "./chunk-33JVAFGQ.js";
6
6
  export {
7
7
  getContext,
8
8
  i18n
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Component callbacks for rich text rendering.
3
+ * Each key maps to a function that wraps children in a component.
4
+ *
5
+ * @example
6
+ * ```tsx
7
+ * const components: RichComponents = {
8
+ * link: (children) => <a href="/tos">{children}</a>,
9
+ * bold: (children) => <strong>{children}</strong>,
10
+ * };
11
+ * ```
12
+ */
13
+ type RichComponents<T = unknown> = Record<string, (children: (string | T)[]) => T>;
14
+
15
+ export type { RichComponents as R };
@@ -0,0 +1,38 @@
1
+ import { R as RichComponents } from '../rich-CQUhdwBc.js';
2
+ import { a as TranslateFunction, T as TranslationMeta } from '../types-DNRFj1XA.js';
3
+
4
+ /**
5
+ * Get the current locale from request context.
6
+ * Returns defaultLocale if called outside a request (e.g. during build).
7
+ */
8
+ declare function getLocale(): string;
9
+ /**
10
+ * Server-side translate function
11
+ *
12
+ * Call patterns:
13
+ * - Development: t('key', 'fallback') or t('key', 'fallback', values)
14
+ * - Production: t('key') or t('key', undefined, values)
15
+ */
16
+ declare const t: TranslateFunction;
17
+ /**
18
+ * Server-side rich text translation function
19
+ *
20
+ * Parses MF2 markup syntax and applies component callbacks to wrap content.
21
+ * Works with any JSX runtime (Astro, React during SSR, etc.).
22
+ *
23
+ * @param key - Translation key
24
+ * @param meta - Fallback string or meta object with fallback (used as message template)
25
+ * @param components - Map of tag names to component callbacks
26
+ * @param values - Runtime interpolation values (ICU MessageFormat)
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * rich('tos', 'Read our {#link}Terms{/link}', {
31
+ * link: (children) => <a href="/tos">{children}</a>
32
+ * })
33
+ * // Returns: ['Read our ', <a href="/tos">Terms</a>]
34
+ * ```
35
+ */
36
+ declare function rich<T = unknown>(key: string, meta?: TranslationMeta | string | undefined, components?: RichComponents<T> | undefined, values?: Record<string, unknown> | undefined): (string | T)[];
37
+
38
+ export { getLocale, rich, t };
@@ -1,15 +1,17 @@
1
1
  import {
2
2
  getContext,
3
3
  i18n
4
- } from "../chunk-ZKXMIGSF.js";
4
+ } from "../chunk-MSJHXRJB.js";
5
5
  import {
6
- normalizeMeta
7
- } from "../chunk-EMM6LRO2.js";
6
+ normalizeMeta,
7
+ partsToNodes
8
+ } from "../chunk-VGPVG5JP.js";
8
9
  import {
9
- compileMessage
10
- } from "../chunk-KNDYKTHE.js";
10
+ compileMessage,
11
+ formatMessageToParts
12
+ } from "../chunk-33JVAFGQ.js";
11
13
 
12
- // src/server/t.ts
14
+ // src/server/translate.ts
13
15
  var fallbackCache = /* @__PURE__ */ new Map();
14
16
  function applyFallback(key, meta, fallback) {
15
17
  if (typeof fallback === "function") {
@@ -53,7 +55,16 @@ var t = ((key, meta, values) => {
53
55
  }
54
56
  return compiled(values);
55
57
  });
58
+ function rich(key, meta, components, values) {
59
+ const normalizedMeta = meta ? normalizeMeta(meta) : { fallback: "" };
60
+ const ctx = getContext();
61
+ const locale = ctx?.locale ?? i18n.getConfig().defaultLocale;
62
+ const raw = ctx?.rawTranslations[key] ?? normalizedMeta.fallback ?? key;
63
+ const parts = formatMessageToParts(locale, raw, values);
64
+ return partsToNodes(parts, components ?? {});
65
+ }
56
66
  export {
57
67
  getLocale,
68
+ rich,
58
69
  t
59
70
  };
@@ -1,13 +1,13 @@
1
1
  import { T as TranslationMeta } from './types-DNRFj1XA.js';
2
2
 
3
3
  /**
4
- * Extracted metadata for a single translation key
4
+ * Deduplicated translation key with all file locations (used in manifest)
5
5
  */
6
6
  type ExtractedKey = {
7
7
  key: string;
8
8
  meta: TranslationMeta;
9
- file: string;
10
- line: number;
9
+ /** All locations where this key is used (file:line format) */
10
+ files: string[];
11
11
  };
12
12
  /**
13
13
  * Chunk-to-keys mapping produced by Vite plugin
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astroscope/i18n",
3
- "version": "0.2.3",
4
- "description": "i18n for Astro + React islands — automatic tree-shaking, parallel loading, any translation source",
3
+ "version": "0.3.0",
4
+ "description": "i18n for Astro + React islands — MessageFormat 2, automatic tree-shaking, parallel loading, any translation source",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -10,10 +10,10 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
12
  },
13
- "./t": {
14
- "types": "./dist/client/t.d.ts",
15
- "browser": "./dist/client/t.js",
16
- "default": "./dist/server/t.js"
13
+ "./translate": {
14
+ "types": "./dist/client/translate.d.ts",
15
+ "browser": "./dist/client/translate.js",
16
+ "default": "./dist/server/translate.js"
17
17
  },
18
18
  "./client-directives/load.js": {
19
19
  "types": "./dist/client/directives/load.d.ts",
@@ -35,8 +35,8 @@
35
35
  "types": "./dist/client/directives/only.d.ts",
36
36
  "import": "./dist/client/directives/only.js"
37
37
  },
38
- "./components": {
39
- "import": "./src/components/index.ts"
38
+ "./astro": {
39
+ "import": "./src/astro/index.ts"
40
40
  },
41
41
  "./internal": {
42
42
  "types": "./dist/internal.d.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "files": [
51
51
  "dist",
52
- "src/components"
52
+ "src/astro"
53
53
  ],
54
54
  "sideEffects": [
55
55
  "./dist/client/directives/*.js",
@@ -69,6 +69,8 @@
69
69
  "i18n",
70
70
  "internationalization",
71
71
  "translations",
72
+ "messageformat",
73
+ "mf2",
72
74
  "react",
73
75
  "islands",
74
76
  "ssr"
@@ -80,7 +82,7 @@
80
82
  },
81
83
  "homepage": "https://github.com/smnbbrv/astroscope/tree/main/packages/i18n#readme",
82
84
  "scripts": {
83
- "build": "tsup src/index.ts src/internal.ts src/extraction/index.ts src/server/t.ts src/client/t.ts src/client/directives/load.ts src/client/directives/idle.ts src/client/directives/visible.ts src/client/directives/media.ts src/client/directives/only.ts src/client/directives/utils.ts --format esm --dts --external astro --external astro/client/load.js --external astro/client/idle.js --external astro/client/visible.js --external astro/client/media.js --external astro/client/only.js --external virtual:@astroscope/i18n/manifest --external @babel/core",
85
+ "build": "tsup src/index.ts src/internal.ts src/extraction/index.ts src/server/translate.ts src/client/translate.ts src/client/directives/load.ts src/client/directives/idle.ts src/client/directives/visible.ts src/client/directives/media.ts src/client/directives/only.ts src/client/directives/utils.ts --format esm --dts --external astro --external astro/client/load.js --external astro/client/idle.js --external astro/client/visible.js --external astro/client/media.js --external astro/client/only.js --external virtual:@astroscope/i18n/manifest --external @babel/core",
84
86
  "typecheck": "tsc --noEmit",
85
87
  "lint": "eslint 'src/**/*.ts'",
86
88
  "lint:fix": "eslint 'src/**/*.ts' --fix"
@@ -89,7 +91,7 @@
89
91
  "@astrojs/compiler": "^2.12.2",
90
92
  "@babel/core": "^7.28.6",
91
93
  "@babel/preset-typescript": "^7.28.5",
92
- "@messageformat/core": "^3.4.0",
94
+ "messageformat": "^4.0.0",
93
95
  "glob": "^11.0.0",
94
96
  "magic-string": "^0.30.21"
95
97
  },
@@ -1,8 +0,0 @@
1
- // src/shared/meta.ts
2
- function normalizeMeta(meta) {
3
- return typeof meta === "string" ? { fallback: meta } : meta;
4
- }
5
-
6
- export {
7
- normalizeMeta
8
- };
@@ -1,31 +0,0 @@
1
- // src/shared/compiler.ts
2
- import MessageFormat from "@messageformat/core";
3
- var cache = /* @__PURE__ */ new Map();
4
- function getMessageFormat(locale) {
5
- let mf = cache.get(locale);
6
- if (!mf) {
7
- mf = new MessageFormat(locale);
8
- cache.set(locale, mf);
9
- }
10
- return mf;
11
- }
12
- function compileMessage(locale, message) {
13
- const mf = getMessageFormat(locale);
14
- try {
15
- return mf.compile(message);
16
- } catch {
17
- return () => message;
18
- }
19
- }
20
- function compileTranslations(locale, raw) {
21
- const compiled = {};
22
- for (const [key, message] of Object.entries(raw)) {
23
- compiled[key] = compileMessage(locale, message);
24
- }
25
- return compiled;
26
- }
27
-
28
- export {
29
- compileMessage,
30
- compileTranslations
31
- };
@@ -1,20 +0,0 @@
1
- import { a as TranslateFunction } from '../types-DNRFj1XA.js';
2
- import '../types-BeDFfFIL.js';
3
-
4
- declare function getLocale(): string;
5
- /**
6
- * Client-side translate function
7
- *
8
- * Reads from window.__i18n__.translations (injected per-chunk).
9
- * In production, translations are always loaded before component code runs
10
- * via the chunk loader injected by the Vite plugin.
11
- *
12
- * Call patterns:
13
- * - Development: t('key', 'fallback') or t('key', 'fallback', values)
14
- * - Production: t('key') or t('key', undefined, values)
15
- *
16
- * Falls back to meta.fallback (dev) or key (prod) when translation is missing.
17
- */
18
- declare const t: TranslateFunction;
19
-
20
- export { getLocale, t };
@@ -1,17 +0,0 @@
1
- import { a as TranslateFunction } from '../types-DNRFj1XA.js';
2
-
3
- /**
4
- * Get the current locale from request context.
5
- * Returns defaultLocale if called outside a request (e.g. during build).
6
- */
7
- declare function getLocale(): string;
8
- /**
9
- * Server-side translate function
10
- *
11
- * Call patterns:
12
- * - Development: t('key', 'fallback') or t('key', 'fallback', values)
13
- * - Production: t('key') or t('key', undefined, values)
14
- */
15
- declare const t: TranslateFunction;
16
-
17
- export { getLocale, t };
File without changes
File without changes