@aircloudhq/web 1.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/package.json +52 -0
- package/src/api.js +48 -0
- package/src/function.js +73 -0
- package/src/helpers/date.js +66 -0
- package/src/helpers/form.js +203 -0
- package/src/helpers/navigation.js +70 -0
- package/src/helpers/number.js +131 -0
- package/src/helpers/sanitize-allowlist.js +10 -0
- package/src/helpers/sanitize.js +73 -0
- package/src/helpers/tag.js +37 -0
- package/src/helpers/text.js +143 -0
- package/src/helpers/translation.js +23 -0
- package/src/helpers/url.js +86 -0
- package/src/i18n/civil.js +43 -0
- package/src/i18n/current.js +25 -0
- package/src/i18n/decimal.js +109 -0
- package/src/i18n/index.js +373 -0
- package/src/i18n/missing.js +35 -0
- package/src/i18n/negotiate.js +67 -0
- package/src/i18n/plural.js +41 -0
- package/src/i18n/strftime.js +76 -0
- package/src/i18n/zone.js +39 -0
- package/src/index.js +16 -0
- package/src/model.js +38 -0
- package/src/react/form.js +167 -0
- package/src/react/i18n.js +52 -0
- package/src/react/index.js +21 -0
- package/src/react/links.js +56 -0
- package/src/react/resource.js +225 -0
- package/src/react/router.js +44 -0
- package/src/resource.js +57 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// The i18n runtime (ADR-223 D6; spec S4 § Translation semantics): `t`, `l`, number formatting and
|
|
2
|
+
// `pluralize` over a locale bundle (contracts/deploy/v1/locale-bundle-v1.json) and NOTHING else —
|
|
3
|
+
// never the host's Intl or time-zone database (D4). Every behaviour here is pinned by the shared
|
|
4
|
+
// vectors (contracts/deploy/v1/i18n-vectors.json), which the Rust function runtime runs too.
|
|
5
|
+
import { abs, delimit, parseDecimal, roundFixed, roundSignificant } from './decimal.js';
|
|
6
|
+
import { pluralCategory } from './plural.js';
|
|
7
|
+
import { civilFromDays, daysFromCivil, floorDiv, weekdayOf } from './civil.js';
|
|
8
|
+
import { strftime } from './strftime.js';
|
|
9
|
+
import { zoneAt } from './zone.js';
|
|
10
|
+
|
|
11
|
+
export { pluralCategory, operands } from './plural.js';
|
|
12
|
+
export { zoneAt } from './zone.js';
|
|
13
|
+
export { negotiateLocale, lookupLocale, acceptLanguage, localeFromUrl } from './negotiate.js';
|
|
14
|
+
export { createMissingReporter } from './missing.js';
|
|
15
|
+
|
|
16
|
+
/** The locale that ends every run-time chain with the platform defaults (S4 § [i18n]). */
|
|
17
|
+
export const PLATFORM_TERMINAL_LOCALE = 'en';
|
|
18
|
+
|
|
19
|
+
const PLURAL_KEYS = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
20
|
+
const OPTION_KEYS = new Set(['scope', 'default', 'count', 'lazy', 'context', 'locale']);
|
|
21
|
+
|
|
22
|
+
// ── chains ───────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** The run-time chain: the locale, its declared fallbacks, default_locale, then `en`. */
|
|
25
|
+
export function runtimeChain(bundle, locale) {
|
|
26
|
+
return unique([locale, ...(bundle.fallbacks?.[locale] ?? []), bundle.default_locale, PLATFORM_TERMINAL_LOCALE]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The locale and its declared fallbacks — where names, number symbols and inflections resolve. */
|
|
30
|
+
export function declaredChain(bundle, locale) {
|
|
31
|
+
return unique([locale, ...(bundle.fallbacks?.[locale] ?? [])]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unique(list) {
|
|
35
|
+
return [...new Set(list.filter(Boolean))];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function dig(tree, parts) {
|
|
39
|
+
let cur = tree;
|
|
40
|
+
for (const p of parts) {
|
|
41
|
+
if (cur === null || typeof cur !== 'object' || Array.isArray(cur) || !(p in cur)) return undefined;
|
|
42
|
+
cur = cur[p];
|
|
43
|
+
}
|
|
44
|
+
return cur;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parts(key) {
|
|
48
|
+
return String(key).split('.').filter((s) => s !== '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstIn(bundle, locales, key, accept = (v) => v !== undefined && v !== null) {
|
|
52
|
+
for (const loc of locales) {
|
|
53
|
+
const v = dig(bundle.messages?.[loc], parts(key));
|
|
54
|
+
if (accept(v)) return { value: v, locale: loc };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
60
|
+
|
|
61
|
+
// ── keys ─────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** The key scope of a lazy lookup: a site page path or a function name (S4 § Locale files). */
|
|
64
|
+
export function lazyScope(lazy) {
|
|
65
|
+
if (!lazy) return undefined;
|
|
66
|
+
if (lazy.site_page) {
|
|
67
|
+
const p = lazy.site_page.replace(/^(app\/site\/)?(src\/)?pages\//, '').replace(/\.(jsx|js)$/, '');
|
|
68
|
+
return p.split('/').filter(Boolean).join('.');
|
|
69
|
+
}
|
|
70
|
+
if (lazy.function) return `functions.${lazy.function}`;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A key with its scope and lazy prefix applied. */
|
|
75
|
+
export function expandKey(key, { scope, lazy } = {}) {
|
|
76
|
+
const k = String(key);
|
|
77
|
+
if (k.startsWith('.')) {
|
|
78
|
+
const s = lazyScope(lazy);
|
|
79
|
+
if (!s) throw new Error(`t("${k}"): a lazy key needs a page or function scope`);
|
|
80
|
+
return `${s}${k}`;
|
|
81
|
+
}
|
|
82
|
+
const sc = Array.isArray(scope) ? scope.join('.') : scope;
|
|
83
|
+
return sc ? `${sc}.${k}` : k;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Rails' humanize of the last key segment — the published rendering of a missing key (D10). */
|
|
87
|
+
export function humanizeKey(key) {
|
|
88
|
+
const seg = parts(key).pop() ?? String(key);
|
|
89
|
+
let s = seg.replace(/^_+/, '');
|
|
90
|
+
if (s.endsWith('_id')) s = s.slice(0, -3);
|
|
91
|
+
s = s.replace(/_/g, ' ').replace(/[A-Za-z0-9]+/g, (w) => w.toLowerCase()).trim();
|
|
92
|
+
return s.replace(/^[a-z]/, (c) => c.toUpperCase());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── t ────────────────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
98
|
+
|
|
99
|
+
/** ERB::Util.html_escape. */
|
|
100
|
+
export function escapeHtml(s) {
|
|
101
|
+
return String(s).replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether a key's translation may carry markup (Rails: the key ends in `_html` or `.html`). */
|
|
105
|
+
export function isHtmlKey(key) {
|
|
106
|
+
return /(?:_|\.|^)html$/.test(String(key));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `%{name}` interpolation; `%%` is a literal percent; a missing argument keeps its placeholder. */
|
|
110
|
+
export function interpolate(text, args, escape = (v) => String(v)) {
|
|
111
|
+
return String(text).replace(/%%|%\{([^}]+)\}/g, (m, name) => {
|
|
112
|
+
if (m === '%%') return '%';
|
|
113
|
+
return Object.prototype.hasOwnProperty.call(args, name) && args[name] !== undefined ? escape(args[name]) : m;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pluralize(bundle, locale, entry, count) {
|
|
118
|
+
if (!isObject(entry) || !PLURAL_KEYS.some((k) => k in entry)) return entry;
|
|
119
|
+
if (Number(parseFloat(String(count))) === 0 && 'zero' in entry) return entry.zero;
|
|
120
|
+
const cats = bundle.cldr?.[locale]?.plurals;
|
|
121
|
+
const cat = cats ? pluralCategory(cats, count) : 'other';
|
|
122
|
+
return cat in entry ? entry[cat] : entry.other;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a translation: per locale of the run-time chain, the key then its key defaults; a literal
|
|
127
|
+
* default only after the whole chain (Rails' Fallbacks backend).
|
|
128
|
+
* @returns {{found: true, value: any, literal?: boolean} | {found: false, key: string}}
|
|
129
|
+
*/
|
|
130
|
+
export function resolve(bundle, locale, key, options = {}) {
|
|
131
|
+
const fullKey = expandKey(key, options);
|
|
132
|
+
const defaults = options.default === undefined ? [] : Array.isArray(options.default) ? options.default : [options.default];
|
|
133
|
+
const keyDefaults = defaults.filter(isObject).map((d) => expandKey(d.key, options));
|
|
134
|
+
const literal = defaults.find((d) => typeof d === 'string');
|
|
135
|
+
for (const loc of runtimeChain(bundle, locale)) {
|
|
136
|
+
for (const k of [fullKey, ...keyDefaults]) {
|
|
137
|
+
let v = dig(bundle.messages?.[loc], parts(k));
|
|
138
|
+
if (v === undefined || v === null) continue;
|
|
139
|
+
if (options.count !== undefined) v = pluralize(bundle, loc, v, options.count);
|
|
140
|
+
if (v === undefined || v === null) continue;
|
|
141
|
+
return { found: true, value: v, key: fullKey };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (literal !== undefined) return { found: true, value: literal, literal: true, key: fullKey };
|
|
145
|
+
return { found: false, key: fullKey };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `t`: the rendered translation in the output context (`html` escapes interpolated values, and the
|
|
150
|
+
* translation text unless the key may carry markup; `text` and `json` escape nothing).
|
|
151
|
+
*/
|
|
152
|
+
export function translate(bundle, locale, key, options = {}, report = () => {}) {
|
|
153
|
+
const context = options.context ?? 'text';
|
|
154
|
+
const args = {};
|
|
155
|
+
for (const [k, v] of Object.entries(options)) if (!OPTION_KEYS.has(k)) args[k] = v;
|
|
156
|
+
if (options.count !== undefined) args.count = options.count;
|
|
157
|
+
const r = resolve(bundle, locale, key, options);
|
|
158
|
+
if (!r.found) {
|
|
159
|
+
report(r.key);
|
|
160
|
+
const h = humanizeKey(r.key);
|
|
161
|
+
return context === 'html' ? escapeHtml(h) : h;
|
|
162
|
+
}
|
|
163
|
+
if (typeof r.value !== 'string') return r.value;
|
|
164
|
+
if (context !== 'html') return interpolate(r.value, args);
|
|
165
|
+
if (isHtmlKey(r.key)) return interpolate(r.value, args, escapeHtml);
|
|
166
|
+
return escapeHtml(interpolate(r.value, args));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── l ────────────────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const CIVIL = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
172
|
+
|
|
173
|
+
/** The locale's value for a name kind: its declared chain's messages, then its CLDR data. */
|
|
174
|
+
function nameSource(bundle, locale) {
|
|
175
|
+
const keys = {
|
|
176
|
+
day_names: 'date.day_names', abbr_day_names: 'date.abbr_day_names',
|
|
177
|
+
month_names: 'date.month_names', abbr_month_names: 'date.abbr_month_names',
|
|
178
|
+
am: 'time.am', pm: 'time.pm',
|
|
179
|
+
};
|
|
180
|
+
return (kind) => {
|
|
181
|
+
const hit = firstIn(bundle, declaredChain(bundle, locale), keys[kind]);
|
|
182
|
+
if (hit) return hit.value;
|
|
183
|
+
const cldr = bundle.cldr?.[locale]?.dates?.[kind];
|
|
184
|
+
if (cldr !== undefined) return cldr;
|
|
185
|
+
throw new Error(`no ${kind} for ${locale} in the bundle`);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Break a value down to local time: a civil date string, or an instant rendered in `zone`. */
|
|
190
|
+
export function brokenDown(bundle, value, zoneName) {
|
|
191
|
+
if (typeof value === 'string' && CIVIL.test(value)) {
|
|
192
|
+
const [, y, m, d] = CIVIL.exec(value).map(Number);
|
|
193
|
+
return { kind: 'date', year: y, month: m, day: d, hour: 0, minute: 0, second: 0, wday: weekdayOf(daysFromCivil(y, m, d)), offset: null, abbr: null };
|
|
194
|
+
}
|
|
195
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);
|
|
196
|
+
if (!Number.isFinite(ms)) throw new RangeError(`l: not a date or time: ${value}`);
|
|
197
|
+
const name = zoneName ?? bundle.time_zone;
|
|
198
|
+
const zone = bundle.zones?.[name];
|
|
199
|
+
if (!zone) throw new Error(`l: the bundle carries no rules for the zone ${name}`);
|
|
200
|
+
const at = Math.floor(ms / 1000);
|
|
201
|
+
const lt = zoneAt(zone, at);
|
|
202
|
+
const local = at + lt.offset;
|
|
203
|
+
const days = floorDiv(local, 86400);
|
|
204
|
+
const [year, month, day] = civilFromDays(days);
|
|
205
|
+
const sod = local - days * 86400;
|
|
206
|
+
return {
|
|
207
|
+
kind: 'time', year, month, day,
|
|
208
|
+
hour: Math.floor(sod / 3600), minute: Math.floor((sod % 3600) / 60), second: sod % 60,
|
|
209
|
+
wday: weekdayOf(days), offset: lt.offset, abbr: lt.abbr,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `l`: a date or time in a named format (date.formats / time.formats) or a strftime string. */
|
|
214
|
+
export function localize(bundle, locale, value, options = {}, report = () => {}) {
|
|
215
|
+
const t = brokenDown(bundle, value, options.zone);
|
|
216
|
+
const format = options.format ?? 'default';
|
|
217
|
+
let pattern = format;
|
|
218
|
+
if (!String(format).includes('%')) {
|
|
219
|
+
const key = `${t.kind}.formats.${format}`;
|
|
220
|
+
const hit = firstIn(bundle, runtimeChain(bundle, locale), key, (v) => typeof v === 'string');
|
|
221
|
+
if (!hit) {
|
|
222
|
+
report(key);
|
|
223
|
+
return humanizeKey(key);
|
|
224
|
+
}
|
|
225
|
+
pattern = hit.value;
|
|
226
|
+
}
|
|
227
|
+
return strftime(pattern, t, nameSource(bundle, locale));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── numbers ──────────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** ActiveSupport::NumberHelper::NumberConverter::DEFAULTS. */
|
|
233
|
+
export const NUMBER_DEFAULTS = {
|
|
234
|
+
format: { separator: '.', delimiter: ',', precision: 3, significant: false, strip_insignificant_zeros: false },
|
|
235
|
+
currency: { format: '%u%n', negative_format: '-%u%n', unit: '$', separator: '.', delimiter: ',', precision: 2, significant: false, strip_insignificant_zeros: false },
|
|
236
|
+
percentage: { delimiter: '', format: '%n%' },
|
|
237
|
+
precision: { delimiter: '' },
|
|
238
|
+
human: {
|
|
239
|
+
delimiter: '', precision: 3, significant: true, strip_insignificant_zeros: true,
|
|
240
|
+
units: { billion: 'Billion', million: 'Million', quadrillion: 'Quadrillion', thousand: 'Thousand', trillion: 'Trillion', unit: '' },
|
|
241
|
+
},
|
|
242
|
+
storage_units: { format: '%n %u', units: { byte: 'Bytes', kb: 'KB', mb: 'MB', gb: 'GB', tb: 'TB', pb: 'PB', eb: 'EB', zb: 'ZB' } },
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The effective options of a number style (Rails' i18n_format_options): defaults, then the first
|
|
247
|
+
* `number.format` hash of the locale's declared chain (else its CLDR symbols), then the first
|
|
248
|
+
* `number.<namespace>.format` hash of the run-time chain, then the call's options.
|
|
249
|
+
*/
|
|
250
|
+
export function numberOptions(bundle, locale, namespace, options = {}) {
|
|
251
|
+
const base = firstIn(bundle, declaredChain(bundle, locale), 'number.format', isObject)?.value
|
|
252
|
+
?? cldrNumberFormat(bundle, locale)
|
|
253
|
+
?? firstIn(bundle, runtimeChain(bundle, locale), 'number.format', isObject)?.value
|
|
254
|
+
?? {};
|
|
255
|
+
const ns = namespace ? firstIn(bundle, runtimeChain(bundle, locale), `number.${namespace}.format`, isObject)?.value ?? {} : {};
|
|
256
|
+
return { ...NUMBER_DEFAULTS.format, ...(namespace ? NUMBER_DEFAULTS[namespace] ?? {} : {}), ...base, ...ns, ...options };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cldrNumberFormat(bundle, locale) {
|
|
260
|
+
const n = bundle.cldr?.[locale]?.numbers;
|
|
261
|
+
return n ? { separator: n.decimal, delimiter: n.group } : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** number_with_delimiter over the given options. */
|
|
265
|
+
export function formatDelimited(value, o) {
|
|
266
|
+
const d = parseDecimal(value);
|
|
267
|
+
return `${d.neg ? '-' : ''}${delimit(d.int, o.delimiter)}${d.frac ? o.separator + d.frac : ''}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** number_with_precision over the given options (half-up rounding, BigDecimal's). */
|
|
271
|
+
export function formatRounded(value, o) {
|
|
272
|
+
const d = parseDecimal(value);
|
|
273
|
+
const precision = Number(o.precision);
|
|
274
|
+
let r = o.significant && precision > 0 ? roundSignificant(d, precision) : roundFixed(d, precision);
|
|
275
|
+
let frac = r.frac;
|
|
276
|
+
if (o.strip_insignificant_zeros) frac = frac.replace(/0+$/, '');
|
|
277
|
+
return `${r.neg ? '-' : ''}${delimit(r.int, o.delimiter)}${frac ? o.separator + frac : ''}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The shared number styles: currency | delimited | precision | percentage. */
|
|
281
|
+
export function formatNumber(bundle, locale, style, value, options = {}) {
|
|
282
|
+
switch (style) {
|
|
283
|
+
case 'delimited':
|
|
284
|
+
return formatDelimited(value, numberOptions(bundle, locale, null, options));
|
|
285
|
+
case 'precision':
|
|
286
|
+
return formatRounded(value, numberOptions(bundle, locale, 'precision', options));
|
|
287
|
+
case 'percentage': {
|
|
288
|
+
const o = numberOptions(bundle, locale, 'percentage', options);
|
|
289
|
+
return o.format.replace('%n', formatRounded(value, o));
|
|
290
|
+
}
|
|
291
|
+
case 'currency': {
|
|
292
|
+
const o = numberOptions(bundle, locale, 'currency', options);
|
|
293
|
+
const d = parseDecimal(value);
|
|
294
|
+
const rounded = formatRounded(abs(d), o);
|
|
295
|
+
const zero = /^[^1-9]*$/.test(rounded);
|
|
296
|
+
const format = d.neg && !zero ? o.negative_format : o.format;
|
|
297
|
+
return format.replace('%n', rounded).replace('%u', o.unit);
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
throw new Error(`formatNumber: unknown style ${style}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── pluralize ────────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** A word's plural under the locale's inflections (its declared chain), Active Support's way. */
|
|
307
|
+
export function inflectPlural(bundle, locale, word) {
|
|
308
|
+
if (!word) return word;
|
|
309
|
+
const inf = firstIn(bundle, declaredChain(bundle, locale), 'inflections', isObject)?.value;
|
|
310
|
+
if (!inf) return word;
|
|
311
|
+
const lower = word.toLowerCase();
|
|
312
|
+
// Active Support: /\b<word>\Z/i — the uncountable word ends the string after a word boundary.
|
|
313
|
+
for (const u of inf.uncountable ?? []) {
|
|
314
|
+
const ul = String(u).toLowerCase();
|
|
315
|
+
if (lower === ul || (lower.endsWith(ul) && /[^a-z0-9_]/.test(lower[lower.length - ul.length - 1] ?? ''))) return word;
|
|
316
|
+
}
|
|
317
|
+
for (const [singular, plural] of inf.irregular ?? []) {
|
|
318
|
+
// The irregular plural is already plural (Rails adds a rule for it too).
|
|
319
|
+
if (lower.endsWith(String(plural).toLowerCase())) return word;
|
|
320
|
+
if (lower.endsWith(String(singular).toLowerCase())) {
|
|
321
|
+
const at = word.length - singular.length;
|
|
322
|
+
const head = word[at];
|
|
323
|
+
const tail = String(plural);
|
|
324
|
+
return word.slice(0, at) + (head === head.toUpperCase() && head !== head.toLowerCase() ? tail[0].toUpperCase() : tail[0]) + tail.slice(1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const rules = inf.plural ?? [];
|
|
328
|
+
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
|
329
|
+
const [pattern, replacement] = rules[i];
|
|
330
|
+
const re = new RegExp(pattern, 'iu');
|
|
331
|
+
if (re.test(word)) return word.replace(re, String(replacement).replace(/\\(\d)/g, '$$$1'));
|
|
332
|
+
}
|
|
333
|
+
return word;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** TextHelper#pluralize: the singular for a count of 1 (or 1.0…), else the plural. */
|
|
337
|
+
export function pluralizeWord(bundle, locale, count, singular, plural) {
|
|
338
|
+
const one = count === 1 || /^1(\.0+)?$/.test(String(count));
|
|
339
|
+
const word = one ? singular : plural ?? inflectPlural(bundle, locale, singular);
|
|
340
|
+
return `${count ?? 0} ${word}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── the instance ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* An i18n instance bound to a bundle and a locale.
|
|
347
|
+
* @param {{bundle: object, locale?: string, reporter?: (key: string) => void, lazy?: object}} init
|
|
348
|
+
*/
|
|
349
|
+
export function createI18n({ bundle, locale, reporter, lazy } = {}) {
|
|
350
|
+
if (!bundle || bundle.version !== 1) throw new Error('createI18n: a locale bundle v1 is required');
|
|
351
|
+
const current = locale && bundle.available_locales.includes(locale) ? locale : bundle.default_locale;
|
|
352
|
+
const report = reporter ?? (() => {});
|
|
353
|
+
const self = {
|
|
354
|
+
bundle,
|
|
355
|
+
locale: current,
|
|
356
|
+
t: (key, options = {}) => translate(bundle, current, key, { lazy, ...options }, report),
|
|
357
|
+
l: (value, options = {}) => localize(bundle, current, value, options, report),
|
|
358
|
+
formatNumber: (style, value, options) => formatNumber(bundle, current, style, value, options),
|
|
359
|
+
numberOptions: (namespace, options) => numberOptions(bundle, current, namespace, options),
|
|
360
|
+
pluralize: (count, singular, plural) => pluralizeWord(bundle, current, count, singular, plural),
|
|
361
|
+
exists: (key, options = {}) => resolve(bundle, current, key, { lazy, ...options }).found,
|
|
362
|
+
/** The raw value of a key through the run-time chain (a hash, an array, a string), or undefined. */
|
|
363
|
+
lookup: (key, options = {}) => {
|
|
364
|
+
const r = resolve(bundle, current, key, { lazy, ...options });
|
|
365
|
+
return r.found ? r.value : undefined;
|
|
366
|
+
},
|
|
367
|
+
/** The raw value of a key through the locale and its declared fallbacks only. */
|
|
368
|
+
lookupDeclared: (key) => firstIn(bundle, declaredChain(bundle, current), key)?.value,
|
|
369
|
+
withLocale: (other) => createI18n({ bundle, locale: other, reporter, lazy }),
|
|
370
|
+
withLazy: (scope) => createI18n({ bundle, locale: current, reporter, lazy: scope }),
|
|
371
|
+
};
|
|
372
|
+
return self;
|
|
373
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The site's run-time missing-key report (spec S4 § Run time; ADR-223 D10): a key composed at run time
|
|
2
|
+
// that is still unresolved after its chain is rendered humanized, and reported to the product host at
|
|
3
|
+
// `POST /_air/i18n-missing` as `{release, locale, key}` — at most once per key per page load (the
|
|
4
|
+
// lifetime of one reporter), and at most MAX_KEYS_PER_PAGE_LOAD keys, so a page composing keys from
|
|
5
|
+
// unbounded data cannot turn into a report per render.
|
|
6
|
+
|
|
7
|
+
export const MISSING_REPORT_PATH = '/_air/i18n-missing';
|
|
8
|
+
export const MAX_KEYS_PER_PAGE_LOAD = 100;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{release: string, locale: string, send?: (path: string, body: string) => void}} init
|
|
12
|
+
* `send` defaults to navigator.sendBeacon (a report never delays or fails the page).
|
|
13
|
+
* @returns {(key: string) => void}
|
|
14
|
+
*/
|
|
15
|
+
export function createMissingReporter({ release, locale, send }) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const transport =
|
|
18
|
+
send ??
|
|
19
|
+
((path, body) => {
|
|
20
|
+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
21
|
+
navigator.sendBeacon(path, new Blob([body], { type: 'application/json' }));
|
|
22
|
+
} else if (typeof fetch === 'function') {
|
|
23
|
+
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: true }).catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return (key) => {
|
|
27
|
+
if (seen.has(key) || seen.size >= MAX_KEYS_PER_PAGE_LOAD) return;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
try {
|
|
30
|
+
transport(MISSING_REPORT_PATH, JSON.stringify({ release, locale, key }));
|
|
31
|
+
} catch {
|
|
32
|
+
// a report is best-effort: the rendering already degraded, the page must not
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Locale negotiation (ADR-223 D7; spec S4 § Locale negotiation). The site's order:
|
|
2
|
+
// 1. the `locale` query parameter, when it names an available locale;
|
|
3
|
+
// 2. reserved for the end-user and organisation preference (#632) — the `preferred` hook;
|
|
4
|
+
// 3. navigator.languages (what the browser also sends as Accept-Language), RFC 4647 lookup;
|
|
5
|
+
// 4. default_locale.
|
|
6
|
+
// Nothing stores the choice outside the URL.
|
|
7
|
+
|
|
8
|
+
/** The `locale` query parameter of a URL (a string, URL or URLSearchParams). */
|
|
9
|
+
export function localeFromUrl(url) {
|
|
10
|
+
if (url === undefined || url === null) return undefined;
|
|
11
|
+
const params = url instanceof URLSearchParams ? url : new URL(String(url), 'http://x').searchParams;
|
|
12
|
+
return params.get('locale') ?? undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function canonical(available, tag) {
|
|
16
|
+
const t = String(tag).toLowerCase();
|
|
17
|
+
return available.find((a) => a.toLowerCase() === t);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC 4647 § 3.4 Lookup: for each language range in priority order, the range and then each
|
|
22
|
+
* truncation of it (dropping the last subtag, and a single-character subtag with it) is compared
|
|
23
|
+
* case-insensitively with the available tags; the first hit wins. `*` never matches.
|
|
24
|
+
*/
|
|
25
|
+
export function lookupLocale(ranges, available) {
|
|
26
|
+
for (const range of ranges ?? []) {
|
|
27
|
+
if (!range || range === '*') continue;
|
|
28
|
+
const subtags = String(range).split('-');
|
|
29
|
+
while (subtags.length > 0) {
|
|
30
|
+
const hit = canonical(available, subtags.join('-'));
|
|
31
|
+
if (hit) return hit;
|
|
32
|
+
subtags.pop();
|
|
33
|
+
if (subtags.length > 0 && subtags[subtags.length - 1].length === 1) subtags.pop();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The language ranges of an Accept-Language header, by descending q (ties keep header order). */
|
|
40
|
+
export function acceptLanguage(header) {
|
|
41
|
+
if (!header) return [];
|
|
42
|
+
return String(header)
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((part, index) => {
|
|
45
|
+
const [range, ...params] = part.trim().split(';');
|
|
46
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
|
47
|
+
return { range: range.trim(), q: q ? Number(q.slice(2)) : 1, index };
|
|
48
|
+
})
|
|
49
|
+
.filter((r) => r.range && Number.isFinite(r.q) && r.q > 0)
|
|
50
|
+
.sort((a, b) => b.q - a.q || a.index - b.index)
|
|
51
|
+
.map((r) => r.range);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The site's locale for a page load.
|
|
56
|
+
* @param {{url?: string|URL|URLSearchParams, languages?: readonly string[], available: string[],
|
|
57
|
+
* defaultLocale: string, preferred?: () => (string|undefined)}} input
|
|
58
|
+
*/
|
|
59
|
+
export function negotiateLocale({ url, languages, available, defaultLocale, preferred }) {
|
|
60
|
+
const param = localeFromUrl(url);
|
|
61
|
+
const fromParam = param && canonical(available, param);
|
|
62
|
+
if (fromParam) return fromParam;
|
|
63
|
+
const pref = preferred?.();
|
|
64
|
+
const fromPref = pref && canonical(available, pref);
|
|
65
|
+
if (fromPref) return fromPref;
|
|
66
|
+
return lookupLocale(languages, available) ?? defaultLocale;
|
|
67
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// CLDR plural categories (UTS #35 Part 3) evaluated over the bundle's rule AST — never Intl.PluralRules.
|
|
2
|
+
import { parseDecimal } from './decimal.js';
|
|
3
|
+
|
|
4
|
+
/** The UTS #35 operands of a count (a number or a decimal string; visible fraction digits count). */
|
|
5
|
+
export function operands(count) {
|
|
6
|
+
const d = parseDecimal(count);
|
|
7
|
+
const frac = d.frac;
|
|
8
|
+
const trimmed = frac.replace(/0+$/, '');
|
|
9
|
+
const i = Number(d.int);
|
|
10
|
+
return {
|
|
11
|
+
n: Number(`${d.int}${frac ? '.' + frac : ''}`),
|
|
12
|
+
i,
|
|
13
|
+
v: frac.length,
|
|
14
|
+
w: trimmed.length,
|
|
15
|
+
f: frac ? Number(frac) : 0,
|
|
16
|
+
t: trimmed ? Number(trimmed) : 0,
|
|
17
|
+
e: 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function relationMatches(rel, ops) {
|
|
22
|
+
let value = ops[rel.operand];
|
|
23
|
+
if (rel.mod) value = rel.operand === 'n' ? value % rel.mod : value % rel.mod;
|
|
24
|
+
// n (and n % m) matches a range only when integral (UTS #35: ranges are integer ranges).
|
|
25
|
+
const integral = rel.operand !== 'n' || (ops.t === 0 && Number.isInteger(value));
|
|
26
|
+
const inRange = integral && rel.ranges.some(([lo, hi]) => value >= lo && value <= hi);
|
|
27
|
+
return rel.negated ? !inRange : inRange;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ruleMatches(rule, ops) {
|
|
31
|
+
return rule.or.some((and) => and.every((rel) => relationMatches(rel, ops)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The CLDR category of `count` under a locale's ordered categories (the last is `other`). */
|
|
35
|
+
export function pluralCategory(categories, count) {
|
|
36
|
+
const ops = operands(count);
|
|
37
|
+
for (const { category, rule } of categories) {
|
|
38
|
+
if (category !== 'other' && ruleMatches(rule, ops)) return category;
|
|
39
|
+
}
|
|
40
|
+
return 'other';
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The strftime subset of spec S4 § Constants, as Rails' I18n `l` renders it: the name directives
|
|
2
|
+
// (%a %A %b %B %p %P) come from the locale data, everything else is Ruby's strftime.
|
|
3
|
+
import { dayOfYear } from './civil.js';
|
|
4
|
+
|
|
5
|
+
const pad = (n, w, c = '0') => String(n).padStart(w, c);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format a broken-down local time.
|
|
9
|
+
* @param {string} format
|
|
10
|
+
* @param {{year,month,day,hour,minute,second,wday,offset:number|null,abbr:string|null}} t
|
|
11
|
+
* offset/abbr null for a civil date (Ruby Date renders %Z as +00:00 and %z as +0000).
|
|
12
|
+
* @param {(kind: 'day_names'|'abbr_day_names'|'month_names'|'abbr_month_names'|'am'|'pm') => any} names
|
|
13
|
+
*/
|
|
14
|
+
export function strftime(format, t, names) {
|
|
15
|
+
let out = '';
|
|
16
|
+
for (let i = 0; i < format.length; i += 1) {
|
|
17
|
+
const c = format[i];
|
|
18
|
+
if (c !== '%' || i === format.length - 1) {
|
|
19
|
+
out += c;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
let spec = format[i + 1];
|
|
23
|
+
let flag = '';
|
|
24
|
+
if (spec === '-' && i + 2 < format.length) {
|
|
25
|
+
flag = '-';
|
|
26
|
+
spec = format[i + 2];
|
|
27
|
+
}
|
|
28
|
+
const r = directive(flag, spec, t, names);
|
|
29
|
+
if (r === null) {
|
|
30
|
+
out += c; // an unsupported directive is emitted as written (Ruby)
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
out += r;
|
|
34
|
+
i += flag ? 2 : 1;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function directive(flag, spec, t, names) {
|
|
40
|
+
const hour12 = t.hour % 12 === 0 ? 12 : t.hour % 12;
|
|
41
|
+
if (flag === '-') {
|
|
42
|
+
switch (spec) {
|
|
43
|
+
case 'm': return String(t.month);
|
|
44
|
+
case 'd': return String(t.day);
|
|
45
|
+
case 'H': return String(t.hour);
|
|
46
|
+
case 'I': return String(hour12);
|
|
47
|
+
default: return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
switch (spec) {
|
|
51
|
+
case 'Y': return String(t.year);
|
|
52
|
+
case 'y': return pad(((t.year % 100) + 100) % 100, 2);
|
|
53
|
+
case 'm': return pad(t.month, 2);
|
|
54
|
+
case 'd': return pad(t.day, 2);
|
|
55
|
+
case 'e': return pad(t.day, 2, ' ');
|
|
56
|
+
case 'H': return pad(t.hour, 2);
|
|
57
|
+
case 'I': return pad(hour12, 2);
|
|
58
|
+
case 'M': return pad(t.minute, 2);
|
|
59
|
+
case 'S': return pad(t.second, 2);
|
|
60
|
+
case 'j': return pad(dayOfYear(t.year, t.month, t.day), 3);
|
|
61
|
+
case 'p': return String(names(t.hour < 12 ? 'am' : 'pm')).toUpperCase();
|
|
62
|
+
case 'P': return String(names(t.hour < 12 ? 'am' : 'pm')).toLowerCase();
|
|
63
|
+
case 'A': return names('day_names')[t.wday];
|
|
64
|
+
case 'a': return names('abbr_day_names')[t.wday];
|
|
65
|
+
case 'B': return names('month_names')[t.month];
|
|
66
|
+
case 'b': return names('abbr_month_names')[t.month];
|
|
67
|
+
case 'Z': return t.offset === null ? '+00:00' : t.abbr;
|
|
68
|
+
case 'z': {
|
|
69
|
+
const o = t.offset ?? 0;
|
|
70
|
+
const a = Math.abs(o);
|
|
71
|
+
return `${o < 0 ? '-' : '+'}${pad(Math.floor(a / 3600), 2)}${pad(Math.floor((a % 3600) / 60), 2)}`;
|
|
72
|
+
}
|
|
73
|
+
case '%': return '%';
|
|
74
|
+
default: return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/i18n/zone.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Time-zone rendering from the bundle's zone rules (RFC 8536's model; contracts/deploy/v1/
|
|
2
|
+
// locale-bundle-v1.json `zone`) — never the host's time-zone database.
|
|
3
|
+
import { civilFromDays, daysFromCivil, floorDiv } from './civil.js';
|
|
4
|
+
|
|
5
|
+
/** The local time type ({offset, dst, abbr}) of `zone` at Unix second `at`. */
|
|
6
|
+
export function zoneAt(zone, at) {
|
|
7
|
+
const ts = zone.transitions;
|
|
8
|
+
if (ts.length === 0) return zone.rule ? ruleAt(zone.rule, at) : zone.initial;
|
|
9
|
+
if (at < ts[0].at) return zone.initial;
|
|
10
|
+
let lo = 0;
|
|
11
|
+
let hi = ts.length - 1;
|
|
12
|
+
while (lo < hi) {
|
|
13
|
+
const mid = (lo + hi + 1) >> 1;
|
|
14
|
+
if (ts[mid].at <= at) lo = mid;
|
|
15
|
+
else hi = mid - 1;
|
|
16
|
+
}
|
|
17
|
+
if (lo === ts.length - 1 && zone.rule) return ruleAt(zone.rule, at);
|
|
18
|
+
const { offset, dst, abbr } = ts[lo];
|
|
19
|
+
return { offset, dst, abbr };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ruleAt(rule, at) {
|
|
23
|
+
if (!rule.dst) return rule.std;
|
|
24
|
+
const [year] = civilFromDays(floorDiv(at + rule.std.offset, 86400));
|
|
25
|
+
const start = wallSecond(year, rule.start) - rule.std.offset;
|
|
26
|
+
const end = wallSecond(year, rule.end) - rule.dst.offset;
|
|
27
|
+
const inDst = start < end ? at >= start && at < end : !(at >= end && at < start);
|
|
28
|
+
return inDst ? rule.dst : rule.std;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The local wall-clock second (seconds since the epoch, as if local time were UTC) of an Mm.w.d date.
|
|
32
|
+
function wallSecond(year, d) {
|
|
33
|
+
const first = daysFromCivil(year, d.month, 1);
|
|
34
|
+
const weekday = (((first % 7) + 7 + 4) % 7); // 1970-01-01 was a Thursday
|
|
35
|
+
let day = first + ((d.weekday - weekday + 7) % 7) + (d.week - 1) * 7;
|
|
36
|
+
const next = d.month === 12 ? daysFromCivil(year + 1, 1, 1) : daysFromCivil(year, d.month + 1, 1);
|
|
37
|
+
while (day >= next) day -= 7;
|
|
38
|
+
return day * 86400 + d.time;
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @aircloudhq/web — the framework-neutral core (ADR-222 D3). Each module is also its own entry point
|
|
2
|
+
// (`@aircloudhq/web/helpers/number`, …); the React bindings are `@aircloudhq/web/react`.
|
|
3
|
+
export * from './helpers/number.js';
|
|
4
|
+
export * from './helpers/date.js';
|
|
5
|
+
export * from './helpers/text.js';
|
|
6
|
+
export * from './helpers/translation.js';
|
|
7
|
+
export * from './helpers/url.js';
|
|
8
|
+
export * from './helpers/navigation.js';
|
|
9
|
+
export * from './helpers/sanitize.js';
|
|
10
|
+
export * from './helpers/tag.js';
|
|
11
|
+
export * from './helpers/form.js';
|
|
12
|
+
export * from './model.js';
|
|
13
|
+
export * from './resource.js';
|
|
14
|
+
export * from './api.js';
|
|
15
|
+
export { createI18n, negotiateLocale, createMissingReporter } from './i18n/index.js';
|
|
16
|
+
export { setCurrentI18n, currentI18n } from './i18n/current.js';
|