@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 +21 -0
- package/README.md +553 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +1 -0
- package/package.json +76 -0
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
|
+
[](https://badge.fury.io/js/@sveltekit-i18n%2Fparser-mf2) [](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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|