@sequoialabs/payload-plugin-reversia 0.1.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/collections/sync-pending.d.ts +2 -0
  4. package/dist/collections/sync-pending.js +27 -0
  5. package/dist/endpoints/confirm-resources-sync.d.ts +3 -0
  6. package/dist/endpoints/confirm-resources-sync.js +31 -0
  7. package/dist/endpoints/resource.d.ts +3 -0
  8. package/dist/endpoints/resource.js +82 -0
  9. package/dist/endpoints/resources-definition.d.ts +3 -0
  10. package/dist/endpoints/resources-definition.js +54 -0
  11. package/dist/endpoints/resources-insert.d.ts +3 -0
  12. package/dist/endpoints/resources-insert.js +174 -0
  13. package/dist/endpoints/resources-sync.d.ts +3 -0
  14. package/dist/endpoints/resources-sync.js +59 -0
  15. package/dist/endpoints/resources.d.ts +3 -0
  16. package/dist/endpoints/resources.js +107 -0
  17. package/dist/endpoints/settings.d.ts +3 -0
  18. package/dist/endpoints/settings.js +34 -0
  19. package/dist/hooks/after-change.d.ts +11 -0
  20. package/dist/hooks/after-change.js +40 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.js +82 -0
  23. package/dist/types.d.ts +238 -0
  24. package/dist/types.js +12 -0
  25. package/dist/utils/auth.d.ts +3 -0
  26. package/dist/utils/auth.js +27 -0
  27. package/dist/utils/cursor.d.ts +7 -0
  28. package/dist/utils/cursor.js +30 -0
  29. package/dist/utils/fields.d.ts +58 -0
  30. package/dist/utils/fields.js +715 -0
  31. package/dist/utils/json-extract.d.ts +37 -0
  32. package/dist/utils/json-extract.js +186 -0
  33. package/dist/utils/labels.d.ts +12 -0
  34. package/dist/utils/labels.js +27 -0
  35. package/dist/utils/path-resolver.d.ts +53 -0
  36. package/dist/utils/path-resolver.js +157 -0
  37. package/dist/utils/payload-helpers.d.ts +16 -0
  38. package/dist/utils/payload-helpers.js +45 -0
  39. package/package.json +57 -0
