@sveltekit-i18n/parser-mf2 3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 sveltekit-i18n
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,553 @@
1
+ [![npm version](https://badge.fury.io/js/@sveltekit-i18n%2Fparser-mf2.svg)](https://badge.fury.io/js/@sveltekit-i18n%2Fparser-mf2) [![Tests](https://github.com/sveltekit-i18n/parsers/actions/workflows/tests-parser-mf2.yml/badge.svg)](https://github.com/sveltekit-i18n/parsers/actions/workflows/tests-parser-mf2.yml)
2
+
3
+ # @sveltekit-i18n/parser-mf2
4
+
5
+ [Unicode MessageFormat 2](https://unicode.org/reports/tr35/tr35-messageFormat.html) for [@sveltekit-i18n/base](https://github.com/sveltekit-i18n/base), powered by [`messageformat`](https://github.com/messageformat/messageformat), the format's reference implementation and this package's only dependency. MessageFormat 2 is the Unicode standard's successor to ICU MessageFormat: variables and functions in single braces, declarations that annotate a value once for the whole message, and selection on any number of values at once. This README is a practical guide to the syntax — the full grammar and the resolution rules are in the specification.
6
+
7
+ ## Features
8
+
9
+ - 🌍 **Unicode standard** – MessageFormat 2, as specified in LDML
10
+ - 📐 **Selection** – Plural categories, exact matches and several selectors at once
11
+ - 🎯 **Declarations** – `.input` and `.local` annotate a value once for the whole message
12
+ - 🔢 **Number formatting** – `:number`, `:integer`, `:currency`, `:percent`, `:unit`
13
+ - 📅 **Date/time formatting** – `:date`, `:datetime`, `:time`, registered out of the box
14
+ - 🔧 **Custom functions** – Register your own, or replace a built-in one
15
+ - 🧩 **Build-time extraction** – Read a catalogue for the parameters its messages name
16
+ - 📝 **TypeScript** – Full type support
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @sveltekit-i18n/parser-mf2
22
+ # bun add @sveltekit-i18n/parser-mf2
23
+ # deno add npm:@sveltekit-i18n/parser-mf2
24
+ ```
25
+
26
+ **Note:** This parser has one external dependency (`messageformat`), which is installed automatically.
27
+
28
+ **Requirements:** Node.js 22.12, Bun 1.2 or Deno 2, or newer. Version 3 is ESM-only, expects [`@sveltekit-i18n/base`](https://github.com/sveltekit-i18n/base) v3 as a peer dependency, and builds on `messageformat` v4.
29
+
30
+ ## Usage
31
+
32
+ ### Basic Setup
33
+
34
+ ```typescript
35
+ // src/lib/translations/index.ts
36
+ import { I18n } from '@sveltekit-i18n/base';
37
+ import parser from '@sveltekit-i18n/parser-mf2';
38
+ import type { Config } from '@sveltekit-i18n/parser-mf2';
39
+
40
+ const config: Config = {
41
+ parser: parser({
42
+ // Where a diagnostic goes; required, `null` included
43
+ onReport: (report) => console.warn(report.message, report.error),
44
+ // Optional: the engine's own options
45
+ // See: https://github.com/messageformat/messageformat
46
+ }),
47
+ loaders: [
48
+ {
49
+ locale: 'en',
50
+ key: 'home',
51
+ routes: ['/'],
52
+ loader: async () => (await import('./en/home.json')).default,
53
+ },
54
+ {
55
+ locale: 'cs',
56
+ key: 'home',
57
+ routes: ['/'],
58
+ loader: async () => (await import('./cs/home.json')).default,
59
+ },
60
+ ],
61
+ };
62
+
63
+ export const i18n = new I18n(config);
64
+ ```
65
+
66
+ ### Load Translations
67
+
68
+ ```typescript
69
+ // src/routes/+layout.ts
70
+ import { i18n } from '$lib/translations';
71
+
72
+ export const load = async ({ url }) => {
73
+ const { pathname } = url;
74
+ const initLocale = 'en';
75
+
76
+ await i18n.loadTranslations(initLocale, pathname);
77
+
78
+ return {};
79
+ };
80
+ ```
81
+
82
+ ### Use in Components
83
+
84
+ ```svelte
85
+ <script>
86
+ import { i18n } from '$lib/translations';
87
+
88
+ let itemCount = 5;
89
+ let gender = 'female';
90
+ </script>
91
+
92
+ <p>{i18n.t('home.items', { count: itemCount })}</p>
93
+ <p>{i18n.t('home.response', { gender })}</p>
94
+ ```
95
+
96
+ `i18n.t(key, payload?)` takes the values the message's variables name; there is no per-call formatting slot, because a message states its own formatting as function options. The examples below use it.
97
+
98
+ ## MessageFormat 2 Syntax
99
+
100
+ A placeholder is an expression in single braces: a variable (`{$name}`), a literal (`{|text|}`, `{42}`), or either with a function and its options (`{$count :number minimumFractionDigits=2}`). Braces, backslashes and pipes are escaped with a backslash: `\{`, `\}`, `\\`, `\|`. In a JSON catalogue each backslash is written twice, and a message with declarations or selection spans several lines, written as `\n`.
101
+
102
+ The outputs below are shown without the [bidi isolation](#bidi-isolation) marks the default settings place around a placeholder of unknown direction; they are invisible in a browser, and a test comparing strings has to expect them.
103
+
104
+ ### Variables
105
+
106
+ ```json
107
+ {
108
+ "greeting": "Hello, {$name}!",
109
+ "user": "Signed in as {$user.name}."
110
+ }
111
+ ```
112
+
113
+ ```javascript
114
+ i18n.t('greeting', { name: 'Alice' })
115
+ // → "Hello, Alice!"
116
+
117
+ i18n.t('user', { user: { name: 'Kat' } })
118
+ // → "Signed in as Kat."
119
+ ```
120
+
121
+ A dotted name reads into an object value. A value that is no text becomes what `String()` makes of it: an object renders as `[object Object]`, an array as its elements joined by commas. An absent value - `undefined` - is not text either: the expression renders as its [fallback](#reports), `{$name}`, instead. `null` is not absence to the engine: bare, it renders as the engine's own fallback, `{�}` (the replacement character in braces), and is reported as `fallback-value` with the error the engine raised; under a function, the function decides what it makes of it - `:string` renders the text `null`, `:number` leaves the expression to the fallback.
122
+
123
+ ### Functions
124
+
125
+ A function follows the operand after a colon and reads its options from the expression:
126
+
127
+ ```json
128
+ {
129
+ "count": "{$count :number} items",
130
+ "precise": "{$value :number minimumFractionDigits=2}",
131
+ "rounded": "{$value :integer}",
132
+ "literal": "{42 :number} and {|plain text|}"
133
+ }
134
+ ```
135
+
136
+ ```javascript
137
+ i18n.t('count', { count: 1234.5 })
138
+ // → "1,234.5 items" (en) or "1 234,5 items" (cs, with a non-breaking space)
139
+
140
+ i18n.t('precise', { value: 3 })
141
+ // → "3.00"
142
+
143
+ i18n.t('rounded', { value: 1234.5 })
144
+ // → "1,235"
145
+
146
+ i18n.t('literal')
147
+ // → "42 and plain text"
148
+ ```
149
+
150
+ `:number` and `:integer` take the digit, rounding, grouping and sign options of `Intl.NumberFormat` the specification lists (`minimumFractionDigits`, `useGrouping`, `signDisplay`, ...) and select on the value; `:integer` takes no fraction-digit options, and an option the specification does not list is ignored without a report. `:string` reads the operand as text and selects on it; `:offset add=1` or `subtract=1` shifts a number before it is formatted. Together with the [draft functions](#date-time-currency-percent-and-unit) registered by this package, these are the functions a message can name out of the box; [custom functions](#custom-functions) add to them.
151
+
152
+ ### Declarations
153
+
154
+ A declaration annotates a value once, before the pattern, which is then written in double braces. `.input` annotates a payload variable; `.local` names a value of the message's own:
155
+
156
+ ```json
157
+ {
158
+ "photos": ".input {$count :integer}\n{{{$count} photos}}",
159
+ "price": ".local $amount = {$value :number minimumFractionDigits=2}\n{{Price: {$amount}}}"
160
+ }
161
+ ```
162
+
163
+ ```javascript
164
+ i18n.t('photos', { count: 1234.5 })
165
+ // → "1,235 photos"
166
+
167
+ i18n.t('price', { value: 3 })
168
+ // → "Price: 3.00"
169
+ ```
170
+
171
+ ### Selection
172
+
173
+ `.match` selects a variant by one or more values. Each variant lists one key per selector - a literal, or `*` for anything - and one variant has to be `*` for every selector. A selector must be annotated: declare it with the function that selects on it.
174
+
175
+ ```json
176
+ {
177
+ "photos": ".input {$count :integer}\n.match $count\n0 {{No photos.}}\none {{One photo.}}\n* {{{$count} photos.}}",
178
+ "response": ".input {$gender :string}\n.match $gender\nmale {{He will respond shortly.}}\nfemale {{She will respond shortly.}}\n* {{They will respond shortly.}}",
179
+ "likes": ".input {$count :integer}\n.input {$gender :string}\n.match $count $gender\n0 * {{Nobody liked this.}}\none female {{She liked this.}}\none male {{He liked this.}}\none * {{They liked this.}}\n* * {{{$count} people liked this.}}"
180
+ }
181
+ ```
182
+
183
+ ```javascript
184
+ i18n.t('photos', { count: 0 })
185
+ // → "No photos."
186
+
187
+ i18n.t('photos', { count: 1 })
188
+ // → "One photo."
189
+
190
+ i18n.t('photos', { count: 5 })
191
+ // → "5 photos."
192
+
193
+ i18n.t('response', { gender: 'female' })
194
+ // → "She will respond shortly."
195
+
196
+ i18n.t('likes', { count: 1, gender: 'female' })
197
+ // → "She liked this."
198
+
199
+ i18n.t('likes', { count: 3, gender: 'female' })
200
+ // → "3 people liked this."
201
+ ```
202
+
203
+ A number selects on its exact value first (`0`, `1`, `1.5`), then on its plural category for the locale: `zero`, `one`, `two`, `few`, `many`, `other`. `select=ordinal` switches `:integer` and `:number` to the ordinal categories:
204
+
205
+ ```json
206
+ {
207
+ "place": ".input {$position :integer select=ordinal}\n.match $position\none {{{$position}st}}\ntwo {{{$position}nd}}\nfew {{{$position}rd}}\n* {{{$position}th}}"
208
+ }
209
+ ```
210
+
211
+ ```javascript
212
+ i18n.t('place', { position: 3 })
213
+ // → "3rd"
214
+
215
+ i18n.t('place', { position: 22 })
216
+ // → "22nd"
217
+ ```
218
+
219
+ ### Date, Time, Currency, Percent and Unit
220
+
221
+ The specification classifies `:date`, `:datetime`, `:time`, `:currency`, `:percent` and `:unit` as DRAFT - liable to change, and outside its stability guarantee - and `messageformat` ships them apart from the required functions for that reason. This package registers them by default, so dates and money format out of the box as they do with the other official parsers, and tracks the upstream draft status: what `messageformat` ships as its draft functions is what a message can name here.
222
+
223
+ ```json
224
+ {
225
+ "today": "Today is {$date :date}",
226
+ "long": "Today is {$date :date length=long}",
227
+ "weekday": "{$date :date fields=year-month-day-weekday length=long}",
228
+ "seen": "Last seen {$date :datetime}",
229
+ "time": "At {$date :time}",
230
+ "total": "Total: {$amount :currency currency=USD}",
231
+ "discount": "Discount: {$rate :percent}",
232
+ "weight": "{$mass :unit unit=kilogram}"
233
+ }
234
+ ```
235
+
236
+ ```javascript
237
+ const date = new Date('2024-01-15T12:00:00');
238
+
239
+ i18n.t('today', { date })
240
+ // → "Today is Jan 15, 2024" (en) or "Today is 15. 1. 2024" (cs)
241
+
242
+ i18n.t('long', { date })
243
+ // → "Today is January 15, 2024"
244
+
245
+ i18n.t('weekday', { date })
246
+ // → "Monday, January 15, 2024"
247
+
248
+ i18n.t('seen', { date })
249
+ // → "Last seen Jan 15, 2024, 12:00 PM"
250
+
251
+ i18n.t('time', { date })
252
+ // → "At 12:00 PM"
253
+
254
+ i18n.t('total', { amount: 99.99 })
255
+ // → "Total: $99.99"
256
+
257
+ i18n.t('discount', { rate: 0.15 })
258
+ // → "Discount: 15%"
259
+
260
+ i18n.t('weight', { mass: 5 })
261
+ // → "5 kg"
262
+ ```
263
+
264
+ A date operand is a `Date`, milliseconds since the epoch, or text `Date` parses. `:date` takes `fields` (`weekday`, `day-weekday`, `month-day`, `month-day-weekday`, `year-month-day`, `year-month-day-weekday`) and `length` (`long`, `medium`, `short`); `:datetime` takes the same as `dateFields` and `dateLength`, plus `timePrecision` (`hour`, `minute`, `second`); `:time` takes `precision`. All three take `timeZone` and `calendar`, and the two with a time part `hour12` and `timeZoneStyle`. `:currency` requires `currency`, `:unit` requires `unit`, and both take the `Intl.NumberFormat` options of their style the specification lists, as `:percent` does; `:currency` states its fraction digits as one `fractionDigits` rather than the `Intl` pair. The option names are the specification's, not `Intl`'s: `dateStyle` or `style` names nothing and is ignored.
265
+
266
+ ### Markup
267
+
268
+ ```json
269
+ {
270
+ "link": "Click {#link href=$url}here{/link}."
271
+ }
272
+ ```
273
+
274
+ ```javascript
275
+ i18n.t('link', { url: '/docs' })
276
+ // → "Click here."
277
+ ```
278
+
279
+ Markup formats to nothing in text: this parser returns a string, so `{#link}` and `{/link}` leave only their content. Their options are still read, which is why the variables they name are [extracted](#extracting-parameters).
280
+
281
+ ## Bidi Isolation
282
+
283
+ By default the engine isolates every placeholder unless both the message and the value are known to be left-to-right, so a right-to-left name inside a left-to-right sentence does not reorder the text around it. A value of unknown direction - text, unless a function states one - is wrapped in U+2068 FIRST STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE; one of known direction in U+2066 LEFT-TO-RIGHT ISOLATE or U+2067 RIGHT-TO-LEFT ISOLATE and U+2069 instead. A number or a date formatted for a left-to-right locale in a left-to-right message is known left-to-right on both sides and gets none. The marks are invisible in a browser, and the examples in this guide omit them. A test comparing strings has to expect them, or switch them off:
284
+
285
+ ```javascript
286
+ // Under the default settings
287
+ expect(i18n.t('greeting', { name: 'Alice' })).toBe('Hello, \u2068Alice\u2069!');
288
+
289
+ // With `bidiIsolation: 'none'`
290
+ expect(i18n.t('greeting', { name: 'Alice' })).toBe('Hello, Alice!');
291
+ ```
292
+
293
+ `bidiIsolation: 'none'` applies no isolation at all; `dir` states the message's base direction where the locale would guess wrong.
294
+
295
+ ## Parser Options
296
+
297
+ Configure the parser with the engine's options, plus `onReport`:
298
+
299
+ ```typescript
300
+ import parser from '@sveltekit-i18n/parser-mf2';
301
+
302
+ const config = {
303
+ parser: parser({
304
+ onReport: (report) => console.warn(report.message, report.error),
305
+ // Optional engine options
306
+ bidiIsolation: 'default',
307
+ dir: 'auto',
308
+ localeMatcher: 'best fit',
309
+ functions: {},
310
+ }),
311
+ };
312
+ ```
313
+
314
+ | Option | Meaning |
315
+ | --- | --- |
316
+ | `onReport` | Where a diagnostic goes: a function, or `null` to discard reports. Required. |
317
+ | `bidiIsolation` | `'default'` isolates placeholders as [described above](#bidi-isolation); `'none'` applies no isolation. |
318
+ | `dir` | The message's base direction, `'ltr'`, `'rtl'` or `'auto'`; detected from the locale when not set. |
319
+ | `localeMatcher` | `'best fit'` or `'lookup'`, the `Intl` locale negotiation each function uses. |
320
+ | `functions` | Functions by name, spread over the draft ones: a new name adds a function, a known name replaces it. |
321
+
322
+ `onReport` is required, `null` included: this package writes to no channel of its own, so where a diagnostic goes is stated by whoever builds the parser. Pass `null` to discard reports.
323
+
324
+ ### Custom Functions
325
+
326
+ A function receives the engine's context (`locales`, `dir`, `localeMatcher`, `onError`), the expression's options, and the operand, and returns a message value: an object naming its `type`, rendering itself with `toString`, and optionally stating its `dir` and selecting a variant key with `selectKey`. `messageformat/functions` exports the `MessageFunction` type, the built-in implementations (`DefaultFunctions`, `DraftFunctions`) and helpers for reading options.
327
+
328
+ ```typescript
329
+ import parser from '@sveltekit-i18n/parser-mf2';
330
+ import type { MessageFunction } from 'messageformat/functions';
331
+
332
+ const upper: MessageFunction<string> = (context, options, input) => ({
333
+ type: 'string',
334
+ dir: 'ltr',
335
+ toString: () => String(input).toUpperCase(),
336
+ });
337
+
338
+ const config = {
339
+ parser: parser({ onReport: null, functions: { upper } }),
340
+ };
341
+ ```
342
+
343
+ ```json
344
+ {
345
+ "shout": "{$name :upper}!"
346
+ }
347
+ ```
348
+
349
+ ```javascript
350
+ i18n.t('shout', { name: 'alice' })
351
+ // → "ALICE!"
352
+ ```
353
+
354
+ A function that throws, or returns something that is no message value, leaves its expression to the [fallback](#reports).
355
+
356
+ ## Reports
357
+
358
+ A report carries `code`, the `key` and `locale` the call was made for, a one-sentence `message`, and the `error` the engine threw or handed back where there was one:
359
+
360
+ | `code` | When |
361
+ | --- | --- |
362
+ | `failed-message` | The message could not be compiled - a syntax error, or a data model error such as a selector without an annotation or a `.match` without a `*` variant - or could not be formatted at all. The raw message is returned. |
363
+ | `fallback-value` | One expression could not be resolved: a variable the payload lacks, a function nobody registered, or a function that rejected its operand or options. The rest of the message rendered, and the output carries the format's fallback for that expression, `{$name}`. |
364
+
365
+ ```javascript
366
+ const config = {
367
+ parser: parser({
368
+ onReport: (report) => logger.warn(report.message, report),
369
+ }),
370
+ };
371
+ ```
372
+
373
+ A report never raises, and neither does a report channel that throws: the failure is contained and the render goes on.
374
+
375
+ ## Caching and Error Handling
376
+
377
+ Compiled messages are cached per parser instance (least-recently-used, up to 10,000 entries keyed by locale and message), so repeated reads of the same message skip recompilation.
378
+
379
+ A key naming no message is nothing to format and yields the empty string; what a missing translation renders as is [`fallbackValue`](https://github.com/sveltekit-i18n/base/blob/master/docs/README.md#fallbackvalue), which base answers before this parser is called.
380
+
381
+ If a message cannot be compiled, the parser does not throw. It reports the failure through [`onReport`](#reports) and returns the raw message, so one broken translation cannot crash your page. An expression that cannot be resolved does not take the message down either: it renders as the format's fallback, `{$name}`, and is reported as `fallback-value`. `parse` always returns a string.
382
+
383
+ ## TypeScript Support
384
+
385
+ ```typescript
386
+ import { I18n } from '@sveltekit-i18n/base';
387
+ import parser from '@sveltekit-i18n/parser-mf2';
388
+ import type { Config, Parser } from '@sveltekit-i18n/parser-mf2';
389
+
390
+ type Payload = { applicationName: string };
391
+
392
+ const config: Config<Payload> = {
393
+ parser: parser({
394
+ onReport: (report: Parser.Report) => { console.warn(report.message); },
395
+ }),
396
+ loaders: [/* ... */],
397
+ };
398
+
399
+ const i18n = new I18n(config);
400
+
401
+ i18n.t('common.welcome', { applicationName: 'My app' })
402
+ // → ok
403
+
404
+ i18n.t('common.welcome', { aplicationName: 'My app' })
405
+ // → type error: typo caught
406
+ ```
407
+
408
+ `Config<Payload>` types the payload `i18n.t` accepts; left bare, `Config` accepts any payload key. `Parser` holds the option, report and parameter types (`Parser.Options`, `Parser.Report`, `Parser.OnReport`, `Parser.Params`, `Parser.Payload`). The library provides type definitions for the parser and configuration, but does not automatically infer translation keys from your JSON files.
409
+
410
+ ## Extracting Parameters
411
+
412
+ What a message expects of its payload is fixed when the message is written, so a catalogue can be read for its parameters instead of them being discovered at render time. `extractParamsFactory` is the build-time half of the base parser contract, a named export beside the default one: a message scanner is of no use while rendering, so the package declares `sideEffects: false` and a bundle that never reaches it drops it.
413
+
414
+ ```typescript
415
+ import { extractParamsFactory } from '@sveltekit-i18n/parser-mf2';
416
+
417
+ const extractParams = extractParamsFactory();
418
+
419
+ extractParams('.input {$count :integer}\n.match $count\n0 {{No photos.}}\n* {{{$count} photos.}}');
420
+ // → [{ name: 'count', kind: 'number', values: ['0'], optional: false }]
421
+ ```
422
+
423
+ Each parameter is reported once, in the order the message first names it, and says what every expression naming it says together. `name` is the payload key, arbitrary text rather than an identifier, so whatever writes it down quotes it. `kind` is what the function annotating the expression narrows the value to:
424
+
425
+ | Expression | `kind` |
426
+ | --- | --- |
427
+ | `{$value}` | `'unknown'` |
428
+ | `{$value :number}`, `:integer`, `:offset`, `:currency`, `:percent`, `:unit` | `'number'` |
429
+ | `{$value :string}` | `'string'` |
430
+ | `{$value :date}`, `:datetime`, `:time` | `'date'` - a `Date` or milliseconds since the epoch |
431
+ | `{$value :custom}` | `'unknown'` - a function the specification does not define narrows nothing |
432
+ | `minimumFractionDigits=$digits`, `{#b class=$cls}` | `'unknown'` - an option is the function's, or the markup's, to read |
433
+
434
+ A parameter several expressions name accepts what all of them say together, and `unknown` is the top of that lattice rather than a member of it: it is what a parameter accepts while nothing has narrowed it, and it drops out the moment something does. So `{$value}` alone reports `unknown`, while `{$value} of {$value :number}` reports `'number'`.
435
+
436
+ A declaration names a parameter too. `.input {$count :integer}` annotates `count` for the whole message; `.local $total = {$price :number}` is the message's own variable, never reported, but `price`, which its expression reads, is - with the kind the local's function gives it, wherever the local is used. A selector names its parameter outright, through a local where it is declared as one. `values` lists the variant keys of a `:string` selector and the exact numeric keys of a numeric one, which are the keys that are values of the parameter - `one`/`few`/`other` are categories the locale decides for a number the caller does not choose, and `*` is the catch-all rather than a value. It is a hint and never a closed set: a value none of them matches takes the `*` variant rather than failing.
437
+
438
+ `optional` reports a parameter every expression naming it puts inside a variant, since only some variants of the message use it, and `when` names those variants - one `{ param, branch }` per selector, `branch` being the key or `*` - so a generator can emit a discriminated payload instead of the flat approximation. Named outside every variant once, in a declaration, as a selector or in a pattern message, the parameter is expected outright.
439
+
440
+ Build the extractor from the same options `parser()` is built from, for symmetry: the syntax is the specification's and a custom function narrows nothing, so no option changes what a message names. `onReport` is not among them - extraction reports nothing.
441
+
442
+ Only the text of a message is scanned. A translation leaf that is not text names no parameters rather than throwing, and neither does a message this parser cannot compile.
443
+
444
+ ## Examples
445
+
446
+ ### Complete Multi-page App
447
+
448
+ ```typescript
449
+ // src/lib/translations/index.ts
450
+ import { I18n } from '@sveltekit-i18n/base';
451
+ import parser from '@sveltekit-i18n/parser-mf2';
452
+ import type { Config } from '@sveltekit-i18n/parser-mf2';
453
+
454
+ const config: Config = {
455
+ parser: parser({ onReport: null }),
456
+ loaders: [
457
+ {
458
+ locale: 'en',
459
+ key: 'common',
460
+ loader: async () => (await import('./en/common.json')).default,
461
+ },
462
+ {
463
+ locale: 'en',
464
+ key: 'home',
465
+ routes: ['/'],
466
+ loader: async () => (await import('./en/home.json')).default,
467
+ },
468
+ {
469
+ locale: 'cs',
470
+ key: 'common',
471
+ loader: async () => (await import('./cs/common.json')).default,
472
+ },
473
+ {
474
+ locale: 'cs',
475
+ key: 'home',
476
+ routes: ['/'],
477
+ loader: async () => (await import('./cs/home.json')).default,
478
+ },
479
+ ],
480
+ };
481
+
482
+ export const i18n = new I18n(config);
483
+ ```
484
+
485
+ ```json
486
+ // src/lib/translations/en/common.json
487
+ {
488
+ "app.name": "My App",
489
+ "nav.home": "Home",
490
+ "nav.about": "About",
491
+ "items": ".input {$count :integer}\n.match $count\n0 {{You have no items.}}\none {{You have one item.}}\n* {{You have {$count} items.}}"
492
+ }
493
+ ```
494
+
495
+ ```svelte
496
+ <!-- src/routes/+page.svelte -->
497
+ <script>
498
+ import { i18n } from '$lib/translations';
499
+
500
+ let cartItems = 3;
501
+ </script>
502
+
503
+ <h1>{i18n.t('common.app.name')}</h1>
504
+ <p>Current locale: {i18n.locale}</p>
505
+ <p>{i18n.t('common.items', { count: cartItems })}</p>
506
+ ```
507
+
508
+ ## Comparison
509
+
510
+ **MessageFormat 2 (parser-mf2):**
511
+ ```json
512
+ {
513
+ "items": ".input {$count :integer}\n.match $count\n0 {{no items}}\none {{one item}}\n* {{{$count} items}}"
514
+ }
515
+ ```
516
+
517
+ **ICU (parser-icu):**
518
+ ```json
519
+ {
520
+ "items": "{count, plural, =0 {no items} one {one item} other {# items}}"
521
+ }
522
+ ```
523
+
524
+ **Curly (parser-curly):**
525
+ ```json
526
+ {
527
+ "items": "{{count; 0:no items; 1:one item; default:{{count}} items;}}"
528
+ }
529
+ ```
530
+
531
+ All three achieve the same result. MessageFormat 2 is the Unicode standard's current format, ICU the one it succeeds, and Curly the smallest of the three; choose based on your preference and requirements.
532
+
533
+ ## More Resources
534
+
535
+ - 📖 [Unicode MessageFormat 2](https://unicode.org/reports/tr35/tr35-messageFormat.html) – The specification
536
+ - 📚 [messageformat](https://github.com/messageformat/messageformat) – The reference implementation
537
+ - 🌐 [sveltekit-i18n.github.io](https://sveltekit-i18n.github.io) – The documentation site, with a live playground
538
+ - 🎨 [All Parsers](https://github.com/sveltekit-i18n/parsers) – Parser overview
539
+ - 💡 [Examples](https://github.com/sveltekit-i18n/lib/tree/master/examples) – Working examples
540
+ - 📋 [Changelog](./CHANGELOG.md) – Version history
541
+
542
+ ## Issues
543
+
544
+ If you're facing issues with this parser, create a ticket [here](https://github.com/sveltekit-i18n/lib/issues).
545
+
546
+ ## Sponsor
547
+
548
+ You can support the maintenance of this package through
549
+ [GitHub Sponsors](https://github.com/sponsors/sveltekit-i18n).
550
+
551
+ ## License
552
+
553
+ MIT
@@ -0,0 +1,32 @@
1
+ import { Parser as Parser$1, Config as Config$1 } from '@sveltekit-i18n/base';
2
+ import { MessageFormatOptions } from 'messageformat';
3
+
4
+ declare namespace Parser {
5
+ type PayloadDefault = Record<string, any>;
6
+ type Payload<T = PayloadDefault> = T;
7
+ type Params<P = PayloadDefault> = [payload?: Payload<P>];
8
+ type ReportCode = 'failed-message' | 'fallback-value';
9
+ type Report = {
10
+ code: ReportCode;
11
+ key: Parser$1.Key;
12
+ locale: Parser$1.Locale;
13
+ message: string;
14
+ error?: unknown;
15
+ };
16
+ type OnReport = (report: Report) => void;
17
+ type Options = MessageFormatOptions<string> & {
18
+ onReport: OnReport | null | undefined;
19
+ };
20
+ type T = Parser$1.T<Params, string>;
21
+ type Factory = (options: Options) => T;
22
+ type ExtractOptions = Omit<Options, 'onReport'>;
23
+ type ExtractParams = Parser$1.ExtractParams;
24
+ type ExtractParamsFactory = Parser$1.ExtractParamsFactory<ExtractOptions>;
25
+ }
26
+ type Config<Payload = Parser.PayloadDefault> = Config$1.T<Parser.Params<Payload>, string>;
27
+
28
+ declare const extractParamsFactory: Parser.ExtractParamsFactory;
29
+
30
+ declare const parser: Parser.Factory;
31
+
32
+ export { type Config, Parser, parser as default, extractParamsFactory };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{MessageFormat as b}from"messageformat";import{DraftFunctions as x}from"messageformat/functions";import{parseMessage as k,validate as w}from"messageformat";var v=i=>{switch(i){case"number":case"integer":case"offset":case"currency":case"percent":case"unit":return"number";case"string":return"string";case"date":case"datetime":case"time":return"date";default:return"unknown"}},B=/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/,E=(i,a)=>{let m=Array.isArray(i)?i:[i],u=a==="unknown"?m:[...m.filter(p=>p!=="unknown"),a],s=[...new Set(u)];return s.length===1?s[0]:s},K=()=>i=>{if(typeof i!="string")return[];let a;try{a=k(i),w(a)}catch{return[]}let m=new Map,u=e=>m.get(e)??{param:e};a.declarations.forEach(({type:e,name:t,value:{arg:r,functionRef:n}})=>{let d=e==="input"?{param:t}:r?.type==="variable"?u(r.name):{};m.set(t,{param:d.param,fn:n?.name??d.fn})});let s=[],p=new Map,y=(e,t,r,n)=>{let d=p.get(e);if(d===void 0){s.push(e),p.set(e,{name:e,kind:t,...n?{values:n}:{},optional:r.length>0,...r.length>0?{when:r}:{}});return}let{when:P,...h}=d,g=r.length>0&&P!==void 0;p.set(e,{...h,kind:E(d.kind??"unknown",t),...n?{values:[...new Set([...d.values??[],...n])]}:{},optional:g,...g?{when:P}:{}})},o=(e,t)=>{Object.values(e??{}).forEach(r=>{if(r.type!=="variable")return;let{param:n}=u(r.name);n!==void 0&&y(n,"unknown",t)})},l=({arg:e,functionRef:t},r)=>{if(e?.type==="variable"){let{param:n}=u(e.name);n!==void 0&&y(n,v(t?.name),r)}o(t?.options,r)},f=(e,t)=>{e.forEach(r=>{if(typeof r!="string"){if(r.type==="markup"){o(r.options,t);return}l(r,t)}})};if(a.declarations.forEach(({value:e})=>l(e,[])),a.type==="message")return f(a.pattern,[]),s.map(e=>p.get(e));let c=a.selectors.map(({name:e},t)=>{let{param:r,fn:n}=u(e),d=v(n),P=a.variants.flatMap(({keys:g})=>{let M=g[t];return M?.type==="literal"?[M.value]:[]}),h=d==="string"?P:d==="number"?P.filter(g=>B.test(g)):[];return r!==void 0&&y(r,d,[],h.length>0?h:void 0),r??e});return a.variants.forEach(({keys:e,value:t})=>{f(t,e.map((r,n)=>({param:c[n],branch:r.type==="*"?"*":r.value})))}),s.map(e=>p.get(e))};var F=1e4,S=({onReport:i,functions:a,...m})=>{let u={...m,functions:{...x,...a}},s=new Map,p=o=>{if(i)try{i(o)}catch{return}},y=(o,l)=>{if(typeof o!="string")return new b(l,o,u);let f=`${l}\0${o}`,c=s.get(f);if(c===void 0){if(c=new b(l,o,u),s.size>=F){let e=s.keys().next().value;e!==void 0&&s.delete(e)}}else s.delete(f);return s.set(f,c),c};return{parse:(o,[l],f,c)=>{if(o===void 0)return"";try{return y(o,f).format(l,e=>{p({code:"fallback-value",key:c,locale:f,message:`An expression in the message for key '${c}' could not be resolved and was rendered as its fallback.`,error:e})})}catch(e){return p({code:"failed-message",key:c,locale:f,message:`Message for key '${c}' could not be formatted and was returned raw.`,error:e}),String(o)}}}},I=S;export{I as default,K as extractParamsFactory};
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@sveltekit-i18n/parser-mf2",
3
+ "version": "3.0.0",
4
+ "description": "Unicode MessageFormat 2 parser compatible with sveltekit-i18n library.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.12",
17
+ "bun": ">=1.2",
18
+ "deno": ">=2"
19
+ },
20
+ "scripts": {
21
+ "dev": "tsup --watch",
22
+ "typecheck": "tsc --noEmit -p tsconfig.json",
23
+ "pretest": "npm run build && npm run typecheck",
24
+ "test": "vitest run",
25
+ "build": "tsup",
26
+ "prepublishOnly": "npm run build",
27
+ "lint": "eslint --fix .",
28
+ "prepare": "cd .. && simple-git-hooks parser-mf2/.simple-git-hooks.json"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+ssh://git@github.com/sveltekit-i18n/parsers.git",
36
+ "directory": "parser-mf2"
37
+ },
38
+ "keywords": [
39
+ "parser",
40
+ "mf2",
41
+ "messageformat",
42
+ "messageformat2",
43
+ "unicode",
44
+ "sveltekit-i18n"
45
+ ],
46
+ "author": "Jarda Svoboda",
47
+ "license": "MIT",
48
+ "bugs": {
49
+ "url": "https://github.com/sveltekit-i18n/lib/issues"
50
+ },
51
+ "homepage": "https://sveltekit-i18n.github.io",
52
+ "funding": "https://github.com/sponsors/sveltekit-i18n",
53
+ "peerDependencies": {
54
+ "@sveltekit-i18n/base": "^3.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@eslint/js": "^10.0.1",
58
+ "@stylistic/eslint-plugin": "^5.10.0",
59
+ "@sveltekit-i18n/base": "^3.0.0",
60
+ "@types/node": "^22.20.3",
61
+ "eslint": "^10.8.1",
62
+ "eslint-plugin-import-x": "^4.17.1",
63
+ "globals": "^17.11.0",
64
+ "simple-git-hooks": "^2.13.1",
65
+ "tsup": "^8.0.1",
66
+ "typescript": "^5.1.6",
67
+ "typescript-eslint": "^8.67.0",
68
+ "vitest": "^4.1.10"
69
+ },
70
+ "overrides": {
71
+ "esbuild": "^0.28.1"
72
+ },
73
+ "dependencies": {
74
+ "messageformat": "^4.0.0"
75
+ }
76
+ }