slicejs-web-framework 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pure message logic for slice.translator: no DOM, no fetch, no `slice` global.
3
+ * Kept apart so it runs under node:test and is shared with the server-side SEO
4
+ * code later (feature 0003 §9).
5
+ */
6
+
7
+ /** Plural categories `Intl.PluralRules` can return, plus the explicit `zero`. */
8
+ export const PLURAL_CATEGORIES = Object.freeze(['zero', 'one', 'two', 'few', 'many', 'other']);
9
+
10
+ /** Base languages written right to left. */
11
+ const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur']);
12
+
13
+ /**
14
+ * True for a plain object (dictionary group, params bag) — not an array, a Date, a marker…
15
+ * @param {unknown} value
16
+ * @returns {value is Record<string, any>}
17
+ */
18
+ export function isPlainObject(value) {
19
+ if (!value || typeof value !== 'object') return false;
20
+ const proto = Object.getPrototypeOf(value);
21
+ return proto === Object.prototype || proto === null;
22
+ }
23
+
24
+ /**
25
+ * A plural message: an object with `other` whose keys are all plural categories.
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ export function isPluralMessage(value) {
30
+ if (!isPlainObject(value) || typeof value.other !== 'string') return false;
31
+ return Object.keys(value).every((key) => PLURAL_CATEGORIES.includes(key));
32
+ }
33
+
34
+ /**
35
+ * Deep-merge `source` into `target` (mutates and returns `target`). Plain objects merge,
36
+ * anything else replaces. Plural objects replace as a whole so categories never mix.
37
+ * @param {Record<string, any>} target
38
+ * @param {Record<string, any>} source
39
+ * @returns {Record<string, any>}
40
+ */
41
+ export function mergeMessages(target, source) {
42
+ if (!isPlainObject(source)) return target;
43
+ for (const [key, value] of Object.entries(source)) {
44
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
45
+ if (isPlainObject(value) && !isPluralMessage(value) && isPlainObject(target[key]) && !isPluralMessage(target[key])) {
46
+ mergeMessages(target[key], value);
47
+ } else if (isPlainObject(value) && !isPluralMessage(value)) {
48
+ target[key] = mergeMessages({}, value);
49
+ } else {
50
+ target[key] = value;
51
+ }
52
+ }
53
+ return target;
54
+ }
55
+
56
+ /**
57
+ * Read a dotted key from a dictionary. Returns `undefined` when any segment is missing.
58
+ * @param {Record<string, any>|undefined} dictionary
59
+ * @param {string} key
60
+ * @returns {any}
61
+ */
62
+ export function lookup(dictionary, key) {
63
+ if (!dictionary || typeof key !== 'string' || key === '') return undefined;
64
+ let node = dictionary;
65
+ for (const segment of key.split('.')) {
66
+ if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, segment)) return undefined;
67
+ node = node[segment];
68
+ }
69
+ return node;
70
+ }
71
+
72
+ /**
73
+ * Pick the supported locale for a requested tag: exact match (case-insensitive), then the
74
+ * base language (`es-VE` → `es`), then a supported regional variant of the same language
75
+ * (`pt` → `pt-BR`). Returns null when nothing matches.
76
+ * @param {string|null|undefined} requested
77
+ * @param {string[]} supported
78
+ * @returns {string|null}
79
+ */
80
+ export function matchLocale(requested, supported) {
81
+ if (!requested || typeof requested !== 'string' || !Array.isArray(supported)) return null;
82
+ const wanted = requested.trim().replace('_', '-').toLowerCase();
83
+ if (!wanted) return null;
84
+ const exact = supported.find((locale) => locale.toLowerCase() === wanted);
85
+ if (exact) return exact;
86
+ const base = wanted.split('-')[0];
87
+ return (
88
+ supported.find((locale) => locale.toLowerCase() === base) ||
89
+ supported.find((locale) => locale.toLowerCase().split('-')[0] === base) ||
90
+ null
91
+ );
92
+ }
93
+
94
+ /**
95
+ * `rtl` for Arabic, Hebrew, Persian and Urdu (any region), `ltr` otherwise.
96
+ * @param {string} locale
97
+ * @returns {'rtl'|'ltr'}
98
+ */
99
+ export function directionOf(locale) {
100
+ const base = String(locale || '').toLowerCase().split(/[-_]/)[0];
101
+ return RTL_LANGUAGES.has(base) ? 'rtl' : 'ltr';
102
+ }
103
+
104
+ /**
105
+ * Cache of Intl objects per constructor, locale and options.
106
+ */
107
+ export class IntlCache {
108
+ constructor() {
109
+ /** @type {Map<string, any>} */
110
+ this.cache = new Map();
111
+ }
112
+
113
+ /**
114
+ * @template T
115
+ * @param {new (locale: string, options?: any) => T} Ctor
116
+ * @param {string} locale
117
+ * @param {Record<string, any>} [options]
118
+ * @returns {T}
119
+ */
120
+ get(Ctor, locale, options = {}) {
121
+ const id = `${Ctor.name}|${locale}|${JSON.stringify(options)}`;
122
+ let instance = this.cache.get(id);
123
+ if (!instance) {
124
+ instance = new Ctor(locale, options);
125
+ this.cache.set(id, instance);
126
+ }
127
+ return instance;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Choose the variant of a plural message for `count`. An explicit `zero` wins for 0;
133
+ * otherwise the locale's plural category, falling back to `other`.
134
+ * @param {Record<string, string>} message
135
+ * @param {number} count
136
+ * @param {string} locale
137
+ * @param {IntlCache} intl
138
+ * @returns {string}
139
+ */
140
+ export function selectPlural(message, count, locale, intl) {
141
+ if (count === 0 && typeof message.zero === 'string') return message.zero;
142
+ let category = 'other';
143
+ try {
144
+ category = intl.get(Intl.PluralRules, locale).select(count);
145
+ } catch {
146
+ category = 'other';
147
+ }
148
+ return typeof message[category] === 'string' ? message[category] : message.other;
149
+ }
150
+
151
+ /**
152
+ * Replace `{name}` with `params.name`. Numbers are formatted for the locale; `{{` and `}}`
153
+ * are literal braces. A missing param leaves `{name}` in place and is reported through
154
+ * `onMissing(name)`.
155
+ * @param {string} template
156
+ * @param {Record<string, any>} params
157
+ * @param {string} locale
158
+ * @param {IntlCache} intl
159
+ * @param {(name: string) => void} [onMissing]
160
+ * @returns {string}
161
+ */
162
+ export function interpolate(template, params, locale, intl, onMissing) {
163
+ if (typeof template !== 'string' || template.indexOf('{') === -1 && template.indexOf('}') === -1) {
164
+ return template;
165
+ }
166
+ return template.replace(/\{\{|\}\}|\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (match, name) => {
167
+ if (match === '{{') return '{';
168
+ if (match === '}}') return '}';
169
+ if (!params || !Object.prototype.hasOwnProperty.call(params, name) || params[name] === undefined) {
170
+ onMissing?.(name);
171
+ return match;
172
+ }
173
+ const value = params[name];
174
+ if (typeof value === 'number' && Number.isFinite(value)) {
175
+ try {
176
+ return intl.get(Intl.NumberFormat, locale).format(value);
177
+ } catch {
178
+ return String(value);
179
+ }
180
+ }
181
+ return String(value);
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Turn a dictionary entry into text: plural selection, then interpolation.
187
+ * Returns `null` when the entry is not a message (missing, or a group).
188
+ * @param {any} entry
189
+ * @param {Record<string, any>|undefined} params
190
+ * @param {string} locale
191
+ * @param {IntlCache} intl
192
+ * @param {{ onMissingParam?: (name: string) => void, onMissingCount?: () => void }} [hooks]
193
+ * @returns {string|null}
194
+ */
195
+ export function formatMessage(entry, params, locale, intl, hooks = {}) {
196
+ let template = null;
197
+ if (typeof entry === 'string') {
198
+ template = entry;
199
+ } else if (isPluralMessage(entry)) {
200
+ const count = Number(params?.count);
201
+ if (params?.count === undefined || Number.isNaN(count)) {
202
+ hooks.onMissingCount?.();
203
+ template = entry.other;
204
+ } else {
205
+ template = selectPlural(entry, count, locale, intl);
206
+ }
207
+ } else {
208
+ return null;
209
+ }
210
+ return interpolate(template, params || {}, locale, intl, hooks.onMissingParam);
211
+ }