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,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
+ }