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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Translator debug panel: the active locale, a switcher, the keys the current page needs
3
+ * that the active locale does not have, and a "show keys" switch.
4
+ *
5
+ * All the data comes from `slice.translator`; the panel never changes how the app
6
+ * translates except through the two documented debugger entry points (`setLocale` and
7
+ * `setShowKeys`).
8
+ */
9
+ import { templateKeys } from './dom.js';
10
+
11
+ export default class TranslatorDebugger extends HTMLElement {
12
+ constructor() {
13
+ super();
14
+ this.isOpen = false;
15
+ this._changeSubscription = null;
16
+ this._renderTimer = null;
17
+ }
18
+
19
+ async init() {
20
+ this.innerHTML = this.renderTemplate();
21
+ slice.stylesManager.registerComponentStyles('TranslatorDebugger', this.renderStyles());
22
+ this.cacheElements();
23
+ this.bindEvents();
24
+ this.makeDraggable();
25
+ this._subscribe();
26
+ this.render();
27
+ }
28
+
29
+ toggle() {
30
+ this.isOpen = !this.isOpen;
31
+ this.container.classList.toggle('active', this.isOpen);
32
+ if (this.isOpen) this.render();
33
+ }
34
+
35
+ open() {
36
+ this.isOpen = true;
37
+ this.container.classList.add('active');
38
+ this.render();
39
+ }
40
+
41
+ close() {
42
+ this.isOpen = false;
43
+ this.container.classList.remove('active');
44
+ }
45
+
46
+ disconnectedCallback() {
47
+ if (this._changeSubscription && slice.events?.unsubscribe) {
48
+ slice.events.unsubscribe('translator:change', this._changeSubscription);
49
+ }
50
+ this._changeSubscription = null;
51
+ }
52
+
53
+ _subscribe() {
54
+ if (this._changeSubscription || typeof slice.events?.subscribe !== 'function') return;
55
+ this._changeSubscription = slice.events.subscribe('translator:change', () => this._scheduleRender());
56
+ }
57
+
58
+ _scheduleRender() {
59
+ if (this._renderTimer) clearTimeout(this._renderTimer);
60
+ this._renderTimer = setTimeout(() => {
61
+ if (this.isOpen) this.render();
62
+ }, 40);
63
+ }
64
+
65
+ cacheElements() {
66
+ this.container = this.querySelector('#translator-debugger');
67
+ this.header = this.querySelector('.translator-header');
68
+ this.localeLabel = this.querySelector('#translator-locale');
69
+ this.localeButtons = this.querySelector('#translator-locales');
70
+ this.showKeysInput = this.querySelector('#translator-show-keys');
71
+ this.missingCount = this.querySelector('#translator-missing-count');
72
+ this.meta = this.querySelector('#translator-meta');
73
+ this.list = this.querySelector('#translator-missing');
74
+ this.refreshButton = this.querySelector('#translator-refresh');
75
+ this.closeButton = this.querySelector('#translator-close');
76
+ }
77
+
78
+ bindEvents() {
79
+ this.refreshButton.addEventListener('click', () => this.render());
80
+ this.closeButton.addEventListener('click', () => this.close());
81
+ this.showKeysInput.addEventListener('change', (event) => {
82
+ slice.translator?.setShowKeys?.(event.target.checked);
83
+ this.render();
84
+ });
85
+ this.localeButtons.addEventListener('click', (event) => {
86
+ const button = event.target.closest('[data-locale]');
87
+ if (button) void this._switchLocale(button.dataset.locale);
88
+ });
89
+ }
90
+
91
+ async _switchLocale(locale) {
92
+ try {
93
+ await slice.translator.setLocale(locale);
94
+ } catch (error) {
95
+ slice.logger?.logError?.('TranslatorDebugger', `Could not switch to "${locale}"`, error);
96
+ }
97
+ this.render();
98
+ }
99
+
100
+ makeDraggable() {
101
+ if (!this.header || !this.container) return;
102
+
103
+ let offset = { x: 0, y: 0 };
104
+ let isDragging = false;
105
+
106
+ this.header.style.cursor = 'grab';
107
+
108
+ this.header.addEventListener('mousedown', (event) => {
109
+ if (event.target.closest('.btn')) return;
110
+ isDragging = true;
111
+ offset.x = event.clientX - this.container.getBoundingClientRect().left;
112
+ offset.y = event.clientY - this.container.getBoundingClientRect().top;
113
+ this.header.style.cursor = 'grabbing';
114
+ });
115
+
116
+ document.addEventListener('mousemove', (event) => {
117
+ if (!isDragging) return;
118
+ const rect = this.container.getBoundingClientRect();
119
+ const maxX = window.innerWidth - rect.width;
120
+ const maxY = window.innerHeight - rect.height;
121
+ const x = Math.min(Math.max(event.clientX - offset.x, 0), maxX);
122
+ const y = Math.min(Math.max(event.clientY - offset.y, 0), maxY);
123
+ this.container.style.left = `${x}px`;
124
+ this.container.style.top = `${y}px`;
125
+ this.container.style.right = 'auto';
126
+ this.container.style.bottom = 'auto';
127
+ });
128
+
129
+ document.addEventListener('mouseup', () => {
130
+ if (!isDragging) return;
131
+ isDragging = false;
132
+ this.header.style.cursor = 'grab';
133
+ });
134
+ }
135
+
136
+ render() {
137
+ // The panel can be created before `slice.events` exists (Slice.js wiring order);
138
+ // retry the subscription on every render so it eventually attaches.
139
+ this._subscribe();
140
+ const translator = slice.translator;
141
+ if (!translator) {
142
+ this.meta.textContent = 'slice.translator is not available.';
143
+ this.list.innerHTML = '';
144
+ return;
145
+ }
146
+
147
+ this.localeLabel.textContent = translator.locale || '—';
148
+ this.showKeysInput.checked = translator.showKeys === true;
149
+ this.localeButtons.innerHTML = (translator.locales || [])
150
+ .map((locale) => `
151
+ <button type="button" class="locale${locale === translator.locale ? ' active' : ''}"
152
+ data-locale="${this.escapeHtml(locale)}" aria-pressed="${locale === translator.locale}">
153
+ ${this.escapeHtml(locale)}
154
+ </button>
155
+ `)
156
+ .join('');
157
+
158
+ this.meta.textContent = [
159
+ `default ${translator.defaultLocale}`,
160
+ translator.fallbackLocale ? `fallback ${translator.fallbackLocale}` : 'no fallback',
161
+ translator.localePrefix ? `localePrefix ${translator.localePrefix}` : 'locale not in the URL',
162
+ translator.enabled ? 'enabled' : 'disabled'
163
+ ].join(' · ');
164
+
165
+ if (!translator.enabled) {
166
+ this.missingCount.textContent = '0';
167
+ this.list.innerHTML = '<div class="empty">Translator disabled in sliceConfig.json</div>';
168
+ return;
169
+ }
170
+
171
+ const { missing, fallback } = this._scanPage();
172
+ this.missingCount.textContent = String(missing.length);
173
+ this.list.innerHTML = [...missing, ...fallback].length
174
+ ? [...missing, ...fallback].map((item) => `
175
+ <div class="key-row ${item.kind}">
176
+ <code class="key">${this.escapeHtml(item.key)}</code>
177
+ <span class="where">${this.escapeHtml(item.where)}</span>
178
+ <span class="tag">${item.kind}</span>
179
+ </div>
180
+ `).join('')
181
+ : '<div class="empty">Every key on this page resolves</div>';
182
+ }
183
+
184
+ /**
185
+ * `data-t` elements on the page, split into keys absent everywhere (the key is on
186
+ * screen) and keys answered by the fallback locale.
187
+ */
188
+ _scanPage() {
189
+ const translator = slice.translator;
190
+ const missing = new Map();
191
+ const fallback = new Map();
192
+ const add = (map, key, where) => {
193
+ const current = map.get(key);
194
+ if (current) current.count += 1;
195
+ else map.set(key, { key, where, count: 1, kind: map === missing ? 'missing' : 'fallback' });
196
+ };
197
+
198
+ for (const element of document.querySelectorAll('[data-t]')) {
199
+ const keys = templateKeys(element);
200
+ if (!keys) continue;
201
+ const needed = [keys.text, ...Object.values(keys.attributes)].filter(Boolean);
202
+ for (const key of needed) {
203
+ if (translator.has(key)) continue;
204
+ if (translator.fallbackLocale && translator.has(key, translator.fallbackLocale)) {
205
+ add(fallback, key, `<${element.tagName.toLowerCase()}>`);
206
+ } else {
207
+ add(missing, key, `<${element.tagName.toLowerCase()}>`);
208
+ }
209
+ }
210
+ }
211
+
212
+ const toList = (map) => [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
213
+ return { missing: toList(missing), fallback: toList(fallback) };
214
+ }
215
+
216
+ escapeHtml(value) {
217
+ return String(value ?? '')
218
+ .replace(/&/g, '&amp;')
219
+ .replace(/</g, '&lt;')
220
+ .replace(/>/g, '&gt;')
221
+ .replace(/"/g, '&quot;')
222
+ .replace(/'/g, '&#39;');
223
+ }
224
+
225
+ renderTemplate() {
226
+ return `
227
+ <div id="translator-debugger">
228
+ <div class="translator-header">
229
+ <div class="brand">
230
+ <span class="status-dot"></span>
231
+ <span class="glyph">文</span>
232
+ <span class="title">TRANSLATOR</span>
233
+ </div>
234
+ <div class="actions">
235
+ <button id="translator-refresh" class="btn" title="Refresh" aria-label="Refresh">⟳</button>
236
+ <button id="translator-close" class="btn" title="Close" aria-label="Close">✕</button>
237
+ </div>
238
+ </div>
239
+ <div class="translator-toolbar">
240
+ <span id="translator-locale" class="pill">—</span>
241
+ <div id="translator-locales" class="locales"></div>
242
+ <label class="keys-toggle">
243
+ <input id="translator-show-keys" type="checkbox" /> keys
244
+ </label>
245
+ <div class="count"><span id="translator-missing-count">0</span> missing</div>
246
+ </div>
247
+ <div class="translator-meta" id="translator-meta"></div>
248
+ <div class="translator-list" id="translator-missing"></div>
249
+ </div>
250
+ `;
251
+ }
252
+
253
+ renderStyles() {
254
+ return `
255
+ /* Slice Instruments — translator. Scoped to <slice-translator-debugger>; every --si-*
256
+ token reads the framework theme variable, with the instrument value as fallback. */
257
+ slice-translator-debugger {
258
+ --si-accent: var(--primary-color, #6ee7ff);
259
+ --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
260
+ --si-surface: var(--primary-background-color, rgba(17, 19, 28, 0.86));
261
+ --si-raised: var(--secondary-background-color, rgba(255, 255, 255, 0.035));
262
+ --si-raised-2: var(--tertiary-background-color, rgba(255, 255, 255, 0.06));
263
+ --si-border: var(--medium-color, rgba(255, 255, 255, 0.09));
264
+ --si-text: var(--font-primary-color, #e8eaf2);
265
+ --si-dim: var(--font-secondary-color, #888fa6);
266
+ --si-danger: var(--danger-color, #ff6b6b);
267
+ --si-success: var(--success-color, #46d39a);
268
+ --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
269
+ }
270
+
271
+ slice-translator-debugger #translator-debugger {
272
+ position: fixed;
273
+ bottom: 20px;
274
+ right: 20px;
275
+ width: min(430px, calc(100vw - 40px));
276
+ max-height: 64vh;
277
+ background: var(--si-surface);
278
+ border: 1px solid var(--si-border);
279
+ border-radius: 14px;
280
+ box-shadow:
281
+ 0 24px 60px -12px rgba(0, 0, 0, 0.55),
282
+ 0 0 0 1px rgba(0, 0, 0, 0.2),
283
+ 0 0 38px -18px rgba(var(--si-accent-rgb), 0.55);
284
+ -webkit-backdrop-filter: blur(22px) saturate(1.3);
285
+ backdrop-filter: blur(22px) saturate(1.3);
286
+ display: none;
287
+ flex-direction: column;
288
+ z-index: 10002;
289
+ overflow: hidden;
290
+ color: var(--si-text);
291
+ font-family: var(--si-mono);
292
+ }
293
+
294
+ slice-translator-debugger #translator-debugger.active {
295
+ display: flex;
296
+ animation: si-translator-in 0.26s cubic-bezier(0.16, 1, 0.3, 1);
297
+ }
298
+
299
+ @keyframes si-translator-in {
300
+ from { opacity: 0; transform: translateY(10px) scale(0.985); }
301
+ to { opacity: 1; transform: translateY(0) scale(1); }
302
+ }
303
+
304
+ slice-translator-debugger #translator-debugger * { box-sizing: border-box; }
305
+
306
+ slice-translator-debugger #translator-debugger::before {
307
+ content: '';
308
+ position: absolute;
309
+ left: 0; top: 0; bottom: 0;
310
+ width: 2px;
311
+ background: linear-gradient(180deg, var(--si-accent), transparent 70%);
312
+ opacity: 0.85;
313
+ pointer-events: none;
314
+ }
315
+
316
+ slice-translator-debugger .translator-header {
317
+ display: flex;
318
+ justify-content: space-between;
319
+ align-items: center;
320
+ padding: 12px 14px;
321
+ background:
322
+ radial-gradient(120% 140% at 0% 0%, rgba(var(--si-accent-rgb), 0.10), transparent 60%),
323
+ var(--si-raised);
324
+ border-bottom: 1px solid var(--si-border);
325
+ user-select: none;
326
+ }
327
+
328
+ slice-translator-debugger .brand { display: flex; align-items: center; gap: 9px; }
329
+
330
+ slice-translator-debugger .status-dot {
331
+ width: 7px; height: 7px;
332
+ border-radius: 50%;
333
+ background: var(--si-accent);
334
+ animation: si-pulse-tr 2.4s ease-out infinite;
335
+ }
336
+
337
+ @keyframes si-pulse-tr {
338
+ 0% { box-shadow: 0 0 0 0 rgba(var(--si-accent-rgb), 0.55); }
339
+ 70% { box-shadow: 0 0 0 7px rgba(var(--si-accent-rgb), 0); }
340
+ 100% { box-shadow: 0 0 0 0 rgba(var(--si-accent-rgb), 0); }
341
+ }
342
+
343
+ slice-translator-debugger .glyph { color: var(--si-accent); font-size: 12px; opacity: 0.9; }
344
+
345
+ slice-translator-debugger .title {
346
+ font-weight: 600;
347
+ font-size: 11px;
348
+ letter-spacing: 0.18em;
349
+ color: var(--si-text);
350
+ }
351
+
352
+ slice-translator-debugger .actions { display: flex; gap: 6px; }
353
+
354
+ slice-translator-debugger .btn {
355
+ width: 26px; height: 26px;
356
+ display: flex; align-items: center; justify-content: center;
357
+ border-radius: 7px;
358
+ border: 1px solid var(--si-border);
359
+ background: var(--si-raised);
360
+ color: var(--si-dim);
361
+ cursor: pointer;
362
+ font-size: 13px;
363
+ line-height: 1;
364
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
365
+ }
366
+ slice-translator-debugger .btn:hover {
367
+ color: var(--si-text);
368
+ background: var(--si-raised-2);
369
+ border-color: rgba(var(--si-accent-rgb), 0.5);
370
+ }
371
+ slice-translator-debugger .btn:active { transform: scale(0.92); }
372
+
373
+ slice-translator-debugger .translator-toolbar {
374
+ display: flex;
375
+ gap: 9px;
376
+ align-items: center;
377
+ padding: 10px 12px;
378
+ border-bottom: 1px solid var(--si-border);
379
+ }
380
+
381
+ slice-translator-debugger .pill {
382
+ padding: 3px 9px;
383
+ border-radius: 999px;
384
+ background: rgba(var(--si-accent-rgb), 0.12);
385
+ border: 1px solid rgba(var(--si-accent-rgb), 0.28);
386
+ color: var(--si-accent);
387
+ font-size: 11px;
388
+ font-weight: 600;
389
+ text-transform: uppercase;
390
+ letter-spacing: 0.08em;
391
+ }
392
+
393
+ slice-translator-debugger .locales { display: flex; gap: 4px; }
394
+
395
+ slice-translator-debugger .locale {
396
+ padding: 3px 8px;
397
+ border-radius: 7px;
398
+ border: 1px solid var(--si-border);
399
+ background: var(--si-raised);
400
+ color: var(--si-dim);
401
+ font: inherit;
402
+ font-size: 11px;
403
+ cursor: pointer;
404
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
405
+ }
406
+ slice-translator-debugger .locale:hover { color: var(--si-text); background: var(--si-raised-2); }
407
+ slice-translator-debugger .locale.active {
408
+ color: var(--si-accent);
409
+ border-color: rgba(var(--si-accent-rgb), 0.5);
410
+ background: rgba(var(--si-accent-rgb), 0.12);
411
+ }
412
+
413
+ slice-translator-debugger .keys-toggle {
414
+ display: inline-flex;
415
+ align-items: center;
416
+ gap: 5px;
417
+ margin-left: auto;
418
+ color: var(--si-dim);
419
+ font-size: 11px;
420
+ cursor: pointer;
421
+ user-select: none;
422
+ }
423
+ slice-translator-debugger .keys-toggle input { accent-color: var(--si-accent); }
424
+
425
+ slice-translator-debugger .count { font-size: 11px; color: var(--si-dim); white-space: nowrap; }
426
+ slice-translator-debugger .count span { color: var(--si-danger); font-weight: 600; }
427
+
428
+ slice-translator-debugger .translator-meta {
429
+ padding: 8px 13px;
430
+ border-bottom: 1px solid var(--si-border);
431
+ color: var(--si-dim);
432
+ font-size: 10.5px;
433
+ letter-spacing: 0.02em;
434
+ }
435
+
436
+ slice-translator-debugger .translator-list {
437
+ padding: 10px 12px 12px;
438
+ overflow: auto;
439
+ display: flex;
440
+ flex-direction: column;
441
+ gap: 7px;
442
+ }
443
+ slice-translator-debugger .translator-list::-webkit-scrollbar { width: 8px; }
444
+ slice-translator-debugger .translator-list::-webkit-scrollbar-thumb {
445
+ background: var(--si-raised-2);
446
+ border-radius: 8px;
447
+ border: 2px solid transparent;
448
+ background-clip: padding-box;
449
+ }
450
+
451
+ slice-translator-debugger .key-row {
452
+ display: flex;
453
+ align-items: center;
454
+ gap: 8px;
455
+ border: 1px solid var(--si-border);
456
+ border-left: 2px solid transparent;
457
+ border-radius: 9px;
458
+ background: var(--si-raised);
459
+ padding: 8px 10px;
460
+ font-size: 11px;
461
+ }
462
+ slice-translator-debugger .key-row.missing { border-left-color: var(--si-danger); }
463
+ slice-translator-debugger .key-row.fallback { border-left-color: var(--si-success); }
464
+
465
+ slice-translator-debugger .key {
466
+ color: var(--si-text);
467
+ font-family: var(--si-mono);
468
+ overflow: hidden;
469
+ text-overflow: ellipsis;
470
+ white-space: nowrap;
471
+ }
472
+
473
+ slice-translator-debugger .where { color: var(--si-dim); font-size: 10px; }
474
+
475
+ slice-translator-debugger .tag {
476
+ margin-left: auto;
477
+ padding: 1px 7px;
478
+ border-radius: 999px;
479
+ font-size: 9.5px;
480
+ letter-spacing: 0.05em;
481
+ text-transform: uppercase;
482
+ white-space: nowrap;
483
+ }
484
+ slice-translator-debugger .key-row.missing .tag {
485
+ color: var(--si-danger);
486
+ background: rgba(255, 107, 107, 0.12);
487
+ border: 1px solid rgba(255, 107, 107, 0.28);
488
+ }
489
+ slice-translator-debugger .key-row.fallback .tag {
490
+ color: var(--si-success);
491
+ background: rgba(70, 211, 154, 0.12);
492
+ border: 1px solid rgba(70, 211, 154, 0.28);
493
+ }
494
+
495
+ slice-translator-debugger .empty {
496
+ color: var(--si-dim);
497
+ font-size: 11px;
498
+ letter-spacing: 0.04em;
499
+ text-align: center;
500
+ padding: 22px 0;
501
+ }
502
+
503
+ @media (prefers-reduced-motion: reduce) {
504
+ slice-translator-debugger #translator-debugger.active { animation: none; }
505
+ slice-translator-debugger .status-dot { animation: none; }
506
+ }
507
+ `;
508
+ }
509
+ }
510
+
511
+ customElements.define('slice-translator-debugger', TranslatorDebugger);
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Content documents: JSON records with a `copy: { en: …, es: … }` branch per node.
3
+ *
4
+ * A content document keeps the structure (ids, URLs, dates, images, technologies) once and
5
+ * the prose once per locale. `resolveContent()` replaces every `copy` with the branch of the
6
+ * active locale and flattens nested nodes, so a component receives plain records — the same
7
+ * shape it had before the content was translated.
8
+ *
9
+ * `validateContentDocument()` is the static counterpart: the CLI runs it over every file in
10
+ * `<translator.path>/content/` and fails when a locale is missing or the branches disagree.
11
+ *
12
+ * Pure on purpose (no DOM, no `slice` global, no imports): the browser runtime, the CLI and
13
+ * the server all load this module.
14
+ */
15
+
16
+ const MAX_DEPTH = 24;
17
+
18
+ /** @param {unknown} value @returns {value is Record<string, any>} */
19
+ function isPlainObject(value) {
20
+ if (!value || typeof value !== 'object') return false;
21
+ const proto = Object.getPrototypeOf(value);
22
+ return proto === Object.prototype || proto === null;
23
+ }
24
+
25
+ /** @param {unknown} value @returns {string} */
26
+ function typeOf(value) {
27
+ if (Array.isArray(value)) return 'array';
28
+ if (value === null) return 'null';
29
+ return typeof value;
30
+ }
31
+
32
+ /**
33
+ * Resolve a content document (or a fragment of it) for one locale.
34
+ *
35
+ * - arrays and plain objects are walked;
36
+ * - a node's `copy` is resolved with `localize()` and merged over the node's structural
37
+ * fields (text wins on a name clash);
38
+ * - a node that only carries `copy` collapses to the localized value itself (string/array);
39
+ * - a `copy` without a branch for the active locale is reported through `onMissing` and the
40
+ * node keeps its structural fields.
41
+ *
42
+ * @param {any} value
43
+ * @param {(value: any) => any} localize
44
+ * @param {{ onMissing?: (path: string) => void, onInvalid?: (path: string) => void }} [options]
45
+ * @param {string} [path]
46
+ * @param {number} [depth]
47
+ * @returns {any}
48
+ */
49
+ export function resolveContent(value, localize, options = {}, path = '', depth = 0) {
50
+ if (value === null || value === undefined || depth > MAX_DEPTH) return value;
51
+ if (Array.isArray(value)) {
52
+ return value.map((item, index) => resolveContent(item, localize, options, `${path}[${index}]`, depth + 1));
53
+ }
54
+ if (!isPlainObject(value)) return value;
55
+
56
+ const { copy, ...rest } = value;
57
+ const out = {};
58
+ for (const [key, item] of Object.entries(rest)) {
59
+ out[key] = resolveContent(item, localize, options, path ? `${path}.${key}` : key, depth + 1);
60
+ }
61
+ if (copy === undefined) return out;
62
+
63
+ const localized = localize(copy);
64
+ if (localized === undefined) {
65
+ options.onMissing?.(path ? `${path}.copy` : 'copy');
66
+ return out;
67
+ }
68
+
69
+ const copyPath = path ? `${path}.copy` : 'copy';
70
+ const resolved = resolveContent(localized, localize, options, copyPath, depth + 1);
71
+ if (isPlainObject(resolved)) return { ...out, ...resolved };
72
+ if (Object.keys(out).length === 0) return resolved;
73
+
74
+ // A node with structural fields and a scalar/array copy is ambiguous: there is no field
75
+ // name to put the text under. Report it instead of guessing.
76
+ options.onInvalid?.(copyPath);
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Compare two branches of a `copy` block.
82
+ * @param {any} reference
83
+ * @param {any} value
84
+ * @param {string} path
85
+ * @param {{ path: string, message: string }[]} errors
86
+ * @returns {void}
87
+ */
88
+ function compareShape(reference, value, path, errors) {
89
+ const expected = typeOf(reference);
90
+ const actual = typeOf(value);
91
+ if (expected !== actual) {
92
+ errors.push({ path, message: `expected ${expected} (as in the first locale), found ${actual}` });
93
+ return;
94
+ }
95
+ if (expected === 'array') {
96
+ if (reference.length !== value.length) {
97
+ errors.push({ path, message: `expected ${reference.length} item(s), found ${value.length}` });
98
+ }
99
+ for (let index = 0; index < Math.min(reference.length, value.length); index += 1) {
100
+ compareShape(reference[index], value[index], `${path}[${index}]`, errors);
101
+ }
102
+ return;
103
+ }
104
+ if (expected === 'object') {
105
+ for (const key of Object.keys(reference)) {
106
+ if (!(key in value)) errors.push({ path: `${path}.${key}`, message: 'missing in this locale' });
107
+ }
108
+ for (const key of Object.keys(value)) {
109
+ if (!(key in reference)) errors.push({ path: `${path}.${key}`, message: 'not present in the first locale' });
110
+ }
111
+ for (const key of Object.keys(reference)) {
112
+ if (key in value) compareShape(reference[key], value[key], `${path}.${key}`, errors);
113
+ }
114
+ }
115
+ }
116
+
117
+ /** @param {any} value @param {string} path @param {{ path: string, message: string }[]} errors */
118
+ function validateText(value, path, errors) {
119
+ if (typeof value === 'string') {
120
+ if (!value.trim()) errors.push({ path, message: 'empty text' });
121
+ return;
122
+ }
123
+ if (Array.isArray(value)) {
124
+ value.forEach((item, index) => validateText(item, `${path}[${index}]`, errors));
125
+ return;
126
+ }
127
+ if (isPlainObject(value)) {
128
+ for (const [key, item] of Object.entries(value)) validateText(item, `${path}.${key}`, errors);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Validate a content document: every `copy` has a branch for every configured locale, the
134
+ * branches have the same shape, and no translated string is empty.
135
+ * @param {any} document
136
+ * @param {readonly string[]} locales
137
+ * @returns {{ path: string, message: string }[]}
138
+ */
139
+ export function validateContentDocument(document, locales) {
140
+ const errors = [];
141
+ const configured = [...new Set((locales || []).filter((locale) => typeof locale === 'string' && locale))];
142
+ if (configured.length === 0) {
143
+ return [{ path: '', message: 'no locales configured to validate the content document' }];
144
+ }
145
+
146
+ const validateCopy = (copy, path) => {
147
+ if (!isPlainObject(copy)) {
148
+ errors.push({ path, message: 'copy must be an object with one branch per locale' });
149
+ return;
150
+ }
151
+ const keys = Object.keys(copy);
152
+ if (keys.length === 0) {
153
+ errors.push({ path, message: 'copy has no locale branches' });
154
+ return;
155
+ }
156
+ for (const locale of configured) {
157
+ if (!keys.some((key) => key.toLowerCase() === locale.toLowerCase())) {
158
+ errors.push({ path, message: `copy is missing "${locale}"` });
159
+ }
160
+ }
161
+ for (const key of keys) {
162
+ if (!configured.some((locale) => locale.toLowerCase() === key.toLowerCase())) {
163
+ errors.push({ path, message: `copy has "${key}", which is not a configured locale` });
164
+ }
165
+ }
166
+ const reference = copy[keys[0]];
167
+ for (const key of keys.slice(1)) compareShape(reference, copy[key], `${path}.${key}`, errors);
168
+ for (const key of keys) validateText(copy[key], `${path}.${key}`, errors);
169
+ };
170
+
171
+ const walk = (node, path, depth) => {
172
+ if (node === null || typeof node !== 'object' || depth > MAX_DEPTH) return;
173
+ if (Array.isArray(node)) {
174
+ node.forEach((item, index) => walk(item, `${path}[${index}]`, depth + 1));
175
+ return;
176
+ }
177
+ if (Object.prototype.hasOwnProperty.call(node, 'copy')) {
178
+ validateCopy(node.copy, path ? `${path}.copy` : 'copy');
179
+ }
180
+ for (const [key, value] of Object.entries(node)) {
181
+ if (key === 'copy') continue;
182
+ walk(value, path ? `${path}.${key}` : key, depth + 1);
183
+ }
184
+ };
185
+
186
+ walk(document, '', 0);
187
+ return errors;
188
+ }