@meith/i18n 0.7.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.
@@ -0,0 +1,12 @@
1
+ import type { CatalogSource, MessageCatalog } from '../catalog'
2
+ import { SOURCE_LOCALE } from '../locale'
3
+ import en from './en.json'
4
+
5
+ export const BOARD_CATALOG_ID = 'meith'
6
+
7
+ export const EN_CATALOG: MessageCatalog = en
8
+
9
+ export const BOARD_CATALOG: CatalogSource = {
10
+ id: BOARD_CATALOG_ID,
11
+ messages: { [SOURCE_LOCALE]: EN_CATALOG },
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export {
2
+ type CatalogBundle,
3
+ type CatalogRegistry,
4
+ type CatalogSource,
5
+ createCatalogRegistry,
6
+ type MessageCatalog,
7
+ } from './catalog'
8
+ export { BOARD_CATALOG, BOARD_CATALOG_ID, EN_CATALOG } from './catalogs/index'
9
+ export {
10
+ type AcceptedLocale,
11
+ type Locale,
12
+ localeChain,
13
+ localeDirection,
14
+ negotiateLocale,
15
+ normaliseLocale,
16
+ parseAcceptLanguage,
17
+ SOURCE_LOCALE,
18
+ } from './locale'
19
+ export {
20
+ formatMessage,
21
+ type MessageArgs,
22
+ type MessageContext,
23
+ MessageSyntaxError,
24
+ type MessageValue,
25
+ messagePlaceholders,
26
+ parseMessage,
27
+ pluralCategory,
28
+ } from './message'
29
+ export { type CatalogMessage, msg } from './msg'
30
+ export { formatDay, formatTimestamp, type TimestampLabel } from './timestamp'
31
+ export {
32
+ createTranslator,
33
+ DEFAULT_TIME_ZONE,
34
+ sourceTranslator,
35
+ type Translator,
36
+ type TranslatorInput,
37
+ usableTimeZone,
38
+ } from './translator'
package/src/locale.ts ADDED
@@ -0,0 +1,116 @@
1
+ export type Locale = string
2
+
3
+ export const SOURCE_LOCALE = 'en'
4
+
5
+ const TAG = /^[a-z]{2,3}(?:-[a-z]{4})?(?:-(?:[a-z]{2}|\d{3}))?$/
6
+
7
+ export function normaliseLocale(value: string | null | undefined): Locale | null {
8
+ if (typeof value !== 'string') return null
9
+
10
+ const trimmed = value.trim()
11
+ if (trimmed === '' || trimmed === '*') return null
12
+
13
+ const parts = trimmed.toLowerCase().split(/[-_]/)
14
+ const language = parts[0]
15
+ if (language === undefined) return null
16
+
17
+ const rest = parts
18
+ .slice(1)
19
+ .map((part) =>
20
+ part.length === 4
21
+ ? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`
22
+ : part.length === 2
23
+ ? part.toUpperCase()
24
+ : part,
25
+ )
26
+
27
+ const tag = [language, ...rest].join('-')
28
+ return TAG.test(tag.toLowerCase()) ? tag : null
29
+ }
30
+
31
+ export function localeChain(locale: Locale): readonly Locale[] {
32
+ const parts = locale.split('-')
33
+ const chain: Locale[] = []
34
+
35
+ for (let length = parts.length; length > 0; length -= 1) {
36
+ chain.push(parts.slice(0, length).join('-'))
37
+ }
38
+
39
+ return chain
40
+ }
41
+
42
+ export interface AcceptedLocale {
43
+ readonly locale: Locale
44
+ readonly quality: number
45
+ }
46
+
47
+ export function parseAcceptLanguage(header: string | null | undefined): readonly AcceptedLocale[] {
48
+ if (typeof header !== 'string') return []
49
+
50
+ const accepted: AcceptedLocale[] = []
51
+
52
+ for (const entry of header.split(',')) {
53
+ const [tag, ...parameters] = entry.split(';')
54
+ const locale = normaliseLocale(tag)
55
+ if (locale === null) continue
56
+
57
+ const quality = parameters.reduce((carried, parameter) => {
58
+ const match = /^\s*q\s*=\s*([\d.]+)\s*$/.exec(parameter)
59
+ if (match?.[1] === undefined) return carried
60
+ const parsed = Number.parseFloat(match[1])
61
+ return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : carried
62
+ }, 1)
63
+
64
+ if (quality > 0) accepted.push({ locale, quality })
65
+ }
66
+
67
+ return accepted
68
+ .map((entry, index) => ({ entry, index }))
69
+ .sort((a, b) => b.entry.quality - a.entry.quality || a.index - b.index)
70
+ .map(({ entry }) => entry)
71
+ }
72
+
73
+ export function negotiateLocale(input: {
74
+ readonly requested: readonly (Locale | null | undefined)[]
75
+ readonly supported: readonly Locale[]
76
+ readonly fallback: Locale
77
+ }): Locale {
78
+ const supported = new Map(input.supported.map((locale) => [locale.toLowerCase(), locale]))
79
+
80
+ for (const candidate of input.requested) {
81
+ const locale = normaliseLocale(candidate)
82
+ if (locale === null) continue
83
+
84
+ for (const step of localeChain(locale)) {
85
+ const match = supported.get(step.toLowerCase())
86
+ if (match !== undefined) return match
87
+ }
88
+
89
+ const prefix = `${locale.split('-')[0]?.toLowerCase() ?? ''}-`
90
+ for (const [key, value] of supported) {
91
+ if (key.startsWith(prefix)) return value
92
+ }
93
+ }
94
+
95
+ return supported.get(input.fallback.toLowerCase()) ?? input.fallback
96
+ }
97
+
98
+ export function localeDirection(locale: Locale): 'ltr' | 'rtl' {
99
+ const language = locale.split('-')[0]?.toLowerCase() ?? ''
100
+ return RIGHT_TO_LEFT.has(language) ? 'rtl' : 'ltr'
101
+ }
102
+
103
+ const RIGHT_TO_LEFT = new Set([
104
+ 'ar',
105
+ 'arc',
106
+ 'ckb',
107
+ 'dv',
108
+ 'fa',
109
+ 'he',
110
+ 'ku',
111
+ 'ps',
112
+ 'sd',
113
+ 'ug',
114
+ 'ur',
115
+ 'yi',
116
+ ])
package/src/message.ts ADDED
@@ -0,0 +1,450 @@
1
+ import type { Locale } from './locale'
2
+
3
+ export type MessageValue = string | number | bigint | boolean | Date | null | undefined
4
+
5
+ export type MessageArgs = Readonly<Record<string, MessageValue>>
6
+
7
+ export class MessageSyntaxError extends Error {
8
+ constructor(
9
+ message: string,
10
+ readonly pattern: string,
11
+ readonly offset: number,
12
+ ) {
13
+ super(`${message} (offset ${offset}) in ${JSON.stringify(pattern)}`)
14
+ this.name = 'MessageSyntaxError'
15
+ }
16
+ }
17
+
18
+ type Node =
19
+ | { readonly kind: 'text'; readonly value: string }
20
+ | { readonly kind: 'hash' }
21
+ | { readonly kind: 'value'; readonly name: string }
22
+ | { readonly kind: 'number'; readonly name: string; readonly style: string | null }
23
+ | { readonly kind: 'date'; readonly name: string; readonly style: string | null }
24
+ | { readonly kind: 'time'; readonly name: string; readonly style: string | null }
25
+ | {
26
+ readonly kind: 'plural'
27
+ readonly name: string
28
+ readonly ordinal: boolean
29
+ readonly offset: number
30
+ readonly branches: ReadonlyMap<string, readonly Node[]>
31
+ }
32
+ | {
33
+ readonly kind: 'select'
34
+ readonly name: string
35
+ readonly branches: ReadonlyMap<string, readonly Node[]>
36
+ }
37
+
38
+ const PLURAL_CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other'])
39
+
40
+ class Parser {
41
+ private at = 0
42
+
43
+ constructor(private readonly pattern: string) {}
44
+
45
+ parse(): readonly Node[] {
46
+ const nodes = this.parseNodes(false)
47
+ if (this.at < this.pattern.length) {
48
+ throw new MessageSyntaxError('unbalanced "}"', this.pattern, this.at)
49
+ }
50
+ return nodes
51
+ }
52
+
53
+ private parseNodes(nested: boolean): readonly Node[] {
54
+ const nodes: Node[] = []
55
+ let text = ''
56
+
57
+ const flush = () => {
58
+ if (text !== '') nodes.push({ kind: 'text', value: text })
59
+ text = ''
60
+ }
61
+
62
+ while (this.at < this.pattern.length) {
63
+ const char = this.pattern[this.at]
64
+
65
+ if (char === '}') break
66
+
67
+ if (char === '{') {
68
+ flush()
69
+ nodes.push(this.parseArgument())
70
+ continue
71
+ }
72
+
73
+ if (char === '#' && nested) {
74
+ flush()
75
+ nodes.push({ kind: 'hash' })
76
+ this.at += 1
77
+ continue
78
+ }
79
+
80
+ if (char === "'") {
81
+ text += this.readQuoted()
82
+ continue
83
+ }
84
+
85
+ text += char
86
+ this.at += 1
87
+ }
88
+
89
+ flush()
90
+ return nodes
91
+ }
92
+
93
+ private readQuoted(): string {
94
+ const next = this.pattern[this.at + 1]
95
+
96
+ if (next === "'") {
97
+ this.at += 2
98
+ return "'"
99
+ }
100
+
101
+ if (next !== '{' && next !== '}' && next !== '#') {
102
+ this.at += 1
103
+ return "'"
104
+ }
105
+
106
+ this.at += 1
107
+ let literal = ''
108
+
109
+ while (this.at < this.pattern.length) {
110
+ if (this.pattern[this.at] === "'") {
111
+ if (this.pattern[this.at + 1] === "'") {
112
+ literal += "'"
113
+ this.at += 2
114
+ continue
115
+ }
116
+ this.at += 1
117
+ return literal
118
+ }
119
+ literal += this.pattern[this.at]
120
+ this.at += 1
121
+ }
122
+
123
+ return literal
124
+ }
125
+
126
+ private parseArgument(): Node {
127
+ const opened = this.at
128
+ this.at += 1
129
+ this.skipSpace()
130
+
131
+ const name = this.readName()
132
+ if (name === '') throw new MessageSyntaxError('placeholder with no name', this.pattern, opened)
133
+
134
+ this.skipSpace()
135
+
136
+ if (this.pattern[this.at] === '}') {
137
+ this.at += 1
138
+ return { kind: 'value', name }
139
+ }
140
+
141
+ this.expect(',', opened)
142
+ this.skipSpace()
143
+
144
+ const type = this.readName()
145
+ this.skipSpace()
146
+
147
+ if (type === 'plural' || type === 'selectordinal') {
148
+ this.expect(',', opened)
149
+ const offset = this.readOffset()
150
+ const branches = this.parseBranches(opened, true)
151
+ return { kind: 'plural', name, ordinal: type === 'selectordinal', offset, branches }
152
+ }
153
+
154
+ if (type === 'select') {
155
+ this.expect(',', opened)
156
+ const branches = this.parseBranches(opened, false)
157
+ return { kind: 'select', name, branches }
158
+ }
159
+
160
+ let style: string | null = null
161
+ if (this.pattern[this.at] === ',') {
162
+ this.at += 1
163
+ this.skipSpace()
164
+ style = this.readUntilClose()
165
+ }
166
+
167
+ this.expect('}', opened)
168
+
169
+ if (type === 'number') return { kind: 'number', name, style }
170
+ if (type === 'date') return { kind: 'date', name, style }
171
+ if (type === 'time') return { kind: 'time', name, style }
172
+
173
+ throw new MessageSyntaxError(`unknown placeholder type "${type}"`, this.pattern, opened)
174
+ }
175
+
176
+ private parseBranches(opened: number, plural: boolean): ReadonlyMap<string, readonly Node[]> {
177
+ const branches = new Map<string, readonly Node[]>()
178
+
179
+ for (;;) {
180
+ this.skipSpace()
181
+ if (this.pattern[this.at] === '}') {
182
+ this.at += 1
183
+ break
184
+ }
185
+ if (this.at >= this.pattern.length) {
186
+ throw new MessageSyntaxError('unterminated placeholder', this.pattern, opened)
187
+ }
188
+
189
+ const selector = this.readSelector()
190
+ if (selector === '') {
191
+ throw new MessageSyntaxError('branch with no selector', this.pattern, this.at)
192
+ }
193
+ if (plural && !selector.startsWith('=') && !PLURAL_CATEGORIES.has(selector)) {
194
+ throw new MessageSyntaxError(`unknown plural category "${selector}"`, this.pattern, this.at)
195
+ }
196
+
197
+ this.skipSpace()
198
+ this.expect('{', opened)
199
+ branches.set(selector, this.parseNodes(plural))
200
+ this.expect('}', opened)
201
+ }
202
+
203
+ if (!branches.has('other')) {
204
+ throw new MessageSyntaxError('no "other" branch', this.pattern, opened)
205
+ }
206
+
207
+ return branches
208
+ }
209
+
210
+ private readOffset(): number {
211
+ const match = /^\s*offset\s*:\s*(-?\d+)/.exec(this.pattern.slice(this.at))
212
+ if (match?.[1] === undefined) return 0
213
+ this.at += match[0].length
214
+ return Number.parseInt(match[1], 10)
215
+ }
216
+
217
+ private readName(): string {
218
+ const start = this.at
219
+ while (this.at < this.pattern.length && /[\w.-]/.test(this.pattern[this.at] ?? '')) {
220
+ this.at += 1
221
+ }
222
+ return this.pattern.slice(start, this.at)
223
+ }
224
+
225
+ private readSelector(): string {
226
+ const start = this.at
227
+ while (this.at < this.pattern.length && /[\w.=-]/.test(this.pattern[this.at] ?? '')) {
228
+ this.at += 1
229
+ }
230
+ return this.pattern.slice(start, this.at)
231
+ }
232
+
233
+ private readUntilClose(): string {
234
+ const start = this.at
235
+ let depth = 0
236
+ while (this.at < this.pattern.length) {
237
+ const char = this.pattern[this.at]
238
+ if (char === '{') depth += 1
239
+ else if (char === '}') {
240
+ if (depth === 0) break
241
+ depth -= 1
242
+ }
243
+ this.at += 1
244
+ }
245
+ return this.pattern.slice(start, this.at).trim()
246
+ }
247
+
248
+ private skipSpace(): void {
249
+ while (/\s/.test(this.pattern[this.at] ?? '')) this.at += 1
250
+ }
251
+
252
+ private expect(char: string, opened: number): void {
253
+ if (this.pattern[this.at] !== char) {
254
+ throw new MessageSyntaxError(`expected "${char}"`, this.pattern, this.at || opened)
255
+ }
256
+ this.at += 1
257
+ }
258
+ }
259
+
260
+ const parsed = new Map<string, readonly Node[]>()
261
+
262
+ export function parseMessage(pattern: string): readonly Node[] {
263
+ const cached = parsed.get(pattern)
264
+ if (cached !== undefined) return cached
265
+
266
+ const nodes = new Parser(pattern).parse()
267
+ parsed.set(pattern, nodes)
268
+ return nodes
269
+ }
270
+
271
+ export interface MessageContext {
272
+ readonly locale: Locale
273
+ readonly timeZone: string
274
+ }
275
+
276
+ export function formatMessage(pattern: string, args: MessageArgs, context: MessageContext): string {
277
+ return render(parseMessage(pattern), args, context, null)
278
+ }
279
+
280
+ function render(
281
+ nodes: readonly Node[],
282
+ args: MessageArgs,
283
+ context: MessageContext,
284
+ hash: number | null,
285
+ ): string {
286
+ let out = ''
287
+
288
+ for (const node of nodes) {
289
+ switch (node.kind) {
290
+ case 'text':
291
+ out += node.value
292
+ break
293
+ case 'hash':
294
+ out += hash === null ? '#' : numberFormat(context.locale, null).format(hash)
295
+ break
296
+ case 'value':
297
+ out += stringify(args[node.name])
298
+ break
299
+ case 'number':
300
+ out += numberFormat(context.locale, node.style).format(numeric(args[node.name]))
301
+ break
302
+ case 'date':
303
+ out += dateFormat(context, node.style, 'date').format(temporal(args[node.name]))
304
+ break
305
+ case 'time':
306
+ out += dateFormat(context, node.style, 'time').format(temporal(args[node.name]))
307
+ break
308
+ case 'select': {
309
+ const branch =
310
+ node.branches.get(stringify(args[node.name])) ?? node.branches.get('other') ?? []
311
+ out += render(branch, args, context, hash)
312
+ break
313
+ }
314
+ case 'plural': {
315
+ const value = numeric(args[node.name])
316
+ const shifted = value - node.offset
317
+ const exact = node.branches.get(`=${value}`)
318
+ const branch =
319
+ exact ??
320
+ node.branches.get(pluralCategory(context.locale, shifted, node.ordinal)) ??
321
+ node.branches.get('other') ??
322
+ []
323
+ out += render(branch, args, context, shifted)
324
+ break
325
+ }
326
+ }
327
+ }
328
+
329
+ return out
330
+ }
331
+
332
+ function stringify(value: MessageValue): string {
333
+ if (value === null || value === undefined) return ''
334
+ if (value instanceof Date) return value.toISOString()
335
+ return String(value)
336
+ }
337
+
338
+ function numeric(value: MessageValue): number {
339
+ if (typeof value === 'number') return value
340
+ if (typeof value === 'bigint') return Number(value)
341
+ if (typeof value === 'string') {
342
+ const parsedValue = Number(value)
343
+ return Number.isFinite(parsedValue) ? parsedValue : 0
344
+ }
345
+ return 0
346
+ }
347
+
348
+ function temporal(value: MessageValue): Date {
349
+ if (value instanceof Date) return value
350
+ if (typeof value === 'number') return new Date(value)
351
+ if (typeof value === 'string') return new Date(value)
352
+ return new Date(0)
353
+ }
354
+
355
+ const numberFormats = new Map<string, Intl.NumberFormat>()
356
+
357
+ function numberFormat(locale: Locale, style: string | null): Intl.NumberFormat {
358
+ const key = `${locale}\u001f${style ?? ''}`
359
+ const cached = numberFormats.get(key)
360
+ if (cached !== undefined) return cached
361
+
362
+ const format = new Intl.NumberFormat(locale, numberOptions(style))
363
+ numberFormats.set(key, format)
364
+ return format
365
+ }
366
+
367
+ function numberOptions(style: string | null): Intl.NumberFormatOptions {
368
+ if (style === 'integer') return { maximumFractionDigits: 0 }
369
+ if (style === 'percent') return { style: 'percent' }
370
+ if (style === 'compact') return { notation: 'compact' }
371
+ if (style?.startsWith('::')) return skeletonOptions(style.slice(2))
372
+ return {}
373
+ }
374
+
375
+ function skeletonOptions(skeleton: string): Intl.NumberFormatOptions {
376
+ const options: Intl.NumberFormatOptions = {}
377
+
378
+ for (const token of skeleton.split(/\s+/)) {
379
+ if (token === 'percent') options.style = 'percent'
380
+ else if (token === 'group-off') options.useGrouping = false
381
+ else if (token.startsWith('currency/')) {
382
+ options.style = 'currency'
383
+ options.currency = token.slice('currency/'.length)
384
+ } else if (/^\.0+$/.test(token)) {
385
+ options.minimumFractionDigits = token.length - 1
386
+ options.maximumFractionDigits = token.length - 1
387
+ } else if (/^\.#+$/.test(token)) {
388
+ options.maximumFractionDigits = token.length - 1
389
+ }
390
+ }
391
+
392
+ return options
393
+ }
394
+
395
+ const dateFormats = new Map<string, Intl.DateTimeFormat>()
396
+
397
+ function dateFormat(
398
+ context: MessageContext,
399
+ style: string | null,
400
+ kind: 'date' | 'time',
401
+ ): Intl.DateTimeFormat {
402
+ const key = `${context.locale}\u001f${context.timeZone}\u001f${kind}\u001f${style ?? ''}`
403
+ const cached = dateFormats.get(key)
404
+ if (cached !== undefined) return cached
405
+
406
+ const width = style === null || style === '' ? 'medium' : style
407
+ const options: Intl.DateTimeFormatOptions =
408
+ kind === 'date'
409
+ ? { dateStyle: dateStyle(width), timeZone: context.timeZone }
410
+ : { timeStyle: dateStyle(width), timeZone: context.timeZone }
411
+
412
+ const format = new Intl.DateTimeFormat(context.locale, options)
413
+ dateFormats.set(key, format)
414
+ return format
415
+ }
416
+
417
+ function dateStyle(width: string): 'full' | 'long' | 'medium' | 'short' {
418
+ if (width === 'full' || width === 'long' || width === 'short') return width
419
+ return 'medium'
420
+ }
421
+
422
+ const pluralRules = new Map<string, Intl.PluralRules>()
423
+
424
+ export function pluralCategory(locale: Locale, value: number, ordinal = false): string {
425
+ const key = `${locale}\u001f${ordinal ? 'ordinal' : 'cardinal'}`
426
+ let rules = pluralRules.get(key)
427
+
428
+ if (rules === undefined) {
429
+ rules = new Intl.PluralRules(locale, { type: ordinal ? 'ordinal' : 'cardinal' })
430
+ pluralRules.set(key, rules)
431
+ }
432
+
433
+ return rules.select(value)
434
+ }
435
+
436
+ export function messagePlaceholders(pattern: string): ReadonlySet<string> {
437
+ const names = new Set<string>()
438
+ collect(parseMessage(pattern), names)
439
+ return names
440
+ }
441
+
442
+ function collect(nodes: readonly Node[], into: Set<string>): void {
443
+ for (const node of nodes) {
444
+ if (node.kind === 'text' || node.kind === 'hash') continue
445
+ into.add(node.name)
446
+ if (node.kind === 'plural' || node.kind === 'select') {
447
+ for (const branch of node.branches.values()) collect(branch, into)
448
+ }
449
+ }
450
+ }
package/src/msg.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { EN_CATALOG } from './catalogs/index'
2
+ import { formatMessage, type MessageArgs } from './message'
3
+
4
+ export interface CatalogMessage {
5
+ readonly key: string
6
+ readonly args?: Readonly<Record<string, string | number>> | undefined
7
+ readonly text: string
8
+ }
9
+
10
+ export function msg(key: string, args?: Readonly<Record<string, string | number>>): CatalogMessage {
11
+ const pattern = EN_CATALOG[key]
12
+
13
+ let text = key
14
+ if (pattern !== undefined) {
15
+ try {
16
+ text = formatMessage(pattern, args as MessageArgs, { locale: 'en', timeZone: 'UTC' })
17
+ } catch {
18
+ text = pattern
19
+ }
20
+ }
21
+
22
+ return args === undefined ? { key, text } : { key, args, text }
23
+ }