@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.
- package/LICENSE.md +165 -0
- package/package.json +29 -0
- package/src/catalog.ts +92 -0
- package/src/catalogs/en.json +2846 -0
- package/src/catalogs/index.ts +12 -0
- package/src/index.ts +38 -0
- package/src/locale.ts +116 -0
- package/src/message.ts +450 -0
- package/src/msg.ts +23 -0
- package/src/timestamp.ts +97 -0
- package/src/translator.ts +88 -0
|
@@ -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
|
+
}
|