slicejs-web-framework 4.1.1 → 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.
- package/Slice/Components/Structural/Controller/Controller.js +1427 -1406
- package/Slice/Components/Structural/EventManager/EventManager.js +652 -651
- package/Slice/Components/Structural/Router/Router.js +982 -820
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +151 -8
- package/Slice/Components/Structural/Translator/Translator.js +1111 -0
- package/Slice/Components/Structural/Translator/TranslatorDebugger.js +511 -0
- package/Slice/Components/Structural/Translator/content.js +188 -0
- package/Slice/Components/Structural/Translator/dom.js +253 -0
- package/Slice/Components/Structural/Translator/localePath.js +90 -0
- package/Slice/Components/Structural/Translator/localized.js +80 -0
- package/Slice/Components/Structural/Translator/markers.js +148 -0
- package/Slice/Components/Structural/Translator/messages.js +211 -0
- package/Slice/Slice.js +826 -711
- package/api/framework/content.js +73 -0
- package/api/framework/seo.js +394 -43
- package/api/framework/server.js +205 -28
- package/package.json +2 -1
- package/sliceConfig.schema.json +150 -122
- package/types/index.d.ts +347 -219
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `data-t` in the DOM: which texts an element gets, and writing them.
|
|
3
|
+
*
|
|
4
|
+
* <h1 data-t="home.title"></h1> text ← home.title
|
|
5
|
+
* <input data-t="home.search" placeholder> placeholder ← home.search.placeholder
|
|
6
|
+
* <button data-t="cart.count" data-t-count="3"> text ← cart.count with { count: 3 }
|
|
7
|
+
* <p data-t="signup.terms"> "Acepto los {link}." rebuilt around
|
|
8
|
+
* <a data-t-slot="link" data-t="signup.link"></a> the slot element, which keeps its
|
|
9
|
+
* </p> attributes and listeners
|
|
10
|
+
*
|
|
11
|
+
* `templateKeys()` is the rule the CLI reuses (`slice translator check`) to know which keys
|
|
12
|
+
* a template needs, so the two cannot drift.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Attributes translated when present on an element with `data-t`, by tag ('*' = any). */
|
|
16
|
+
export const TRANSLATABLE_ATTRIBUTES = Object.freeze({
|
|
17
|
+
placeholder: '*',
|
|
18
|
+
title: '*',
|
|
19
|
+
alt: '*',
|
|
20
|
+
'aria-label': '*',
|
|
21
|
+
'aria-description': '*',
|
|
22
|
+
'aria-placeholder': '*',
|
|
23
|
+
'aria-roledescription': '*',
|
|
24
|
+
'aria-valuetext': '*',
|
|
25
|
+
label: ['OPTION', 'OPTGROUP', 'TRACK'],
|
|
26
|
+
value: ['INPUT'],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** `data-t-*` names that are directives, not message params. */
|
|
30
|
+
export const RESERVED_PARAMS = Object.freeze(['text', 'slot']);
|
|
31
|
+
|
|
32
|
+
/** Elements that never hold text. */
|
|
33
|
+
const VOID_ELEMENTS = new Set([
|
|
34
|
+
'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR',
|
|
35
|
+
'SELECT',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const INPUT_VALUE_TYPES = new Set(['button', 'submit', 'reset']);
|
|
39
|
+
|
|
40
|
+
const SLOT_MARK = '\u0000';
|
|
41
|
+
const SLOT_PATTERN = /\u0000slot:([^\u0000]+)\u0000/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `aria-label` → `ariaLabel`; `user-name` → `userName`.
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function camelCase(name) {
|
|
49
|
+
return name.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Translatable attributes present on an element (even empty).
|
|
54
|
+
* @param {{ tagName: string, hasAttribute(name: string): boolean, getAttribute(name: string): string|null }} element
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
export function translatableAttributes(element) {
|
|
58
|
+
const tag = String(element.tagName || '').toUpperCase();
|
|
59
|
+
const found = [];
|
|
60
|
+
for (const [attribute, tags] of Object.entries(TRANSLATABLE_ATTRIBUTES)) {
|
|
61
|
+
if (!element.hasAttribute(attribute)) continue;
|
|
62
|
+
if (tags !== '*' && !tags.includes(tag)) continue;
|
|
63
|
+
if (attribute === 'value' && !INPUT_VALUE_TYPES.has(String(element.getAttribute('type') || '').toLowerCase())) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
found.push(attribute);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The keys an element with `data-t` needs: its text (unless it only fills attributes) and
|
|
73
|
+
* `key.<attribute>` for each translatable attribute present.
|
|
74
|
+
* @param {{ tagName: string, hasAttribute(name: string): boolean, getAttribute(name: string): string|null }} element
|
|
75
|
+
* @returns {{ key: string, text: string|null, attributes: Record<string, string> }|null}
|
|
76
|
+
*/
|
|
77
|
+
export function templateKeys(element) {
|
|
78
|
+
const key = (element.getAttribute('data-t') || '').trim();
|
|
79
|
+
if (!key) return null;
|
|
80
|
+
/** @type {Record<string, string>} */
|
|
81
|
+
const attributes = {};
|
|
82
|
+
const present = translatableAttributes(element);
|
|
83
|
+
for (const attribute of present) attributes[attribute] = `${key}.${camelCase(attribute)}`;
|
|
84
|
+
const tag = String(element.tagName || '').toUpperCase();
|
|
85
|
+
const fillsText = !VOID_ELEMENTS.has(tag) && (present.length === 0 || element.hasAttribute('data-t-text'));
|
|
86
|
+
return { key, text: fillsText ? key : null, attributes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Message params from `data-t-*` attributes: `data-t-count="3"` → `{ count: 3 }`.
|
|
91
|
+
* Numeric strings become numbers so plurals and number formatting work.
|
|
92
|
+
* @param {Element} element
|
|
93
|
+
* @returns {Record<string, string|number>}
|
|
94
|
+
*/
|
|
95
|
+
export function paramsOf(element) {
|
|
96
|
+
/** @type {Record<string, string|number>} */
|
|
97
|
+
const params = {};
|
|
98
|
+
for (const { name, value } of Array.from(element.attributes)) {
|
|
99
|
+
if (!name.startsWith('data-t-')) continue;
|
|
100
|
+
const param = name.slice(7);
|
|
101
|
+
if (!param || RESERVED_PARAMS.includes(param)) continue;
|
|
102
|
+
params[camelCase(param)] = /^-?\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : value;
|
|
103
|
+
}
|
|
104
|
+
return params;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Elements under `root` (and `root` itself) that carry `data-t`.
|
|
109
|
+
* @param {ParentNode & Partial<Element>} root
|
|
110
|
+
* @returns {Element[]}
|
|
111
|
+
*/
|
|
112
|
+
export function translatableIn(root) {
|
|
113
|
+
const list = [];
|
|
114
|
+
if (root && typeof root.hasAttribute === 'function' && root.hasAttribute('data-t')) list.push(/** @type {Element} */ (root));
|
|
115
|
+
if (root && typeof root.querySelectorAll === 'function') list.push(...root.querySelectorAll('[data-t]'));
|
|
116
|
+
return list;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {Object} DomContext
|
|
121
|
+
* @property {(key: string, params?: Record<string, any>) => string} t
|
|
122
|
+
* @property {() => string} signature locale + dictionary version, to skip elements already up to date
|
|
123
|
+
* @property {WeakMap<Element, string>} applied
|
|
124
|
+
* @property {WeakMap<Element, Map<string, Element>>} slots
|
|
125
|
+
* @property {(message: string) => void} [warn]
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Translate one element. Skips it when nothing changed since the last pass (same locale,
|
|
130
|
+
* dictionaries, key and params), so the observer and explicit passes can overlap freely.
|
|
131
|
+
* @param {Element} element
|
|
132
|
+
* @param {DomContext} context
|
|
133
|
+
* @returns {boolean} whether something was written
|
|
134
|
+
*/
|
|
135
|
+
export function translateElement(element, context) {
|
|
136
|
+
const keys = templateKeys(element);
|
|
137
|
+
if (!keys) return false;
|
|
138
|
+
|
|
139
|
+
const params = paramsOf(element);
|
|
140
|
+
const stamp = `${context.signature()}|${keys.key}|${JSON.stringify(params)}|${Object.keys(keys.attributes).join(',')}|${keys.text ? 1 : 0}`;
|
|
141
|
+
if (context.applied.get(element) === stamp) return false;
|
|
142
|
+
context.applied.set(element, stamp);
|
|
143
|
+
|
|
144
|
+
for (const [attribute, key] of Object.entries(keys.attributes)) {
|
|
145
|
+
const text = context.t(key, params);
|
|
146
|
+
if (element.getAttribute(attribute) !== text) element.setAttribute(attribute, text);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (keys.text) {
|
|
150
|
+
writeText(element, keys.key, params, context);
|
|
151
|
+
} else if (Object.keys(keys.attributes).length === 0) {
|
|
152
|
+
context.warn?.(`data-t="${keys.key}" on <${element.tagName.toLowerCase()}> has nothing to fill: add a translatable attribute (placeholder, aria-label…)`);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Translate every `data-t` element under `root`.
|
|
159
|
+
* @param {ParentNode} root
|
|
160
|
+
* @param {DomContext} context
|
|
161
|
+
* @returns {number} elements written
|
|
162
|
+
*/
|
|
163
|
+
export function translateTree(root, context) {
|
|
164
|
+
let written = 0;
|
|
165
|
+
for (const element of translatableIn(root)) {
|
|
166
|
+
if (translateElement(element, context)) written++;
|
|
167
|
+
}
|
|
168
|
+
return written;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {Element} element
|
|
173
|
+
* @param {string} key
|
|
174
|
+
* @param {Record<string, any>} params
|
|
175
|
+
* @param {DomContext} context
|
|
176
|
+
*/
|
|
177
|
+
function writeText(element, key, params, context) {
|
|
178
|
+
const slots = slotsOf(element, context);
|
|
179
|
+
|
|
180
|
+
if (slots.size > 0) {
|
|
181
|
+
/** @type {Record<string, any>} */
|
|
182
|
+
const withSlots = { ...params };
|
|
183
|
+
for (const name of slots.keys()) withSlots[name] = `${SLOT_MARK}slot:${name}${SLOT_MARK}`;
|
|
184
|
+
const message = context.t(key, withSlots);
|
|
185
|
+
const nodes = [];
|
|
186
|
+
const used = new Set();
|
|
187
|
+
const parts = message.split(SLOT_PATTERN);
|
|
188
|
+
// split with a capture group alternates: text, slot name, text, slot name, …
|
|
189
|
+
parts.forEach((part, index) => {
|
|
190
|
+
if (index % 2 === 0) {
|
|
191
|
+
if (part) nodes.push(element.ownerDocument.createTextNode(part));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const slot = slots.get(part);
|
|
195
|
+
if (slot && !used.has(part)) {
|
|
196
|
+
used.add(part);
|
|
197
|
+
nodes.push(slot);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
element.replaceChildren(...nodes);
|
|
201
|
+
for (const [name, slot] of slots) {
|
|
202
|
+
if (used.has(name)) slot.__sliceSlotDetached = false;
|
|
203
|
+
else if (!slot.isConnected) slot.__sliceSlotDetached = true;
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const text = context.t(key, params);
|
|
209
|
+
if (element.children.length === 0) {
|
|
210
|
+
if (element.textContent !== text) element.textContent = text;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Child elements without data-t-slot (an icon in a button): keep them and replace only
|
|
215
|
+
// the element's own text, where the first visible text was (or at the end).
|
|
216
|
+
const ownText = Array.from(element.childNodes).filter((node) => node.nodeType === 3);
|
|
217
|
+
const anchor = ownText.find((node) => (node.textContent || '').trim() !== '');
|
|
218
|
+
if (anchor && ownText.filter((node) => (node.textContent || '').trim() !== '').length === 1 && anchor.textContent === text) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const node = element.ownerDocument.createTextNode(text);
|
|
222
|
+
if (anchor) element.insertBefore(node, anchor);
|
|
223
|
+
else element.appendChild(node);
|
|
224
|
+
for (const old of ownText) if ((old.textContent || '').trim() !== '') old.remove();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Slot elements of `element`: descendants with `data-t-slot` whose closest `data-t`
|
|
229
|
+
* ancestor is `element`. Remembered, so a locale whose message omits a slot does not lose
|
|
230
|
+
* it for the next locale.
|
|
231
|
+
* @param {Element} element
|
|
232
|
+
* @param {DomContext} context
|
|
233
|
+
* @returns {Map<string, Element>}
|
|
234
|
+
*/
|
|
235
|
+
function slotsOf(element, context) {
|
|
236
|
+
let slots = context.slots.get(element);
|
|
237
|
+
if (!slots) slots = new Map();
|
|
238
|
+
const present = new Map();
|
|
239
|
+
for (const candidate of element.querySelectorAll('[data-t-slot]')) {
|
|
240
|
+
if (candidate.parentElement?.closest('[data-t]') !== element) continue;
|
|
241
|
+
const name = candidate.getAttribute('data-t-slot');
|
|
242
|
+
if (!name) continue;
|
|
243
|
+
present.set(name, candidate);
|
|
244
|
+
}
|
|
245
|
+
// A slot the app removed is gone; one this helper detached to show a message without
|
|
246
|
+
// it is kept for the next locale (that is why the cache exists).
|
|
247
|
+
for (const [name, node] of slots) {
|
|
248
|
+
if (!node.isConnected && !node.__sliceSlotDetached) slots.delete(name);
|
|
249
|
+
}
|
|
250
|
+
for (const [name, node] of present) slots.set(name, node);
|
|
251
|
+
if (slots.size) context.slots.set(element, slots);
|
|
252
|
+
return slots;
|
|
253
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locale in the URL (`localePrefix`, feature 0003 §7). Pure: no DOM, no `slice` global, no
|
|
3
|
+
* imports — the server-side SEO code uses the same rules.
|
|
4
|
+
*
|
|
5
|
+
* localePrefix: "always" /es/pricing /en/pricing (`/` redirects to a locale)
|
|
6
|
+
* localePrefix: "except-default" /pricing /en/pricing (default locale has no prefix)
|
|
7
|
+
* localePrefix: false /pricing (the locale is never in the URL)
|
|
8
|
+
*
|
|
9
|
+
* Routes are declared once, without a locale; the prefix is added and removed here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @typedef {false|'always'|'except-default'} LocalePrefix */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize the `localePrefix` option: anything else means "not in the URL".
|
|
16
|
+
* @param {unknown} value
|
|
17
|
+
* @returns {LocalePrefix}
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeLocalePrefix(value) {
|
|
20
|
+
return value === 'always' || value === 'except-default' ? value : false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split `?query#hash` off a path.
|
|
25
|
+
* @param {string} path
|
|
26
|
+
* @returns {[string, string]} [pathname, suffix]
|
|
27
|
+
*/
|
|
28
|
+
export function splitSuffix(path) {
|
|
29
|
+
const index = path.search(/[?#]/);
|
|
30
|
+
return index === -1 ? [path, ''] : [path.slice(0, index), path.slice(index)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove a known locale prefix: `/en/pricing?x=1` → `{ locale: 'en', path: '/pricing?x=1' }`.
|
|
35
|
+
* The match is case-insensitive and returns the locale as configured. A first segment that
|
|
36
|
+
* is not a configured locale (`/fr/…`, `/docs/…`) is part of the path.
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @param {readonly string[]} locales
|
|
39
|
+
* @returns {{ locale: string|null, path: string }}
|
|
40
|
+
*/
|
|
41
|
+
export function splitLocalePath(url, locales) {
|
|
42
|
+
const value = typeof url === 'string' && url ? url : '/';
|
|
43
|
+
const [pathname, suffix] = splitSuffix(value);
|
|
44
|
+
const match = /^\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
45
|
+
if (!match || !Array.isArray(locales)) return { locale: null, path: value };
|
|
46
|
+
const segment = match[1].toLowerCase();
|
|
47
|
+
const locale = locales.find((candidate) => candidate.toLowerCase() === segment);
|
|
48
|
+
if (!locale) return { locale: null, path: value };
|
|
49
|
+
return { locale, path: `${match[2] || '/'}${suffix}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The URL of an app path in a locale.
|
|
54
|
+
* @param {string} path app path, without locale (may carry `?query#hash`)
|
|
55
|
+
* @param {string|null} locale
|
|
56
|
+
* @param {{ localePrefix: LocalePrefix, defaultLocale: string }} options
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function localizePath(path, locale, { localePrefix, defaultLocale }) {
|
|
60
|
+
const value = typeof path === 'string' && path ? path : '/';
|
|
61
|
+
if (!localePrefix || !locale) return value;
|
|
62
|
+
if (localePrefix === 'except-default' && locale === defaultLocale) return value;
|
|
63
|
+
const [pathname, suffix] = splitSuffix(value);
|
|
64
|
+
return `/${locale}${pathname === '/' ? '' : pathname}${suffix}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The locale a URL stands for: its prefix, or — with "except-default" — the default locale
|
|
69
|
+
* when there is none. `null` with "always" and no prefix (the locale must be chosen and the
|
|
70
|
+
* URL redirected), and always `null` without a prefix.
|
|
71
|
+
* @param {string} url
|
|
72
|
+
* @param {{ localePrefix: LocalePrefix, defaultLocale: string, locales: readonly string[] }} options
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function localeOfUrl(url, { localePrefix, defaultLocale, locales }) {
|
|
76
|
+
if (!localePrefix) return null;
|
|
77
|
+
const { locale } = splitLocalePath(url, locales);
|
|
78
|
+
if (locale) return locale;
|
|
79
|
+
return localePrefix === 'except-default' ? defaultLocale : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether `href` is something `localizePath` must leave alone: another origin, a scheme
|
|
84
|
+
* (`mailto:`), a protocol-relative URL, a fragment, a query, or a relative path.
|
|
85
|
+
* @param {string} href
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isExternalHref(href) {
|
|
89
|
+
return typeof href !== 'string' || !href.startsWith('/') || href.startsWith('//');
|
|
90
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized content values: `{ en: ..., es: ... }` → the branch for the active locale.
|
|
3
|
+
*
|
|
4
|
+
* Pure on purpose (no DOM, no `slice` global, no imports) so the browser runtime, the CLI
|
|
5
|
+
* and the server can share exactly one resolver. This is not a dictionary lookup: it never
|
|
6
|
+
* sees keys, only a value that carries one branch per locale. UI messages still live in
|
|
7
|
+
* dictionaries and go through `t()`; content records use this.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOCALE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i;
|
|
11
|
+
const MAX_DEPTH = 24;
|
|
12
|
+
|
|
13
|
+
/** `en` and `en-US` are the same language for branch selection. */
|
|
14
|
+
function sameLocale(a, b) {
|
|
15
|
+
const left = String(a).toLowerCase();
|
|
16
|
+
const right = String(b).toLowerCase();
|
|
17
|
+
return left === right || left.split('-')[0] === right.split('-')[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value @returns {value is Record<string, any>} */
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
if (!value || typeof value !== 'object') return false;
|
|
23
|
+
const proto = Object.getPrototypeOf(value);
|
|
24
|
+
return proto === Object.prototype || proto === null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether `value` is an object whose keys are locale tags (`{ en: …, es: … }`).
|
|
29
|
+
* With a configured list, every key must be one of those locales; without one (translator
|
|
30
|
+
* disabled), every key has to look like a language tag.
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @param {readonly string[]} [locales]
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isLocalizedObject(value, locales) {
|
|
36
|
+
if (!isPlainObject(value)) return false;
|
|
37
|
+
const keys = Object.keys(value);
|
|
38
|
+
if (keys.length === 0) return false;
|
|
39
|
+
if (Array.isArray(locales) && locales.length > 0) {
|
|
40
|
+
const configured = new Set(locales.map((locale) => String(locale).toLowerCase()));
|
|
41
|
+
// Every key must be a locale tag, and at least one must be configured. The extra tags
|
|
42
|
+
// still count: with the translator off (locales = [browser]) `{ en, es }` must resolve
|
|
43
|
+
// to the browser branch instead of leaking the whole object into the UI.
|
|
44
|
+
return keys.every((key) => configured.has(key.toLowerCase()) || LOCALE_TAG.test(key))
|
|
45
|
+
&& keys.some((key) => [...configured].some((locale) => sameLocale(key, locale)));
|
|
46
|
+
}
|
|
47
|
+
return keys.every((key) => LOCALE_TAG.test(key));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a value for one locale. Localized objects pick their branch; arrays and plain
|
|
52
|
+
* objects are walked recursively; everything else is returned untouched.
|
|
53
|
+
*
|
|
54
|
+
* Fallback order: `locale` → `fallbackLocale` → `defaultLocale`. A localized object with
|
|
55
|
+
* none of those branches resolves to `undefined` (the caller decides whether that is an
|
|
56
|
+
* error; the content validator catches it before deploy).
|
|
57
|
+
* @param {any} value
|
|
58
|
+
* @param {{ locale?: string, fallbackLocale?: string|null, defaultLocale?: string, locales?: readonly string[] }} [options]
|
|
59
|
+
* @param {number} [depth]
|
|
60
|
+
* @returns {any}
|
|
61
|
+
*/
|
|
62
|
+
export function localize(value, options = {}, depth = 0) {
|
|
63
|
+
if (value === null || value === undefined || depth > MAX_DEPTH) return value;
|
|
64
|
+
if (Array.isArray(value)) return value.map((item) => localize(item, options, depth + 1));
|
|
65
|
+
if (!isPlainObject(value)) return value;
|
|
66
|
+
|
|
67
|
+
if (isLocalizedObject(value, options.locales)) {
|
|
68
|
+
const chain = [options.locale, options.fallbackLocale, options.defaultLocale]
|
|
69
|
+
.filter((locale) => typeof locale === 'string' && locale);
|
|
70
|
+
for (const locale of chain) {
|
|
71
|
+
const key = Object.keys(value).find((candidate) => sameLocale(candidate, locale));
|
|
72
|
+
if (key !== undefined) return localize(value[key], options, depth + 1);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [key, item] of Object.entries(value)) out[key] = localize(item, options, depth + 1);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key references for component props: `slice.t.ref('orders.save')`.
|
|
3
|
+
*
|
|
4
|
+
* A marker is what an app passes instead of a string. The framework resolves it before
|
|
5
|
+
* the component sees it (the component only ever receives text) and assigns the prop
|
|
6
|
+
* again when the locale changes. Pure: no DOM, no `slice` global.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Self-contained on purpose: the CLI bundler inlines the helpers a Structural component
|
|
10
|
+
// imports, but not helpers imported by those helpers.
|
|
11
|
+
/** @param {unknown} value @returns {value is Record<string, any>} */
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
if (!value || typeof value !== 'object') return false;
|
|
14
|
+
const proto = Object.getPrototypeOf(value);
|
|
15
|
+
return proto === Object.prototype || proto === null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class TranslationRef {
|
|
19
|
+
/** @type {(() => void) | undefined} */
|
|
20
|
+
#onStringify;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} key
|
|
24
|
+
* @param {Record<string, any>} [params]
|
|
25
|
+
* @param {() => void} [onStringify] called when the marker is used as text by mistake
|
|
26
|
+
*/
|
|
27
|
+
constructor(key, params, onStringify) {
|
|
28
|
+
this.key = String(key);
|
|
29
|
+
this.params = params && typeof params === 'object' ? Object.freeze({ ...params }) : undefined;
|
|
30
|
+
this.#onStringify = onStringify;
|
|
31
|
+
Object.freeze(this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A marker that ends up as text (`'Save: ' + marker`) shows its key, and warns in
|
|
36
|
+
* development: it was meant for a prop, not for string concatenation.
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
toString() {
|
|
40
|
+
this.#onStringify?.();
|
|
41
|
+
return this.key;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} hint */
|
|
45
|
+
[Symbol.toPrimitive](hint) {
|
|
46
|
+
return hint === 'number' ? NaN : this.toString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toJSON() {
|
|
50
|
+
return this.toString();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A reference to a *list* of messages for a component prop, from `slice.t.ref.list()`.
|
|
56
|
+
* It resolves to an array, and the whole prop is assigned again on a locale change.
|
|
57
|
+
*/
|
|
58
|
+
export class TranslationListRef {
|
|
59
|
+
/** @type {(() => void) | undefined} */
|
|
60
|
+
#onStringify;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} key
|
|
64
|
+
* @param {Record<string, any>} [params]
|
|
65
|
+
* @param {() => void} [onStringify] called when the marker is used as text by mistake
|
|
66
|
+
*/
|
|
67
|
+
constructor(key, params, onStringify) {
|
|
68
|
+
this.key = String(key);
|
|
69
|
+
this.params = params && typeof params === 'object' ? Object.freeze({ ...params }) : undefined;
|
|
70
|
+
this.#onStringify = onStringify;
|
|
71
|
+
Object.freeze(this);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @returns {string} */
|
|
75
|
+
toString() {
|
|
76
|
+
this.#onStringify?.();
|
|
77
|
+
return this.key;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} hint */
|
|
81
|
+
[Symbol.toPrimitive](hint) {
|
|
82
|
+
return hint === 'number' ? NaN : this.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
toJSON() {
|
|
86
|
+
return this.toString();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {value is TranslationRef}
|
|
93
|
+
*/
|
|
94
|
+
export function isMarker(value) {
|
|
95
|
+
return value instanceof TranslationRef;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @returns {value is TranslationListRef}
|
|
101
|
+
*/
|
|
102
|
+
export function isListMarker(value) {
|
|
103
|
+
return value instanceof TranslationListRef;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a prop value holds a marker: itself, or nested in arrays and plain objects.
|
|
108
|
+
* Class instances (components, Dates, Maps…) are not walked.
|
|
109
|
+
* @param {unknown} value
|
|
110
|
+
* @param {number} [depth]
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function containsMarker(value, depth = 0) {
|
|
114
|
+
if (isMarker(value) || isListMarker(value)) return true;
|
|
115
|
+
if (depth > 8) return false;
|
|
116
|
+
if (Array.isArray(value)) return value.some((item) => containsMarker(item, depth + 1));
|
|
117
|
+
if (isPlainObject(value)) return Object.values(value).some((item) => containsMarker(item, depth + 1));
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A copy of `value` with every marker replaced by `translate(key, params)` (or
|
|
123
|
+
* `translateList(key, params)` for a list marker). Arrays and plain objects are copied only
|
|
124
|
+
* along the paths that hold markers; everything else keeps its identity. Markers inside a
|
|
125
|
+
* marker's params resolve too.
|
|
126
|
+
* @param {unknown} value
|
|
127
|
+
* @param {(key: string, params?: Record<string, any>) => string} translate
|
|
128
|
+
* @param {number} [depth]
|
|
129
|
+
* @param {((key: string, params?: Record<string, any>) => any) | null} [translateList]
|
|
130
|
+
* @returns {any}
|
|
131
|
+
*/
|
|
132
|
+
export function resolveMarkers(value, translate, depth = 0, translateList = null) {
|
|
133
|
+
if (isMarker(value) || isListMarker(value)) {
|
|
134
|
+
const params = value.params ? resolveMarkers(value.params, translate, depth + 1, translateList) : undefined;
|
|
135
|
+
if (isListMarker(value)) {
|
|
136
|
+
return typeof translateList === 'function' ? translateList(value.key, params) : translate(value.key, params);
|
|
137
|
+
}
|
|
138
|
+
return translate(value.key, params);
|
|
139
|
+
}
|
|
140
|
+
if (depth > 8 || !containsMarker(value, depth)) return value;
|
|
141
|
+
if (Array.isArray(value)) return value.map((item) => resolveMarkers(item, translate, depth + 1, translateList));
|
|
142
|
+
/** @type {Record<string, any>} */
|
|
143
|
+
const copy = {};
|
|
144
|
+
for (const [key, item] of Object.entries(/** @type {Record<string, any>} */ (value))) {
|
|
145
|
+
copy[key] = resolveMarkers(item, translate, depth + 1, translateList);
|
|
146
|
+
}
|
|
147
|
+
return copy;
|
|
148
|
+
}
|