@focus-reactive/payload-plugin-translator 0.7.0 → 0.7.2

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/README.md CHANGED
@@ -370,6 +370,9 @@ import type {
370
370
  FieldTranslationConfig,
371
371
  AccessGuard,
372
372
  AccessGuardRequest,
373
+ TranslationTask, // descriptor passed to the lifecycle callbacks — Since v0.7.0
374
+ TranslationLifecycleCallbacks, // shape of the `lifecycle` config — Since v0.7.0
375
+ TranslationProvenanceRecord, // a stored provenance row — Since v0.7.0
373
376
  } from "@focus-reactive/payload-plugin-translator";
374
377
  ```
375
378
 
@@ -379,9 +382,11 @@ Every public API is annotated with `@since x.y.z` in its JSDoc, and features car
379
382
 
380
383
  ## Roadmap
381
384
 
385
+ Planned features building on the v0.7.0 provenance foundation:
386
+
387
+ - **Stale-translation detection** — surface which target locales are out of sync with their source, using the recorded provenance fingerprint.
382
388
  - **Global translation dashboard** — translate across all collections from one place, with project-wide progress.
383
- - **Vercel Cron runner** — a built-in runner for serverless deploys without manual API-route wiring.
384
- - **Auto-translate on source change** — trigger translation automatically when default-locale content changes.
389
+ - **Auto-translate on source change** — retranslate automatically when default-locale content changes.
385
390
 
386
391
  ## License
387
392
 
@@ -11,13 +11,15 @@ export interface BlockLike {
11
11
  }
12
12
  /**
13
13
  * A tab as this layer needs to read it: an optional `name` (present → it opens a data boundary;
14
- * absent → it flattens into the parent scope) and its child `fields`. Payload's `Tab`
15
- * (`NamedTab | UnnamedTab`) is assignable to this.
14
+ * absent → it flattens into the parent scope), its child `fields`, and `localized` (a *named* tab
15
+ * may be `localized`, partitioning its subtree per locale — Payload's sanitizer propagates it the
16
+ * same as a group). Payload's `Tab` (`NamedTab | UnnamedTab`) is assignable to this.
16
17
  *
17
18
  * @public
18
19
  */
19
20
  export interface TabLike {
20
21
  name?: string;
22
+ localized?: boolean;
21
23
  fields: FieldLike[];
22
24
  }
23
25
  /**
@@ -5,9 +5,9 @@ const asObject = (value)=>isObject(value) ? value : {};
5
5
  /**
6
6
  * Reconcile walker: produces the full document shape from source + target, with target
7
7
  * priority (source fills empty target slots). Iteration is driven by `source`; a field with
8
- * no source value is dropped, `id` is stripped from array/block elements (Postgres rejects it
9
- * on update), `blockType` is preserved, and non-object array items / unknown blocks pass
10
- * through unchanged.
8
+ * no source value is dropped, `blockType` is preserved, non-object array items / unknown blocks
9
+ * pass through unchanged, and array/block `id` is kept for shared rows but stripped for per-locale
10
+ * rows (see {@link Cursor.sharedRow} — the data-loss-critical bit).
11
11
  *
12
12
  * Array/block elements are paired with their target counterpart by `id`, not by position (see
13
13
  * {@link matchElementById}); output order always follows `source`.
@@ -15,9 +15,12 @@ const asObject = (value)=>isObject(value) ? value : {};
15
15
  enterObject (field, cursor) {
16
16
  const sourceValue = cursor.source[field.name];
17
17
  if (!isObject(sourceValue)) return "skip"; // undefined / non-object source → drop the field
18
+ // A localized group or named tab partitions its whole subtree per locale → descendants are
19
+ // per-locale rows (Payload's sanitizer propagates `tab.localized` the same as a group).
18
20
  return {
19
21
  source: sourceValue,
20
- target: asObject(cursor.target[field.name])
22
+ target: asObject(cursor.target[field.name]),
23
+ sharedRow: cursor.sharedRow && !field.localized
21
24
  };
22
25
  },
23
26
  enterList (field, cursor) {
@@ -26,6 +29,8 @@ const asObject = (value)=>isObject(value) ? value : {};
26
29
  const targetValue = cursor.target[field.name];
27
30
  const targetArr = Array.isArray(targetValue) ? targetValue : [];
28
31
  const isBlocks = field.type === "blocks";
32
+ // Elements are shared rows only when this container AND every ancestor is non-localized.
33
+ const childShared = cursor.sharedRow && !field.localized;
29
34
  const children = [];
30
35
  sourceValue.forEach((item, index)=>{
31
36
  if (!isObject(item)) return; // non-object element → passthrough (rebuilt in combine)
@@ -35,7 +40,8 @@ const asObject = (value)=>isObject(value) ? value : {};
35
40
  children.push({
36
41
  cursor: {
37
42
  source: item,
38
- target: matchElementById(targetArr, item, isBlocks)
43
+ target: matchElementById(targetArr, item, isBlocks),
44
+ sharedRow: childShared
39
45
  },
40
46
  fields,
41
47
  key: index
@@ -58,13 +64,29 @@ const asObject = (value)=>isObject(value) ? value : {};
58
64
  child.key,
59
65
  child.out
60
66
  ]));
61
- return sourceArr.map((item, index)=>byIndex.has(index) ? byIndex.get(index) : item);
67
+ // Passthrough items (unknown blockType / unresolved fields) never reach the element branch's
68
+ // id guard, so apply the same rule here: strip a per-locale row's id to avoid an insert
69
+ // collision. Elements are shared only when this container and every ancestor is non-localized.
70
+ const childShared = cursor.sharedRow && !container.field.localized;
71
+ return sourceArr.map((item, index)=>{
72
+ if (byIndex.has(index)) return byIndex.get(index);
73
+ if (!childShared && isObject(item) && item.id != null) {
74
+ const { id: _dropped, ...rest } = item;
75
+ return rest;
76
+ }
77
+ return item;
78
+ });
62
79
  }
63
80
  const result = {};
64
81
  for (const child of children)result[child.key] = child.out;
65
- // Block elements keep their blockType; id is intentionally stripped (Postgres rejects it on update).
66
- if (container.kind === "element" && container.field.type === "blocks") {
67
- result.blockType = cursor.source.blockType;
82
+ if (container.kind === "element") {
83
+ // Block elements keep their blockType.
84
+ if (container.field.type === "blocks") result.blockType = cursor.source.blockType;
85
+ // Keep `id` only for a shared (non-localized, no localized ancestor) row so Payload updates it
86
+ // in place; stripping it there makes Payload recreate the row and wipe other locales. Under a
87
+ // localized container/ancestor the rows are per-locale, so the id is dropped to avoid an
88
+ // insert collision with the source locale's row. Guard against writing `id: undefined`.
89
+ if (cursor.sharedRow && cursor.source.id != null) result.id = cursor.source.id;
68
90
  }
69
91
  return result;
70
92
  }
@@ -88,7 +110,8 @@ const asObject = (value)=>isObject(value) ? value : {};
88
110
  */ reconcile(sourceData, targetData) {
89
111
  const root = {
90
112
  source: sourceData,
91
- target: targetData ?? {}
113
+ target: targetData ?? {},
114
+ sharedRow: true
92
115
  };
93
116
  return walkFields(this.schema, root, reconcileWalker) ?? {};
94
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",