@miloun/cosmo 0.1.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 +73 -0
- package/changelog.md +31 -0
- package/dist/cosmo.d.ts +381 -0
- package/dist/cosmo.d.ts.map +1 -0
- package/dist/cosmo.js +747 -0
- package/dist/cosmo.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +33 -0
- package/dist/errors.js.map +1 -0
- package/dist/helper.d.ts +7 -0
- package/dist/helper.d.ts.map +1 -0
- package/dist/helper.js +9 -0
- package/dist/helper.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/message.d.ts +6 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/message.js +193 -0
- package/dist/message.js.map +1 -0
- package/package.json +69 -0
package/dist/cosmo.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cosmo — application localisation for JavaScript / Node.
|
|
3
|
+
*
|
|
4
|
+
* A thin, ergonomic layer over the standard `Intl` API. It bundles **no** locale
|
|
5
|
+
* data of its own — every result comes straight from the JavaScript engine's ICU.
|
|
6
|
+
* Features that depend on ICU facilities the `Intl` API does not expose (RBNF
|
|
7
|
+
* spellout/ordinal text, CLDR quotation delimiters, raw resource bundles) are
|
|
8
|
+
* intentionally absent rather than reimplemented from hardcoded tables.
|
|
9
|
+
*/
|
|
10
|
+
import { CosmoError, InvalidArgumentError, UnsupportedError } from "./errors.js";
|
|
11
|
+
import { formatMessage } from "./message.js";
|
|
12
|
+
const WIDTH_TO_DATE_STYLE = {
|
|
13
|
+
short: "short",
|
|
14
|
+
medium: "medium",
|
|
15
|
+
long: "long",
|
|
16
|
+
full: "full",
|
|
17
|
+
};
|
|
18
|
+
const WIDTH_TO_UNIT_DISPLAY = {
|
|
19
|
+
none: "long",
|
|
20
|
+
short: "narrow",
|
|
21
|
+
medium: "short",
|
|
22
|
+
long: "long",
|
|
23
|
+
full: "long",
|
|
24
|
+
};
|
|
25
|
+
const VALID_WIDTHS = new Set(["none", "short", "medium", "long", "full"]);
|
|
26
|
+
function assertWidth(width) {
|
|
27
|
+
if (!VALID_WIDTHS.has(width)) {
|
|
28
|
+
throw new InvalidArgumentError(`"${width}" is not a valid format width (use none/short/medium/long/full).`);
|
|
29
|
+
}
|
|
30
|
+
return width;
|
|
31
|
+
}
|
|
32
|
+
/** Normalises an underscore (`en_AU`) or BCP-47 (`en-AU`) tag to a canonical BCP-47 tag. */
|
|
33
|
+
function canonicaliseLocale(input) {
|
|
34
|
+
const raw = (input ?? "").trim() || defaultLocale();
|
|
35
|
+
const bcp47 = raw.replace(/_/g, "-");
|
|
36
|
+
try {
|
|
37
|
+
return Intl.getCanonicalLocales(bcp47)[0] ?? bcp47;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return new Intl.Locale(bcp47).toString();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function defaultLocale() {
|
|
44
|
+
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
45
|
+
}
|
|
46
|
+
function toDate(moment) {
|
|
47
|
+
return moment instanceof Date ? moment : new Date(moment);
|
|
48
|
+
}
|
|
49
|
+
export class Cosmo {
|
|
50
|
+
/** Canonical BCP-47 locale identifier, e.g. `"en-AU"`. */
|
|
51
|
+
locale;
|
|
52
|
+
/** Parsed language / script / region subtags. */
|
|
53
|
+
subtags;
|
|
54
|
+
/** Resolved modifiers (calendar / currency / timeZone). */
|
|
55
|
+
modifiers;
|
|
56
|
+
intlLocale;
|
|
57
|
+
/**
|
|
58
|
+
* @param locale BCP-47 (or underscore-separated `en_AU`) locale identifier. Unicode
|
|
59
|
+
* extensions such as `-u-nu-latn-ca-buddhist` are honoured. Defaults to the
|
|
60
|
+
* runtime locale.
|
|
61
|
+
* @param modifiers Optional `calendar`, `currency`, and `timeZone` overrides.
|
|
62
|
+
*/
|
|
63
|
+
constructor(locale, modifiers = {}) {
|
|
64
|
+
this.locale = canonicaliseLocale(locale);
|
|
65
|
+
this.intlLocale = new Intl.Locale(this.locale);
|
|
66
|
+
this.subtags = {
|
|
67
|
+
language: this.intlLocale.language ?? "",
|
|
68
|
+
script: this.intlLocale.script ?? "",
|
|
69
|
+
region: this.intlLocale.region ?? "",
|
|
70
|
+
};
|
|
71
|
+
this.modifiers = {
|
|
72
|
+
calendar: modifiers.calendar ?? this.intlLocale.calendar ?? null,
|
|
73
|
+
currency: modifiers.currency ?? null,
|
|
74
|
+
timeZone: modifiers.timeZone ?? null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds a Cosmo from locale subtags instead of a string.
|
|
79
|
+
* @example Cosmo.fromSubtags({ language: "en", region: "AU" })
|
|
80
|
+
*/
|
|
81
|
+
static fromSubtags(subtags, modifiers = {}) {
|
|
82
|
+
// Build via the Intl.Locale options bag (rather than string-joining) so the
|
|
83
|
+
// subtags are validated and canonicalised.
|
|
84
|
+
const tag = new Intl.Locale(subtags.language || "und", {
|
|
85
|
+
script: subtags.script || undefined,
|
|
86
|
+
region: subtags.region || undefined,
|
|
87
|
+
}).toString();
|
|
88
|
+
return new Cosmo(tag, modifiers);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Builds a Cosmo from an HTTP `Accept-Language` header, picking the
|
|
92
|
+
* highest-quality tag.
|
|
93
|
+
*/
|
|
94
|
+
static fromAcceptLanguage(header, modifiers = {}) {
|
|
95
|
+
const best = (header ?? "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((part) => {
|
|
98
|
+
const [tag, ...params] = part.trim().split(";");
|
|
99
|
+
const q = params.find((p) => p.trim().startsWith("q="));
|
|
100
|
+
return { tag: (tag ?? "").trim(), q: q ? Number(q.split("=")[1]) : 1 };
|
|
101
|
+
})
|
|
102
|
+
.filter((e) => e.tag && e.tag !== "*")
|
|
103
|
+
.sort((a, b) => b.q - a.q)[0];
|
|
104
|
+
return new Cosmo(best?.tag, modifiers);
|
|
105
|
+
}
|
|
106
|
+
// #region key → value lookups (Intl.DisplayNames)
|
|
107
|
+
display(type, code) {
|
|
108
|
+
try {
|
|
109
|
+
return new Intl.DisplayNames([this.locale], { type, fallback: "code" }).of(code) ?? "";
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Localised language name (e.g. `"en"` → `"English"`, in `fa` → `"انگلیسی"`).
|
|
117
|
+
* Accepts a bare language code or a full locale (the language subtag is used).
|
|
118
|
+
* Returns `""` for an empty/nullish argument.
|
|
119
|
+
*/
|
|
120
|
+
language(code = this.locale) {
|
|
121
|
+
if (!code)
|
|
122
|
+
return "";
|
|
123
|
+
let lang = code;
|
|
124
|
+
try {
|
|
125
|
+
lang = new Intl.Locale(code.replace(/_/g, "-")).language ?? code;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* use as-is */
|
|
129
|
+
}
|
|
130
|
+
return this.display("language", lang);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Localised country/region name (e.g. `"AU"` → `"Australia"`).
|
|
134
|
+
* Accepts a region code or a locale containing one. Returns `""` when empty.
|
|
135
|
+
*/
|
|
136
|
+
country(code = this.subtags.region) {
|
|
137
|
+
if (!code)
|
|
138
|
+
return "";
|
|
139
|
+
let region = code;
|
|
140
|
+
if (/[-_]/.test(code)) {
|
|
141
|
+
try {
|
|
142
|
+
region = new Intl.Locale(code.replace(/_/g, "-")).region ?? "";
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
region = "";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!region)
|
|
149
|
+
return "";
|
|
150
|
+
return this.display("region", region.toUpperCase());
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Localised script name (e.g. `"Latn"` → `"Latin"`). Defaults to the locale's
|
|
154
|
+
* script subtag. Returns `""` when there is no script.
|
|
155
|
+
*/
|
|
156
|
+
script(code = this.subtags.script) {
|
|
157
|
+
if (!code)
|
|
158
|
+
return "";
|
|
159
|
+
const titled = code.charAt(0).toUpperCase() + code.slice(1).toLowerCase();
|
|
160
|
+
return this.display("script", titled);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Localised calendar name (e.g. `"buddhist"` → `"Buddhist Calendar"`).
|
|
164
|
+
* Returns `""` for an empty argument.
|
|
165
|
+
*/
|
|
166
|
+
calendar(code) {
|
|
167
|
+
if (!code)
|
|
168
|
+
return "";
|
|
169
|
+
return this.display("calendar", code);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Localised currency name (default) or symbol.
|
|
173
|
+
* @param code ISO 4217 code; defaults to the `currency` modifier.
|
|
174
|
+
* @param symbol When `true`, returns the standard (disambiguated) symbol — e.g.
|
|
175
|
+
* `"A$"` for AUD in `en-US`, not the ambiguous narrow `"$"`.
|
|
176
|
+
* @param strict Throw on an unknown currency instead of echoing the code back.
|
|
177
|
+
*/
|
|
178
|
+
currency(code = this.modifiers.currency, symbol = false, strict = false) {
|
|
179
|
+
const ccy = (code ?? "").toUpperCase();
|
|
180
|
+
if (!ccy)
|
|
181
|
+
return "";
|
|
182
|
+
if (symbol) {
|
|
183
|
+
try {
|
|
184
|
+
const parts = new Intl.NumberFormat(this.locale, {
|
|
185
|
+
style: "currency",
|
|
186
|
+
currency: ccy,
|
|
187
|
+
currencyDisplay: "symbol",
|
|
188
|
+
}).formatToParts(1);
|
|
189
|
+
return parts.find((p) => p.type === "currency")?.value ?? ccy;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
if (strict)
|
|
193
|
+
throw new InvalidArgumentError(`"${ccy}" is not a valid currency code.`);
|
|
194
|
+
return ccy;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let name;
|
|
198
|
+
try {
|
|
199
|
+
name = new Intl.DisplayNames([this.locale], { type: "currency", fallback: "code" }).of(ccy) ?? ccy;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
name = ccy;
|
|
203
|
+
}
|
|
204
|
+
// DisplayNames echoes the (upper-cased) code back when the currency is unknown.
|
|
205
|
+
if (name === ccy && strict) {
|
|
206
|
+
throw new InvalidArgumentError(`"${ccy}" is not a valid currency code.`);
|
|
207
|
+
}
|
|
208
|
+
return name;
|
|
209
|
+
}
|
|
210
|
+
/** Text direction of the locale (or a given language): `"rtl"` or `"ltr"`. */
|
|
211
|
+
direction(language = this.locale) {
|
|
212
|
+
try {
|
|
213
|
+
const loc = new Intl.Locale((language ?? this.locale).replace(/_/g, "-"));
|
|
214
|
+
const info = typeof loc.getTextInfo === "function" ? loc.getTextInfo() : loc.textInfo;
|
|
215
|
+
return info?.direction === "rtl" ? "rtl" : "ltr";
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return "ltr";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Country flag emoji for a region (e.g. `"AU"` → `"🇦🇺"`). Defaults to the
|
|
223
|
+
* locale's region. Uses the Unicode regional-indicator transform, so no data
|
|
224
|
+
* table is involved.
|
|
225
|
+
*/
|
|
226
|
+
flag(country = this.subtags.region) {
|
|
227
|
+
const region = (country ?? "").toUpperCase();
|
|
228
|
+
if (!/^[A-Z]{2}$/.test(region))
|
|
229
|
+
return "";
|
|
230
|
+
const A = 0x1f1e6 - 0x41; // regional indicator offset
|
|
231
|
+
return String.fromCodePoint(region.charCodeAt(0) + A, region.charCodeAt(1) + A);
|
|
232
|
+
}
|
|
233
|
+
// #endregion
|
|
234
|
+
// #region numbers
|
|
235
|
+
/**
|
|
236
|
+
* Formats a number using the locale's default decimal format.
|
|
237
|
+
* @param options Optional rounding/grouping controls ({@link NumberOptions}).
|
|
238
|
+
*/
|
|
239
|
+
number(value, options = {}) {
|
|
240
|
+
return new Intl.NumberFormat(this.locale, { ...options }).format(value);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Formats a fraction as a localised percentage (e.g. `0.2` → `"20%"`).
|
|
244
|
+
* @param precision Maximum fraction digits (default 3).
|
|
245
|
+
* @param options Optional rounding/grouping controls ({@link NumberOptions});
|
|
246
|
+
* an explicit `maximumFractionDigits` here overrides `precision`.
|
|
247
|
+
*/
|
|
248
|
+
percentage(value, precision = 3, options = {}) {
|
|
249
|
+
return new Intl.NumberFormat(this.locale, {
|
|
250
|
+
style: "percent",
|
|
251
|
+
maximumFractionDigits: precision,
|
|
252
|
+
...options,
|
|
253
|
+
}).format(value);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Formats a monetary value.
|
|
257
|
+
*
|
|
258
|
+
* No currency is inferred from the region: the `Intl` API exposes no
|
|
259
|
+
* region→currency mapping, and this library bundles no data. Provide a
|
|
260
|
+
* currency code or set the `currency` modifier.
|
|
261
|
+
*
|
|
262
|
+
* @returns The formatted amount, or `""` when no currency is available
|
|
263
|
+
* (unless `strict`, which throws).
|
|
264
|
+
*/
|
|
265
|
+
money(value, code = this.modifiers.currency, options = {}) {
|
|
266
|
+
options ??= {}; // the `= {}` default only covers undefined, not an explicit null
|
|
267
|
+
const ccy = (code ?? "").toUpperCase();
|
|
268
|
+
if (!ccy) {
|
|
269
|
+
if (options.strict) {
|
|
270
|
+
throw new InvalidArgumentError("No currency provided. Pass a code or set the `currency` modifier.");
|
|
271
|
+
}
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
// A malformed code makes Intl.NumberFormat throw a raw RangeError; reject it
|
|
275
|
+
// up front so callers get the same branded error as currency() does.
|
|
276
|
+
if (!/^[A-Z]{3}$/.test(ccy)) {
|
|
277
|
+
throw new InvalidArgumentError(`"${ccy}" is not a valid currency code.`);
|
|
278
|
+
}
|
|
279
|
+
const { precision, strict, ...numberOptions } = options;
|
|
280
|
+
void strict;
|
|
281
|
+
const fmtOptions = {
|
|
282
|
+
style: "currency",
|
|
283
|
+
currency: ccy,
|
|
284
|
+
...numberOptions,
|
|
285
|
+
};
|
|
286
|
+
if (precision != null) {
|
|
287
|
+
fmtOptions.minimumFractionDigits ??= precision;
|
|
288
|
+
fmtOptions.maximumFractionDigits ??= precision;
|
|
289
|
+
}
|
|
290
|
+
return new Intl.NumberFormat(this.locale, fmtOptions).format(value);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Returns a single localised number symbol that the `Intl` API exposes via
|
|
294
|
+
* `formatToParts` (decimal/group separators, percent, sign symbols, nan,
|
|
295
|
+
* infinity). Symbols ICU keeps internal (permille, pad escape, …) are not
|
|
296
|
+
* available and throw.
|
|
297
|
+
*
|
|
298
|
+
* @param name One of: `decimal`, `group`, `percent`, `minusSign`, `plusSign`,
|
|
299
|
+
* `nan`, `infinity`, `currency` (case/`_`-insensitive; `_separator`/`_symbol`
|
|
300
|
+
* suffixes are ignored).
|
|
301
|
+
*/
|
|
302
|
+
symbol(name) {
|
|
303
|
+
const key = name.toLowerCase().replace(/[_\s-]/g, "").replace(/separator$|symbol$|sign$/g, "");
|
|
304
|
+
const nf = (opts, value, type) => {
|
|
305
|
+
const part = new Intl.NumberFormat(this.locale, opts).formatToParts(value).find((p) => p.type === type);
|
|
306
|
+
return part?.value ?? "";
|
|
307
|
+
};
|
|
308
|
+
switch (key) {
|
|
309
|
+
case "decimal":
|
|
310
|
+
return nf({ minimumFractionDigits: 1 }, 1.1, "decimal");
|
|
311
|
+
case "group":
|
|
312
|
+
case "grouping":
|
|
313
|
+
return nf({ useGrouping: true }, 1000, "group");
|
|
314
|
+
case "percent":
|
|
315
|
+
return nf({ style: "percent" }, 0, "percentSign");
|
|
316
|
+
case "minus":
|
|
317
|
+
return nf({ signDisplay: "always" }, -1, "minusSign");
|
|
318
|
+
case "plus":
|
|
319
|
+
return nf({ signDisplay: "always" }, 1, "plusSign");
|
|
320
|
+
case "nan":
|
|
321
|
+
return nf({}, NaN, "nan");
|
|
322
|
+
case "infinity":
|
|
323
|
+
case "infinite":
|
|
324
|
+
return nf({}, Infinity, "infinity");
|
|
325
|
+
case "currency":
|
|
326
|
+
return nf({ style: "currency", currency: "USD", currencyDisplay: "symbol" }, 1, "currency");
|
|
327
|
+
default:
|
|
328
|
+
throw new InvalidArgumentError(`"${name}" is not a number symbol exposed by Intl. ` +
|
|
329
|
+
`Available: decimal, group, percent, minusSign, plusSign, nan, infinity, currency.`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Formats a measurement with a localised unit (e.g. `2.19` gigabytes).
|
|
334
|
+
* @param category Informational unit category (e.g. `"digital"`); accepted for
|
|
335
|
+
* descriptive grouping but not required by `Intl`.
|
|
336
|
+
* @param unit The unit identifier, e.g. `"gigabyte"`, `"celsius"`, `"gram"`.
|
|
337
|
+
* Must be one of the units sanctioned by ECMA-402.
|
|
338
|
+
* @param value Numeric value.
|
|
339
|
+
* @param width `full`/`long` → long, `medium` → short, `short` → narrow.
|
|
340
|
+
* @throws CosmoError if the unit is not supported by `Intl`.
|
|
341
|
+
* @see https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers
|
|
342
|
+
*/
|
|
343
|
+
unit(category, unit, value, width = "full") {
|
|
344
|
+
void category;
|
|
345
|
+
const unitDisplay = WIDTH_TO_UNIT_DISPLAY[assertWidth(width)];
|
|
346
|
+
try {
|
|
347
|
+
return new Intl.NumberFormat(this.locale, { style: "unit", unit, unitDisplay }).format(value);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
throw new InvalidArgumentError(`"${unit}" is not a unit supported by the Intl API (ECMA-402 sanctioned units only).`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Formats an ICU MessageFormat pattern (subset: args, number, plural, select). */
|
|
354
|
+
message(pattern, args = {}) {
|
|
355
|
+
return formatMessage(this.locale, pattern, args);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Formats an **undirected** duration (magnitude only) given in **seconds**.
|
|
359
|
+
* For the directed form (with a past/future orientation, e.g. "3 days ago")
|
|
360
|
+
* see {@link Cosmo.relativeDuration}.
|
|
361
|
+
* @param withWords When `true`, spells out the units (`"339 hours, …"`),
|
|
362
|
+
* otherwise uses the digital clock form (`"339:27:40"`).
|
|
363
|
+
*
|
|
364
|
+
* Requires `Intl.DurationFormat` (Node 22+). Both forms are produced entirely
|
|
365
|
+
* by ICU.
|
|
366
|
+
*/
|
|
367
|
+
duration(value, withWords = false) {
|
|
368
|
+
const DurationFormat = Intl.DurationFormat;
|
|
369
|
+
if (typeof DurationFormat !== "function") {
|
|
370
|
+
throw new UnsupportedError("duration() requires Intl.DurationFormat (Node 22+).");
|
|
371
|
+
}
|
|
372
|
+
// A breakdown of arbitrary units (days/weeks/…) — render them as-is.
|
|
373
|
+
if (typeof value !== "number") {
|
|
374
|
+
return new DurationFormat(this.locale, { style: withWords ? "long" : "short" }).format(value);
|
|
375
|
+
}
|
|
376
|
+
// A scalar number of seconds — split into the hours/minutes/seconds clock form.
|
|
377
|
+
const total = Math.trunc(value);
|
|
378
|
+
const parts = {
|
|
379
|
+
hours: Math.trunc(total / 3600),
|
|
380
|
+
minutes: Math.trunc((total % 3600) / 60),
|
|
381
|
+
seconds: total % 60,
|
|
382
|
+
};
|
|
383
|
+
const options = withWords
|
|
384
|
+
? { style: "long" }
|
|
385
|
+
: { style: "digital", hours: "numeric" };
|
|
386
|
+
return new DurationFormat(this.locale, options).format(parts);
|
|
387
|
+
}
|
|
388
|
+
// #endregion
|
|
389
|
+
// #region dates & times
|
|
390
|
+
dateTimeFormat(dateWidth, timeWidth, calendar) {
|
|
391
|
+
const options = {};
|
|
392
|
+
if (dateWidth !== "none")
|
|
393
|
+
options.dateStyle = WIDTH_TO_DATE_STYLE[dateWidth];
|
|
394
|
+
if (timeWidth !== "none")
|
|
395
|
+
options.timeStyle = WIDTH_TO_DATE_STYLE[timeWidth];
|
|
396
|
+
const cal = calendar === "gregorian" ? "gregory" : calendar ?? this.modifiers.calendar;
|
|
397
|
+
if (cal)
|
|
398
|
+
options.calendar = cal;
|
|
399
|
+
if (this.modifiers.timeZone)
|
|
400
|
+
options.timeZone = this.modifiers.timeZone;
|
|
401
|
+
return new Intl.DateTimeFormat(this.locale, options);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Formats a moment (date and/or time) using the locale's conventions.
|
|
405
|
+
* @param moment A `Date` or Unix-millisecond timestamp.
|
|
406
|
+
* @param dateWidth `none`/`short`/`medium`/`long`/`full` (default `short`).
|
|
407
|
+
* @param timeWidth `none`/`short`/`medium`/`long`/`full` (default `short`).
|
|
408
|
+
* @param calendar Pass `"gregorian"` to force Gregorian; otherwise the
|
|
409
|
+
* locale/modifier calendar is used.
|
|
410
|
+
*/
|
|
411
|
+
moment(moment, dateWidth = "short", timeWidth = "short", calendar) {
|
|
412
|
+
assertWidth(dateWidth);
|
|
413
|
+
assertWidth(timeWidth);
|
|
414
|
+
if (dateWidth === "none" && timeWidth === "none")
|
|
415
|
+
return "";
|
|
416
|
+
return this.dateTimeFormat(dateWidth, timeWidth, calendar).format(toDate(moment));
|
|
417
|
+
}
|
|
418
|
+
/** Formats just the date part of a moment. */
|
|
419
|
+
date(moment, width = "short") {
|
|
420
|
+
return this.moment(moment, width, "none");
|
|
421
|
+
}
|
|
422
|
+
/** Formats just the time (clock) part of a moment. */
|
|
423
|
+
time(moment, width = "short") {
|
|
424
|
+
return this.moment(moment, "none", width);
|
|
425
|
+
}
|
|
426
|
+
// #endregion
|
|
427
|
+
// #region collation (Intl.Collator)
|
|
428
|
+
/**
|
|
429
|
+
* Locale-aware comparison of two strings, suitable for `Array#sort`.
|
|
430
|
+
* Returns a negative number, `0`, or a positive number.
|
|
431
|
+
*/
|
|
432
|
+
compare(a, b, options = {}) {
|
|
433
|
+
return new Intl.Collator(this.locale, { ...options }).compare(a, b);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Returns a new array sorted by the locale's collation rules.
|
|
437
|
+
* @param key Optional accessor returning the string to sort each item by.
|
|
438
|
+
* @param options Optional collation tailoring ({@link CollationOptions}).
|
|
439
|
+
*/
|
|
440
|
+
sort(items, key, options = {}) {
|
|
441
|
+
const collator = new Intl.Collator(this.locale, { ...options });
|
|
442
|
+
const get = key ?? ((item) => String(item));
|
|
443
|
+
return [...items].sort((a, b) => collator.compare(get(a), get(b)));
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Locale-aware substring test that honours the locale's collation, so
|
|
447
|
+
* accents/case can be ignored.
|
|
448
|
+
* @param sensitivity `base` (ignore case & accents, default), `accent`,
|
|
449
|
+
* `case`, or `variant` (exact). See `Intl.Collator`.
|
|
450
|
+
*/
|
|
451
|
+
contains(haystack, needle, sensitivity = "base", options = {}) {
|
|
452
|
+
if (needle === "")
|
|
453
|
+
return true;
|
|
454
|
+
const collator = new Intl.Collator(this.locale, { usage: "search", sensitivity, ...options });
|
|
455
|
+
const seg = new Intl.Segmenter(this.locale, { granularity: "grapheme" });
|
|
456
|
+
const hay = [...seg.segment(haystack)].map((s) => s.segment);
|
|
457
|
+
const need = [...seg.segment(needle)].length;
|
|
458
|
+
for (let i = 0; i + need <= hay.length; i++) {
|
|
459
|
+
if (collator.compare(hay.slice(i, i + need).join(""), needle) === 0)
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
// #endregion
|
|
465
|
+
// #region text segmentation (Intl.Segmenter)
|
|
466
|
+
/**
|
|
467
|
+
* Splits text into words using the locale's word-boundary rules, keeping only
|
|
468
|
+
* word-like segments (drops whitespace and punctuation). Mirrors ICU's
|
|
469
|
+
* word `BreakIterator`.
|
|
470
|
+
*/
|
|
471
|
+
splitWords(text) {
|
|
472
|
+
const seg = new Intl.Segmenter(this.locale, { granularity: "word" });
|
|
473
|
+
return [...seg.segment(text)].filter((s) => s.isWordLike).map((s) => s.segment);
|
|
474
|
+
}
|
|
475
|
+
/** Splits text into sentences using the locale's sentence-boundary rules. */
|
|
476
|
+
splitSentences(text) {
|
|
477
|
+
const seg = new Intl.Segmenter(this.locale, { granularity: "sentence" });
|
|
478
|
+
return [...seg.segment(text)].map((s) => s.segment.trim()).filter(Boolean);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Truncates text to at most `max` graphemes, breaking on a word boundary and
|
|
482
|
+
* appending `ellipsis`. Grapheme- and word-aware via `Intl.Segmenter`, so it
|
|
483
|
+
* never splits a combining sequence. Returns the original text if it already
|
|
484
|
+
* fits.
|
|
485
|
+
*/
|
|
486
|
+
ellipsize(text, max, ellipsis = "…") {
|
|
487
|
+
const graphemes = [...new Intl.Segmenter(this.locale, { granularity: "grapheme" }).segment(text)];
|
|
488
|
+
if (graphemes.length <= max)
|
|
489
|
+
return text;
|
|
490
|
+
const budget = Math.max(0, max - [...ellipsis].length);
|
|
491
|
+
const head = graphemes.slice(0, budget).map((s) => s.segment).join("");
|
|
492
|
+
// Prefer to cut at the last word boundary that still fits.
|
|
493
|
+
const words = [...new Intl.Segmenter(this.locale, { granularity: "word" }).segment(head)];
|
|
494
|
+
let cut = head;
|
|
495
|
+
const last = words.at(-1);
|
|
496
|
+
if (words.length > 1 && last && last.index > 0) {
|
|
497
|
+
cut = head.slice(0, last.index).trimEnd();
|
|
498
|
+
}
|
|
499
|
+
return (cut || head.trimEnd()) + ellipsis;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Splits text into grapheme clusters (user-perceived characters), so combining
|
|
503
|
+
* marks and emoji ZWJ sequences stay intact. Mirrors `Intl.Segmenter`
|
|
504
|
+
* `granularity:"grapheme"`.
|
|
505
|
+
*/
|
|
506
|
+
splitGraphemes(text) {
|
|
507
|
+
return [...new Intl.Segmenter(this.locale, { granularity: "grapheme" }).segment(text)].map((s) => s.segment);
|
|
508
|
+
}
|
|
509
|
+
// #endregion
|
|
510
|
+
// #region locale metadata
|
|
511
|
+
/**
|
|
512
|
+
* The LDML plural category a number falls into for this locale
|
|
513
|
+
* (e.g. `1` → `"one"`, `2` → `"other"` in English). Mirrors the category
|
|
514
|
+
* selection inside ICU `MessageFormat`.
|
|
515
|
+
* @param ordinal Use ordinal rules (1st/2nd/3rd …) instead of cardinal.
|
|
516
|
+
*/
|
|
517
|
+
pluralCategory(value, ordinal = false) {
|
|
518
|
+
return new Intl.PluralRules(this.locale, {
|
|
519
|
+
type: ordinal ? "ordinal" : "cardinal",
|
|
520
|
+
}).select(value);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Week conventions for the locale: first day of the week, weekend days, and
|
|
524
|
+
* the minimal days in the first week. Mirrors `IntlCalendar` accessors.
|
|
525
|
+
* @throws CosmoError if the runtime lacks `Intl.Locale#getWeekInfo`.
|
|
526
|
+
*/
|
|
527
|
+
weekInfo() {
|
|
528
|
+
const loc = this.intlLocale;
|
|
529
|
+
const info = typeof loc.getWeekInfo === "function" ? loc.getWeekInfo() : loc.weekInfo;
|
|
530
|
+
if (!info) {
|
|
531
|
+
throw new UnsupportedError("weekInfo() requires Intl.Locale#getWeekInfo() support.");
|
|
532
|
+
}
|
|
533
|
+
const result = { firstDay: info.firstDay, weekend: [...info.weekend] };
|
|
534
|
+
if (info.minimalDays != null)
|
|
535
|
+
result.minimalDays = info.minimalDays;
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Returns a new Cosmo with likely subtags added (e.g. `"en"` → `"en-Latn-US"`).
|
|
540
|
+
* Uses `Intl.Locale#maximize`.
|
|
541
|
+
*/
|
|
542
|
+
addLikelySubtags() {
|
|
543
|
+
return new Cosmo(this.intlLocale.maximize().toString(), this.modifiers);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Returns a new Cosmo with likely subtags removed (e.g. `"en-Latn-US"` → `"en"`).
|
|
547
|
+
* Uses `Intl.Locale#minimize`.
|
|
548
|
+
*/
|
|
549
|
+
removeLikelySubtags() {
|
|
550
|
+
return new Cosmo(this.intlLocale.minimize().toString(), this.modifiers);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Localised month names (January … December), following the active calendar.
|
|
554
|
+
* Mirrors `IntlDateFormatter` month symbols.
|
|
555
|
+
*/
|
|
556
|
+
monthNames(width = "full") {
|
|
557
|
+
const month = WIDTH_TO_UNIT_DISPLAY[assertWidth(width)];
|
|
558
|
+
const cal = this.modifiers.calendar === "gregorian" ? "gregory" : this.modifiers.calendar ?? undefined;
|
|
559
|
+
const nameFmt = new Intl.DateTimeFormat(this.locale, { month, timeZone: "UTC", calendar: cal });
|
|
560
|
+
// Calendars don't line up with Gregorian months, so place each name by the
|
|
561
|
+
// calendar's own month ordinal rather than by Gregorian position. The ordinal
|
|
562
|
+
// formatter must use the same calendar `nameFmt` resolved (the locale can imply
|
|
563
|
+
// one, e.g. fa-IR → persian, even when no modifier is set).
|
|
564
|
+
const resolvedCal = nameFmt.resolvedOptions().calendar;
|
|
565
|
+
const ordFmt = new Intl.DateTimeFormat("en-US", { month: "numeric", timeZone: "UTC", calendar: resolvedCal });
|
|
566
|
+
const names = new Array(12).fill("");
|
|
567
|
+
const start = Date.UTC(2023, 0, 1);
|
|
568
|
+
for (let day = 0; day < 400; day++) {
|
|
569
|
+
const ms = start + day * 86_400_000;
|
|
570
|
+
const idx = Number.parseInt(ordFmt.format(ms), 10) - 1;
|
|
571
|
+
if (idx >= 0 && idx < 12 && !names[idx]) {
|
|
572
|
+
names[idx] = nameFmt.formatToParts(ms).find((p) => p.type === "month")?.value ?? "";
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return names;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Localised weekday names, **Sunday first** (matching ICU symbol order).
|
|
579
|
+
* Mirrors `IntlDateFormatter` weekday symbols.
|
|
580
|
+
*/
|
|
581
|
+
weekdayNames(width = "full") {
|
|
582
|
+
const weekday = WIDTH_TO_UNIT_DISPLAY[assertWidth(width)];
|
|
583
|
+
const fmt = new Intl.DateTimeFormat(this.locale, { weekday, timeZone: "UTC" });
|
|
584
|
+
// 2021-08-01 (UTC) is a Sunday.
|
|
585
|
+
return Array.from({ length: 7 }, (_, d) => fmt.formatToParts(Date.UTC(2021, 7, 1 + d)).find((p) => p.type === "weekday")?.value ?? "");
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Localised display name of a time zone (e.g. `"Australian Eastern Standard Time"`).
|
|
589
|
+
* Defaults to the `timeZone` modifier, falling back to the runtime zone.
|
|
590
|
+
* @param style `long` (default), `short`, `shortOffset`, `longOffset`,
|
|
591
|
+
* `shortGeneric`, or `longGeneric`.
|
|
592
|
+
*/
|
|
593
|
+
timeZoneName(style = "long") {
|
|
594
|
+
const timeZone = this.modifiers.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
595
|
+
const parts = new Intl.DateTimeFormat(this.locale, { timeZone, timeZoneName: style }).formatToParts(Date.now());
|
|
596
|
+
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Generic localised display name — a single entry point over the dedicated
|
|
600
|
+
* lookups. Mirrors `Intl.DisplayNames`.
|
|
601
|
+
* @param type `language`, `region`, `script`, `calendar`, or `currency`.
|
|
602
|
+
* @param code The code to translate (e.g. `"en"`, `"AU"`, `"Hans"`, `"buddhist"`, `"EUR"`).
|
|
603
|
+
*/
|
|
604
|
+
displayName(type, code) {
|
|
605
|
+
switch (type) {
|
|
606
|
+
case "language":
|
|
607
|
+
return this.language(code);
|
|
608
|
+
case "region":
|
|
609
|
+
return this.country(code);
|
|
610
|
+
case "script":
|
|
611
|
+
return this.script(code);
|
|
612
|
+
case "calendar":
|
|
613
|
+
return this.calendar(code);
|
|
614
|
+
case "currency":
|
|
615
|
+
return this.currency(code);
|
|
616
|
+
default:
|
|
617
|
+
throw new InvalidArgumentError(`"${type}" is not a display-name type (use language/region/script/calendar/currency).`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* The values the runtime's ICU supports for a given key (e.g. all IANA time
|
|
622
|
+
* zones, calendars, currencies). Mirrors `Intl.supportedValuesOf`.
|
|
623
|
+
* @param key `calendar`, `collation`, `currency`, `numberingSystem`, `timeZone`, or `unit`.
|
|
624
|
+
*/
|
|
625
|
+
supportedValues(key) {
|
|
626
|
+
const fn = Intl.supportedValuesOf;
|
|
627
|
+
if (typeof fn !== "function") {
|
|
628
|
+
throw new UnsupportedError("supportedValues() requires Intl.supportedValuesOf (Node 18+).");
|
|
629
|
+
}
|
|
630
|
+
return fn(key);
|
|
631
|
+
}
|
|
632
|
+
// #endregion
|
|
633
|
+
// #region case transforms
|
|
634
|
+
/** Locale-aware upper-casing (e.g. Turkish dotted/dotless I). */
|
|
635
|
+
upper(text) {
|
|
636
|
+
return text.toLocaleUpperCase(this.locale);
|
|
637
|
+
}
|
|
638
|
+
/** Locale-aware lower-casing. */
|
|
639
|
+
lower(text) {
|
|
640
|
+
return text.toLocaleLowerCase(this.locale);
|
|
641
|
+
}
|
|
642
|
+
// #endregion
|
|
643
|
+
// #region relative time, lists, ranges & notation
|
|
644
|
+
/**
|
|
645
|
+
* Renders a **directed duration** — a signed amount carrying a past/future
|
|
646
|
+
* orientation — in the locale's words (e.g. `(-3, "day")` → `"3 days ago"`,
|
|
647
|
+
* `(2, "hour")` → `"in 2 hours"`). The directed counterpart of
|
|
648
|
+
* {@link Cosmo.duration}, which is undirected (magnitude only). Wraps
|
|
649
|
+
* `Intl.RelativeTimeFormat`.
|
|
650
|
+
* @param amount Signed amount: negative = past (`"… ago"`), positive = future
|
|
651
|
+
* (`"in …"`).
|
|
652
|
+
* @param unit `second`, `minute`, `hour`, `day`, `week`, `month`, `quarter`, `year`.
|
|
653
|
+
* @param numeric `always` (default, "1 day ago") or `auto` (allows "yesterday").
|
|
654
|
+
*/
|
|
655
|
+
relativeDuration(amount, unit, numeric = "always") {
|
|
656
|
+
return new Intl.RelativeTimeFormat(this.locale, { numeric }).format(amount, unit);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Renders the directed duration **between two moments** as relative text,
|
|
660
|
+
* auto-selecting the largest sensible unit (e.g. `"in 5 days"`,
|
|
661
|
+
* `"3 days ago"`). Computes `target − reference`, then formats via
|
|
662
|
+
* {@link Cosmo.relativeDuration}. JS-only.
|
|
663
|
+
* @param target The moment being described.
|
|
664
|
+
* @param reference The moment `target` is measured against. **Defaults to
|
|
665
|
+
* now.** When `target` is after `reference` the result is future (`"in …"`);
|
|
666
|
+
* when before, it is past (`"… ago"`).
|
|
667
|
+
* @param numeric `auto` (default, allows "yesterday") or `always`.
|
|
668
|
+
*/
|
|
669
|
+
relativeDurationBetween(target, reference = Date.now(), numeric = "auto") {
|
|
670
|
+
const diffSeconds = (toDate(target).getTime() - toDate(reference).getTime()) / 1000;
|
|
671
|
+
const divisions = [
|
|
672
|
+
{ amount: 60, unit: "second" },
|
|
673
|
+
{ amount: 60, unit: "minute" },
|
|
674
|
+
{ amount: 24, unit: "hour" },
|
|
675
|
+
{ amount: 7, unit: "day" },
|
|
676
|
+
{ amount: 4.34524, unit: "week" },
|
|
677
|
+
{ amount: 12, unit: "month" },
|
|
678
|
+
{ amount: Number.POSITIVE_INFINITY, unit: "year" },
|
|
679
|
+
];
|
|
680
|
+
const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric });
|
|
681
|
+
let amount = diffSeconds;
|
|
682
|
+
for (const division of divisions) {
|
|
683
|
+
if (Math.abs(amount) < division.amount) {
|
|
684
|
+
return rtf.format(Math.round(amount), division.unit);
|
|
685
|
+
}
|
|
686
|
+
amount /= division.amount;
|
|
687
|
+
}
|
|
688
|
+
return rtf.format(Math.round(amount), "year");
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Joins a list using the locale's conventions (e.g. `"A, B, and C"`).
|
|
692
|
+
* Mirrors `Intl.ListFormat`.
|
|
693
|
+
* @param type `conjunction` (and, default), `disjunction` (or), or `unit`.
|
|
694
|
+
* @param width maps to long/short/narrow list styles.
|
|
695
|
+
*/
|
|
696
|
+
join(items, type = "conjunction", width = "full") {
|
|
697
|
+
const style = WIDTH_TO_UNIT_DISPLAY[assertWidth(width)];
|
|
698
|
+
return new Intl.ListFormat(this.locale, { type, style }).format(items);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Compact number notation (e.g. `1200` → `"1.2K"`, or `"1.2 thousand"`).
|
|
702
|
+
* JS-only.
|
|
703
|
+
* @param width `full`/`long` → long words, otherwise short.
|
|
704
|
+
*/
|
|
705
|
+
compact(value, width = "short") {
|
|
706
|
+
const compactDisplay = width === "full" || width === "long" ? "long" : "short";
|
|
707
|
+
return new Intl.NumberFormat(this.locale, { notation: "compact", compactDisplay }).format(value);
|
|
708
|
+
}
|
|
709
|
+
/** Scientific notation (e.g. `12345` → `"1.2345E4"`). Mirrors `NumberFormatter::SCIENTIFIC`. */
|
|
710
|
+
scientific(value) {
|
|
711
|
+
// Intl's default of 3 mantissa fraction digits would round 1.2345E4 to
|
|
712
|
+
// 1.235E4; ICU's scientific pattern (#E0, the PHP/Python ports) keeps full
|
|
713
|
+
// precision. 20 covers every double (max ~17 significant digits).
|
|
714
|
+
return new Intl.NumberFormat(this.locale, { notation: "scientific", maximumFractionDigits: 20 }).format(value);
|
|
715
|
+
}
|
|
716
|
+
/** Formats a numeric range (e.g. `"3–5"`). Uses `NumberFormat#formatRange`. JS-only. */
|
|
717
|
+
numberRange(start, end) {
|
|
718
|
+
const nf = new Intl.NumberFormat(this.locale);
|
|
719
|
+
return nf.formatRange(start, end);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Formats a monetary range (e.g. `"$3.00 – $5.00"`). JS-only.
|
|
723
|
+
* @returns `""` when no currency is available (or throws if none and required).
|
|
724
|
+
*/
|
|
725
|
+
moneyRange(start, end, code = this.modifiers.currency) {
|
|
726
|
+
const ccy = (code ?? "").toUpperCase();
|
|
727
|
+
if (!ccy)
|
|
728
|
+
return "";
|
|
729
|
+
const nf = new Intl.NumberFormat(this.locale, {
|
|
730
|
+
style: "currency",
|
|
731
|
+
currency: ccy,
|
|
732
|
+
});
|
|
733
|
+
return nf.formatRange(start, end);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Formats a moment range (e.g. `"2–5 Feb 2020"`). Uses `DateTimeFormat#formatRange`. JS-only.
|
|
737
|
+
* @param dateWidth Defaults to `medium` — `short` numeric dates read poorly as
|
|
738
|
+
* a range, so this differs from {@link Cosmo.date} (which defaults to `short`).
|
|
739
|
+
*/
|
|
740
|
+
dateRange(start, end, dateWidth = "medium", timeWidth = "none") {
|
|
741
|
+
assertWidth(dateWidth);
|
|
742
|
+
assertWidth(timeWidth);
|
|
743
|
+
const fmt = this.dateTimeFormat(dateWidth, timeWidth);
|
|
744
|
+
return fmt.formatRange(toDate(start), toDate(end));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=cosmo.js.map
|