@@ -0,0 +1,715 @@
1
+ import { ReversiaFieldType } from '../types.js';
2
+ import { applyByKeys, compileKeyMatcher, DEFAULT_RICHTEXT_KEYS, extractByKeys, } from './json-extract.js';
3
+ import { resolveStaticLabel } from './labels.js';
4
+ import { applyTranslationsToContainer, joinPointers, resolveLeafLocations, } from './path-resolver.js';
5
+ /* -------------------------------------------------------------------------- */
6
+ /* Schema introspection */
7
+ /* -------------------------------------------------------------------------- */
8
+ function getFieldLabel(field) {
9
+ if (!('name' in field)) {
10
+ return '';
11
+ }
12
+ const name = String(field.name ?? '');
13
+ if ('label' in field && field.label) {
14
+ return resolveStaticLabel(field.label, name);
15
+ }
16
+ return '';
17
+ }
18
+ function getReversiaCustom(field) {
19
+ if ('custom' in field && field.custom && typeof field.custom === 'object') {
20
+ const custom = field.custom;
21
+ if (custom.reversia && typeof custom.reversia === 'object') {
22
+ return custom.reversia;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ function isLocalized(field) {
28
+ return 'localized' in field && field.localized === true;
29
+ }
30
+ function isNamed(field) {
31
+ return 'name' in field && typeof field.name === 'string' && field.name.length > 0;
32
+ }
33
+ function isTabsField(field) {
34
+ return field.type === 'tabs' && 'tabs' in field;
35
+ }
36
+ function isBlocksField(field) {
37
+ return field.type === 'blocks' && 'blocks' in field;
38
+ }
39
+ function isArrayField(field) {
40
+ return field.type === 'array' && 'fields' in field;
41
+ }
42
+ function isGroupLikeField(field) {
43
+ return ((field.type === 'group' || field.type === 'collapsible' || field.type === 'row') &&
44
+ 'fields' in field &&
45
+ Array.isArray(field.fields));
46
+ }
47
+ const SCALAR_PAYLOAD_TYPES = new Set([
48
+ 'text',
49
+ 'textarea',
50
+ 'email',
51
+ 'code',
52
+ 'date',
53
+ 'number',
54
+ 'select',
55
+ 'checkbox',
56
+ 'radio',
57
+ 'point',
58
+ ]);
59
+ function isScalarPayloadType(type) {
60
+ return SCALAR_PAYLOAD_TYPES.has(type);
61
+ }
62
+ /* -------------------------------------------------------------------------- */
63
+ /* Discovery: top-level localized fields */
64
+ /* -------------------------------------------------------------------------- */
65
+ /**
66
+ * Collects one `LocalizedFieldInfo` per top-level field that is itself
67
+ * localized OR that contains at least one localized descendant.
68
+ *
69
+ * Top-level localized scalars become non-container entries. Everything else
70
+ * (richText, json, group, array, blocks, or any unnamed wrapper containing
71
+ * localized descendants) becomes a container with one or more `leaves`.
72
+ */
73
+ export function findLocalizedFields(fields) {
74
+ const out = [];
75
+ for (const field of flattenTransparentWrappers(fields)) {
76
+ if (!isNamed(field)) {
77
+ continue;
78
+ }
79
+ const info = describeTopLevelField(field);
80
+ if (info) {
81
+ out.push(info);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * `tabs` (when unnamed), `row`, `collapsible` and similar wrappers don't
88
+ * introduce a key in the document. Flatten them so their inner fields are
89
+ * treated as top-level for discovery.
90
+ */
91
+ function flattenTransparentWrappers(fields) {
92
+ const out = [];
93
+ for (const field of fields) {
94
+ if (isTabsField(field)) {
95
+ for (const tab of field.tabs) {
96
+ if ('name' in tab && typeof tab.name === 'string' && tab.name.length > 0) {
97
+ // A named tab IS a top-level container in its own right.
98
+ out.push({
99
+ name: tab.name,
100
+ type: 'group',
101
+ label: tab.label,
102
+ fields: tab.fields,
103
+ });
104
+ }
105
+ else {
106
+ out.push(...flattenTransparentWrappers(tab.fields));
107
+ }
108
+ }
109
+ continue;
110
+ }
111
+ if (!isNamed(field) && (field.type === 'row' || field.type === 'collapsible')) {
112
+ if ('fields' in field && Array.isArray(field.fields)) {
113
+ out.push(...flattenTransparentWrappers(field.fields));
114
+ }
115
+ continue;
116
+ }
117
+ out.push(field);
118
+ }
119
+ return out;
120
+ }
121
+ function describeTopLevelField(field) {
122
+ const name = field.name;
123
+ const label = getFieldLabel(field);
124
+ const reversia = getReversiaCustom(field);
125
+ const payloadFieldType = field.type;
126
+ // Top-level scalar that is itself localized → non-container, value shipped
127
+ // as a primitive.
128
+ if (isLocalized(field) && isScalarPayloadType(payloadFieldType)) {
129
+ return {
130
+ name,
131
+ label,
132
+ payloadFieldType,
133
+ isContainer: false,
134
+ reversia,
135
+ leaves: [
136
+ {
137
+ segments: [],
138
+ kind: 'scalar',
139
+ payloadFieldType,
140
+ reversia,
141
+ },
142
+ ],
143
+ };
144
+ }
145
+ // Top-level richText / json (localized or not localized but containing
146
+ // sub-extractions — currently we only look at the field itself for these)
147
+ // → container with one json leaf.
148
+ if (payloadFieldType === 'richText' || payloadFieldType === 'json') {
149
+ if (!isLocalized(field)) {
150
+ return null;
151
+ }
152
+ return {
153
+ name,
154
+ label,
155
+ payloadFieldType,
156
+ isContainer: true,
157
+ reversia,
158
+ leaves: [
159
+ {
160
+ segments: [],
161
+ kind: 'json',
162
+ payloadFieldType,
163
+ reversia,
164
+ },
165
+ ],
166
+ };
167
+ }
168
+ // Structured wrapper: group / array / blocks. Walk inside to collect
169
+ // localized descendant leaves; emit a container only if any were found.
170
+ const innerLeaves = [];
171
+ if (isGroupLikeField(field)) {
172
+ collectLeaves(field.fields, [], innerLeaves);
173
+ }
174
+ else if (isArrayField(field)) {
175
+ collectLeaves(field.fields, [{ kind: 'iterate' }], innerLeaves);
176
+ }
177
+ else if (isBlocksField(field)) {
178
+ for (const block of field.blocks) {
179
+ collectLeaves(block.fields, [{ kind: 'iterateBlock', blockSlug: block.slug }], innerLeaves);
180
+ }
181
+ }
182
+ else {
183
+ return null;
184
+ }
185
+ if (innerLeaves.length === 0) {
186
+ return null;
187
+ }
188
+ return {
189
+ name,
190
+ label,
191
+ payloadFieldType,
192
+ isContainer: true,
193
+ reversia,
194
+ leaves: innerLeaves,
195
+ };
196
+ }
197
+ function collectLeaves(fields, parentSegments, out) {
198
+ for (const field of fields) {
199
+ if (!isNamed(field)) {
200
+ if (isTabsField(field)) {
201
+ for (const tab of field.tabs) {
202
+ const tabSegments = 'name' in tab && typeof tab.name === 'string' && tab.name.length > 0
203
+ ? [...parentSegments, { kind: 'key', name: tab.name }]
204
+ : parentSegments;
205
+ collectLeaves(tab.fields, tabSegments, out);
206
+ }
207
+ }
208
+ if ((field.type === 'row' || field.type === 'collapsible') &&
209
+ 'fields' in field &&
210
+ Array.isArray(field.fields)) {
211
+ collectLeaves(field.fields, parentSegments, out);
212
+ }
213
+ continue;
214
+ }
215
+ const segments = [...parentSegments, { kind: 'key', name: field.name }];
216
+ const reversia = getReversiaCustom(field);
217
+ const localized = isLocalized(field);
218
+ if (localized && isScalarPayloadType(field.type)) {
219
+ out.push({
220
+ segments,
221
+ kind: 'scalar',
222
+ payloadFieldType: field.type,
223
+ reversia,
224
+ });
225
+ continue;
226
+ }
227
+ if (localized && (field.type === 'richText' || field.type === 'json')) {
228
+ out.push({
229
+ segments,
230
+ kind: 'json',
231
+ payloadFieldType: field.type,
232
+ reversia,
233
+ });
234
+ continue;
235
+ }
236
+ if (isGroupLikeField(field)) {
237
+ collectLeaves(field.fields, segments, out);
238
+ continue;
239
+ }
240
+ if (isArrayField(field)) {
241
+ collectLeaves(field.fields, [...segments, { kind: 'iterate' }], out);
242
+ continue;
243
+ }
244
+ if (isBlocksField(field)) {
245
+ for (const block of field.blocks) {
246
+ collectLeaves(block.fields, [...segments, { kind: 'iterateBlock', blockSlug: block.slug }], out);
247
+ }
248
+ }
249
+ }
250
+ }
251
+ /* -------------------------------------------------------------------------- */
252
+ /* Configuration (resources-definition) */
253
+ /* -------------------------------------------------------------------------- */
254
+ export function resolveContentType(field) {
255
+ if (field.reversia?.type) {
256
+ return field.reversia.type;
257
+ }
258
+ if (field.isContainer) {
259
+ return ReversiaFieldType.JSON;
260
+ }
261
+ return undefined;
262
+ }
263
+ function resolveBehavior(field) {
264
+ return field.reversia?.behavior;
265
+ }
266
+ function resolveAsLabel(field, hasLabelAlready) {
267
+ if (field.reversia?.asLabel !== undefined) {
268
+ return field.reversia.asLabel;
269
+ }
270
+ if (!hasLabelAlready && !field.isContainer && (field.name === 'title' || field.name === 'name')) {
271
+ return true;
272
+ }
273
+ return false;
274
+ }
275
+ function humanize(segment) {
276
+ const spaced = segment
277
+ .replace(/[_-]+/g, ' ')
278
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
279
+ .replace(/\s+/g, ' ')
280
+ .trim();
281
+ if (spaced.length === 0) {
282
+ return segment;
283
+ }
284
+ return spaced
285
+ .split(' ')
286
+ .map((word) => (word.length > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
287
+ .join(' ');
288
+ }
289
+ export function buildTranslatableConfiguration(localizedFields) {
290
+ const config = {};
291
+ let hasLabelField = false;
292
+ for (const field of localizedFields) {
293
+ const fieldConfig = {
294
+ label: field.label || humanize(field.name),
295
+ };
296
+ const isLabel = resolveAsLabel(field, hasLabelField);
297
+ if (isLabel) {
298
+ fieldConfig.asLabel = true;
299
+ hasLabelField = true;
300
+ }
301
+ const contentType = resolveContentType(field);
302
+ if (contentType) {
303
+ fieldConfig.type = contentType;
304
+ }
305
+ const behavior = resolveBehavior(field);
306
+ if (behavior) {
307
+ fieldConfig.behavior = behavior;
308
+ }
309
+ if (field.reversia?.selected === false) {
310
+ fieldConfig.selected = false;
311
+ }
312
+ config[field.name] = fieldConfig;
313
+ }
314
+ return config;
315
+ }
316
+ // Back-compat re-export for callers that only need the content-type resolver.
317
+ export const getContentType = resolveContentType;
318
+ /**
319
+ * Produces zero or one serialised entry for a top-level localized field.
320
+ *
321
+ * - Scalars: pass-through primitive value (or `extract` result).
322
+ * - Containers: walk every localized leaf, build a JSON-pointer map of only
323
+ * the translatable atoms (so we never ship non-localized siblings or fields
324
+ * the user opted out of), JSON.stringify and emit one entry.
325
+ *
326
+ * Returns `undefined` when there is nothing translatable to send (empty
327
+ * source value, no matching leaves, or filtered out).
328
+ */
329
+ export function serializeField(field, doc) {
330
+ const value = doc?.[field.name];
331
+ if (value === undefined || value === null) {
332
+ return undefined;
333
+ }
334
+ const contentType = resolveContentType(field);
335
+ // Top-level extract escape hatch wins.
336
+ if (field.reversia?.extract) {
337
+ if (!field.reversia.apply) {
338
+ throw new Error(`reversia.extract on field "${field.name}" requires a matching reversia.apply.`);
339
+ }
340
+ const extracted = field.reversia.extract(value);
341
+ if (extracted === undefined || extracted === null || extracted.length === 0) {
342
+ return undefined;
343
+ }
344
+ return { name: field.name, value: extracted, contentType };
345
+ }
346
+ if (!field.isContainer) {
347
+ if (typeof value === 'object') {
348
+ // Defensive: top-level scalar shouldn't have an object value but if it
349
+ // does we ship a JSON.stringify rather than an opaque [object Object].
350
+ return { name: field.name, value: JSON.stringify(value), contentType };
351
+ }
352
+ return { name: field.name, value: value, contentType };
353
+ }
354
+ const map = extractContainerAtoms(field, value);
355
+ if (Object.keys(map).length === 0) {
356
+ return undefined;
357
+ }
358
+ return { name: field.name, value: JSON.stringify(map), contentType };
359
+ }
360
+ /**
361
+ * Walks every localized leaf inside a container value and aggregates atomic
362
+ * translatable strings into a single `{ pointer: value }` map. Non-localized
363
+ * sibling fields and structural keys are pre-filtered — Reversia never sees
364
+ * anything we don't ask it to translate.
365
+ */
366
+ function extractContainerAtoms(field, containerValue) {
367
+ const map = {};
368
+ for (const leaf of field.leaves) {
369
+ const locations = resolveLeafLocations(containerValue, leaf.segments);
370
+ for (const { pointer, value } of locations) {
371
+ if (value === undefined || value === null) {
372
+ continue;
373
+ }
374
+ if (leaf.kind === 'scalar') {
375
+ if (typeof value === 'string') {
376
+ if (value.length > 0) {
377
+ map[pointer === '' ? '' : pointer] = value;
378
+ }
379
+ continue;
380
+ }
381
+ if (typeof value === 'number' || typeof value === 'boolean') {
382
+ map[pointer === '' ? '' : pointer] = String(value);
383
+ }
384
+ continue;
385
+ }
386
+ // leaf.kind === 'json' — extract sub-leaves by translatableKeys
387
+ const subKeys = resolveTranslatableKeys(leaf);
388
+ if (typeof value !== 'object') {
389
+ // Unexpected primitive in a richText/json leaf — ship as-is.
390
+ if (typeof value === 'string' && value.length > 0) {
391
+ map[pointer === '' ? '' : pointer] = value;
392
+ }
393
+ continue;
394
+ }
395
+ if (!subKeys) {
396
+ // json leaf with no keys configured: ship the whole serialised value
397
+ // at the leaf's pointer so it round-trips intact.
398
+ map[pointer === '' ? '' : pointer] = JSON.stringify(value);
399
+ continue;
400
+ }
401
+ const matcher = compileKeyMatcher(subKeys);
402
+ const sub = extractByKeys(value, matcher);
403
+ for (const [subPointer, subValue] of Object.entries(sub)) {
404
+ map[joinPointers(pointer, subPointer)] = subValue;
405
+ }
406
+ }
407
+ }
408
+ return map;
409
+ }
410
+ function resolveTranslatableKeys(leaf) {
411
+ const explicit = leaf.reversia?.translatableKeys;
412
+ if (explicit && explicit.length > 0) {
413
+ return explicit;
414
+ }
415
+ if (leaf.payloadFieldType === 'richText') {
416
+ return [...DEFAULT_RICHTEXT_KEYS];
417
+ }
418
+ return null;
419
+ }
420
+ /* -------------------------------------------------------------------------- */
421
+ /* Deserialization */
422
+ /* -------------------------------------------------------------------------- */
423
+ /**
424
+ * Reverses `serializeField` for one top-level field.
425
+ *
426
+ * - Scalars: return `translatedRaw` as-is (after `apply` if defined).
427
+ * - Containers: parse the JSON pointer map; deep-clone the source-locale
428
+ * container value as the base; overlay each translated leaf at its pointer.
429
+ * For `json` sub-leaves the pointer is split between the leaf location and
430
+ * the sub-extraction pointer — `applyByKeys` replays the standard richText
431
+ * path on the leaf's value before we re-attach it.
432
+ *
433
+ * The source-clone strategy guarantees required non-localized siblings (block
434
+ * structure, array item ids, sub-object scaffolding) are preserved when we
435
+ * write to Payload — same pattern as the PrestaShop module.
436
+ */
437
+ export function deserializeFieldValue(field, sourceValue, translatedRaw) {
438
+ if (field.reversia?.apply) {
439
+ const asString = typeof translatedRaw === 'string' ? translatedRaw : JSON.stringify(translatedRaw);
440
+ return field.reversia.apply(sourceValue, asString);
441
+ }
442
+ if (!field.isContainer) {
443
+ return translatedRaw;
444
+ }
445
+ const translations = coerceTranslationMap(translatedRaw);
446
+ if (!translations) {
447
+ // Nothing usable from Reversia — fall back to source clone.
448
+ return sourceValue === undefined || sourceValue === null
449
+ ? sourceValue
450
+ : structuredClone(sourceValue);
451
+ }
452
+ // Group translations by their target leaf so we can hand richText/json
453
+ // sub-pointers to applyByKeys, which already knows how to splice them into
454
+ // a Lexical/JSON tree.
455
+ const buckets = bucketTranslationsByLeaf(field, translations);
456
+ let working = sourceValue === undefined || sourceValue === null ? null : structuredClone(sourceValue);
457
+ for (const bucket of buckets) {
458
+ if (bucket.leaf.kind === 'scalar') {
459
+ // One pointer, one value — write each instance at its container pointer.
460
+ for (const [pointer, value] of Object.entries(bucket.entries)) {
461
+ working = writeRaw(working, pointer, value);
462
+ }
463
+ continue;
464
+ }
465
+ // json leaf: locate every leaf instance, apply sub-pointer translations
466
+ // onto it, write the rebuilt subtree back.
467
+ const locations = resolveLeafLocations(working, bucket.leaf.segments);
468
+ for (const { pointer: leafPointer } of locations) {
469
+ const subTranslations = {};
470
+ for (const [pointer, value] of Object.entries(bucket.entries)) {
471
+ if (pointerStartsWith(pointer, leafPointer)) {
472
+ const sub = pointer.slice(leafPointer.length);
473
+ subTranslations[sub === '' ? '' : sub] = value;
474
+ }
475
+ }
476
+ if (Object.keys(subTranslations).length === 0) {
477
+ continue;
478
+ }
479
+ const leafSource = readAtPointer(working, leafPointer);
480
+ let rebuilt;
481
+ if (subTranslations[''] !== undefined && Object.keys(subTranslations).length === 1) {
482
+ // Whole-value json (no translatableKeys): try to JSON.parse round-trip.
483
+ const raw = subTranslations[''];
484
+ try {
485
+ rebuilt = JSON.parse(raw);
486
+ }
487
+ catch {
488
+ rebuilt = raw;
489
+ }
490
+ }
491
+ else if (leafSource && typeof leafSource === 'object') {
492
+ rebuilt = applyByKeys(leafSource, subTranslations);
493
+ }
494
+ else {
495
+ // No source structure — best-effort tree from sub-pointers.
496
+ rebuilt = applyTranslationsToContainer(leafSource, subTranslations);
497
+ }
498
+ working = writeRaw(working, leafPointer, rebuilt);
499
+ }
500
+ }
501
+ return working;
502
+ }
503
+ function bucketTranslationsByLeaf(field, translations) {
504
+ // Order leaves by descending segment length so deeper / more specific leaves
505
+ // claim pointers before broader catch-alls. (Stable for the common
506
+ // shallow-only schema.)
507
+ const ordered = [...field.leaves].sort((a, b) => b.segments.length - a.segments.length);
508
+ const remaining = new Map(Object.entries(translations));
509
+ const buckets = [];
510
+ for (const leaf of ordered) {
511
+ const entries = {};
512
+ const leafPath = leafSegmentsAsPointerPrefix(leaf);
513
+ for (const [pointer, value] of remaining) {
514
+ if (leaf.kind === 'scalar') {
515
+ if (matchesScalarLeaf(pointer, leaf)) {
516
+ entries[pointer] = value;
517
+ remaining.delete(pointer);
518
+ }
519
+ continue;
520
+ }
521
+ if (leafPath === null) {
522
+ continue;
523
+ }
524
+ if (leafPath === '' || pointerStartsWith(pointer, leafPath)) {
525
+ entries[pointer] = value;
526
+ remaining.delete(pointer);
527
+ }
528
+ }
529
+ if (Object.keys(entries).length > 0) {
530
+ buckets.push({ leaf, entries });
531
+ }
532
+ }
533
+ return buckets;
534
+ }
535
+ /**
536
+ * For a scalar leaf, the incoming pointer must address exactly the leaf's
537
+ * structural shape: every `key` segment becomes a literal pointer segment;
538
+ * every `iterate` / `iterateBlock` segment matches any one numeric segment.
539
+ */
540
+ function matchesScalarLeaf(pointer, leaf) {
541
+ const parts = pointer === '' ? [] : pointer.slice(1).split('/');
542
+ let p = 0;
543
+ for (const seg of leaf.segments) {
544
+ if (p >= parts.length) {
545
+ return false;
546
+ }
547
+ if (seg.kind === 'key') {
548
+ if (parts[p] !== seg.name) {
549
+ return false;
550
+ }
551
+ p++;
552
+ continue;
553
+ }
554
+ if (!/^\d+$/.test(parts[p])) {
555
+ return false;
556
+ }
557
+ p++;
558
+ }
559
+ return p === parts.length;
560
+ }
561
+ /**
562
+ * Returns the pointer prefix for a json leaf when its location is fully
563
+ * key-only (so we can do a fast `startsWith` match). For leaves under
564
+ * iterate / iterateBlock segments, returns `null` — we fall through to
565
+ * per-instance resolution.
566
+ */
567
+ function leafSegmentsAsPointerPrefix(leaf) {
568
+ const parts = [];
569
+ for (const seg of leaf.segments) {
570
+ if (seg.kind === 'key') {
571
+ parts.push(seg.name);
572
+ continue;
573
+ }
574
+ return null;
575
+ }
576
+ return parts.length === 0 ? '' : `/${parts.join('/')}`;
577
+ }
578
+ function pointerStartsWith(pointer, prefix) {
579
+ if (prefix === '') {
580
+ return true;
581
+ }
582
+ if (!pointer.startsWith(prefix)) {
583
+ return false;
584
+ }
585
+ // Ensure prefix lands on a segment boundary.
586
+ return pointer.length === prefix.length || pointer[prefix.length] === '/';
587
+ }
588
+ function readAtPointer(target, pointer) {
589
+ if (pointer === '') {
590
+ return target;
591
+ }
592
+ const parts = pointer.slice(1).split('/').map(decodePointerSegment);
593
+ let current = target;
594
+ for (const part of parts) {
595
+ if (current === null || current === undefined) {
596
+ return undefined;
597
+ }
598
+ if (Array.isArray(current)) {
599
+ const idx = Number(part);
600
+ if (!Number.isInteger(idx)) {
601
+ return undefined;
602
+ }
603
+ current = current[idx];
604
+ continue;
605
+ }
606
+ if (typeof current !== 'object') {
607
+ return undefined;
608
+ }
609
+ current = current[part];
610
+ }
611
+ return current;
612
+ }
613
+ /**
614
+ * Internal write — like `writeAtPointer` but accepts arbitrary value types
615
+ * (not just strings) and returns the (possibly replaced) root.
616
+ */
617
+ function writeRaw(target, pointer, value) {
618
+ if (pointer === '') {
619
+ return value;
620
+ }
621
+ const parts = pointer.slice(1).split('/').map(decodePointerSegment);
622
+ let root = target;
623
+ if (root === null || root === undefined) {
624
+ root = /^\d+$/.test(parts[0]) ? [] : {};
625
+ }
626
+ let current = root;
627
+ for (let i = 0; i < parts.length - 1; i++) {
628
+ const key = parts[i];
629
+ const nextKey = parts[i + 1];
630
+ const nextIsIndex = /^\d+$/.test(nextKey);
631
+ if (Array.isArray(current)) {
632
+ const idx = Number(key);
633
+ if (current[idx] === null || current[idx] === undefined) {
634
+ current[idx] = nextIsIndex ? [] : {};
635
+ }
636
+ current = current[idx];
637
+ continue;
638
+ }
639
+ if (current && typeof current === 'object') {
640
+ const obj = current;
641
+ if (obj[key] === null || obj[key] === undefined) {
642
+ obj[key] = nextIsIndex ? [] : {};
643
+ }
644
+ current = obj[key];
645
+ }
646
+ }
647
+ const last = parts[parts.length - 1];
648
+ if (Array.isArray(current)) {
649
+ current[Number(last)] = value;
650
+ }
651
+ else if (current && typeof current === 'object') {
652
+ current[last] = value;
653
+ }
654
+ return root;
655
+ }
656
+ function decodePointerSegment(seg) {
657
+ return seg.replace(/~1/g, '/').replace(/~0/g, '~');
658
+ }
659
+ function coerceTranslationMap(raw) {
660
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
661
+ const out = {};
662
+ for (const [k, v] of Object.entries(raw)) {
663
+ if (typeof v === 'string') {
664
+ out[k] = v;
665
+ }
666
+ }
667
+ return Object.keys(out).length > 0 ? out : null;
668
+ }
669
+ if (typeof raw === 'string') {
670
+ try {
671
+ const parsed = JSON.parse(raw);
672
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
673
+ const out = {};
674
+ for (const [k, v] of Object.entries(parsed)) {
675
+ if (typeof v === 'string') {
676
+ out[k] = v;
677
+ }
678
+ }
679
+ return Object.keys(out).length > 0 ? out : null;
680
+ }
681
+ }
682
+ catch {
683
+ return null;
684
+ }
685
+ }
686
+ return null;
687
+ }
688
+ /* -------------------------------------------------------------------------- */
689
+ /* Source-clone helpers (PrestaShop-style: clone source, overlay translations) */
690
+ /* -------------------------------------------------------------------------- */
691
+ /**
692
+ * Builds the base `updateData` for a translation insertion by deep-cloning
693
+ * each top-level localized field's value out of the source-locale doc. This
694
+ * guarantees required nested siblings (block structure, ids, non-localized
695
+ * subfields) are present in the update payload, even when Reversia only sent
696
+ * a subset of leaves.
697
+ */
698
+ export function cloneLocalizedContainersFromSource(sourceDoc, fields) {
699
+ const out = {};
700
+ if (!sourceDoc || typeof sourceDoc !== 'object') {
701
+ return out;
702
+ }
703
+ const src = sourceDoc;
704
+ for (const field of fields) {
705
+ if (!(field.name in src)) {
706
+ continue;
707
+ }
708
+ const value = src[field.name];
709
+ if (value === undefined || value === null) {
710
+ continue;
711
+ }
712
+ out[field.name] = field.isContainer ? structuredClone(value) : value;
713
+ }
714
+ return out;
715
+ }