@focus-reactive/payload-plugin-translator 0.5.1 → 0.6.1

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 (64) hide show
  1. package/README.md +191 -287
  2. package/dist/client/entities/translation/api/mutations/useTranslateField.d.ts +22 -0
  3. package/dist/client/entities/translation/api/mutations/useTranslateField.js +31 -0
  4. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -3
  5. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +15 -13
  6. package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.d.ts +2 -2
  7. package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.js +10 -10
  8. package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.d.ts +1 -1
  9. package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.js +9 -9
  10. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +2 -2
  11. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +10 -8
  12. package/dist/client/shared/lib/assets/icons/SendIcon.d.ts +1 -0
  13. package/dist/client/shared/lib/assets/icons/SendIcon.js +17 -0
  14. package/dist/client/shared/ui/Button/Button.d.ts +2 -1
  15. package/dist/client/shared/ui/Button/Button.js +1 -1
  16. package/dist/client/shared/ui/Button/styles.module.scss +26 -3
  17. package/dist/client/widgets/translate-field-control/index.d.ts +1 -0
  18. package/dist/client/widgets/translate-field-control/index.js +3 -0
  19. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.d.ts +26 -0
  20. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.d.ts +13 -0
  21. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.js +14 -0
  22. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +204 -0
  23. package/dist/client/widgets/translate-field-control/ui/styles.module.scss +48 -0
  24. package/dist/field-actions.d.ts +25 -0
  25. package/dist/field-actions.js +23 -0
  26. package/dist/field-config.d.ts +26 -26
  27. package/dist/field-config.js +14 -37
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/plugin.d.ts +1 -1
  31. package/dist/plugin.js +3 -1
  32. package/dist/server/features/translate-field/handler.d.ts +17 -0
  33. package/dist/server/features/translate-field/handler.js +85 -0
  34. package/dist/server/features/translate-field/index.d.ts +6 -0
  35. package/dist/server/features/translate-field/index.js +5 -0
  36. package/dist/server/features/translate-field/model.d.ts +62 -0
  37. package/dist/server/features/translate-field/model.js +26 -0
  38. package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +15 -7
  39. package/dist/server/features/translate-field/resolveFieldSubtree.js +14 -8
  40. package/dist/server/features/translate-field/route.d.ts +12 -0
  41. package/dist/server/features/translate-field/route.js +18 -0
  42. package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +6 -0
  43. package/dist/server/modules/translation-levels/PluginConfigBuilder.js +4 -0
  44. package/dist/server/modules/translation-levels/fieldLevel.d.ts +23 -0
  45. package/dist/server/modules/translation-levels/fieldLevel.js +37 -0
  46. package/dist/server/modules/translation-levels/index.d.ts +1 -0
  47. package/dist/server/modules/translation-levels/index.js +1 -0
  48. package/dist/server/modules/translation-levels/types.d.ts +6 -1
  49. package/dist/server/modules/translation-levels/types.js +0 -1
  50. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.js +10 -4
  51. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.d.ts +1 -1
  52. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.js +1 -1
  53. package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts +5 -0
  54. package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js +13 -4
  55. package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +17 -0
  56. package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +8 -2
  57. package/dist/server/shared/field-config/types.js +1 -1
  58. package/dist/server/shared/field-traversal/findFieldByPath.d.ts +25 -10
  59. package/dist/server/shared/field-traversal/findFieldByPath.js +59 -14
  60. package/dist/server/shared/field-traversal/index.d.ts +1 -1
  61. package/dist/server/shared/field-traversal/index.js +1 -1
  62. package/dist/server/shared/field-traversal/kernel.d.ts +22 -0
  63. package/dist/server/shared/field-traversal/kernel.js +27 -0
  64. package/package.json +2 -2
@@ -0,0 +1,23 @@
1
+ import type { TranslationLevel } from "./types";
2
+ /**
3
+ * Field-level translation surface: a synchronous `POST {basePath}/field` endpoint that translates
4
+ * a single declared field **from a chosen source locale** — it reads the field's value from the
5
+ * saved document in that locale and translates it into the active locale. No runner, no queue, no
6
+ * persistence. Unlike `documentLevel` / `collectionLevel`, it contributes no admin component: the
7
+ * per-field control UI is wired separately at field-declaration time via `withFieldTranslation(field)`.
8
+ *
9
+ * Not part of the default `levels`, so it stays strictly opt-in.
10
+ *
11
+ * @returns an opaque {@link TranslationLevel} to list in `translatorPlugin({ levels })`
12
+ * @since 0.6.0
13
+ * @example
14
+ * ```ts
15
+ * translatorPlugin({
16
+ * collections: [Posts],
17
+ * translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
18
+ * runner: createPayloadJobsRunner(),
19
+ * levels: [documentLevel(), fieldLevel()],
20
+ * });
21
+ * ```
22
+ */
23
+ export declare function fieldLevel(): TranslationLevel;
@@ -0,0 +1,37 @@
1
+ import { createFieldRoute } from "../../features/translate-field";
2
+ /**
3
+ * Field-level translation surface: a synchronous `POST {basePath}/field` endpoint that translates
4
+ * a single declared field **from a chosen source locale** — it reads the field's value from the
5
+ * saved document in that locale and translates it into the active locale. No runner, no queue, no
6
+ * persistence. Unlike `documentLevel` / `collectionLevel`, it contributes no admin component: the
7
+ * per-field control UI is wired separately at field-declaration time via `withFieldTranslation(field)`.
8
+ *
9
+ * Not part of the default `levels`, so it stays strictly opt-in.
10
+ *
11
+ * @returns an opaque {@link TranslationLevel} to list in `translatorPlugin({ levels })`
12
+ * @since 0.6.0
13
+ * @example
14
+ * ```ts
15
+ * translatorPlugin({
16
+ * collections: [Posts],
17
+ * translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
18
+ * runner: createPayloadJobsRunner(),
19
+ * levels: [documentLevel(), fieldLevel()],
20
+ * });
21
+ * ```
22
+ */ export function fieldLevel() {
23
+ return {
24
+ extend (ctx) {
25
+ ctx.addEndpoints([
26
+ createFieldRoute({
27
+ schemaMap: ctx.schemaMap,
28
+ translationProvider: ctx.translationProvider,
29
+ access: ctx.access,
30
+ basePath: ctx.basePath
31
+ })
32
+ ]);
33
+ }
34
+ };
35
+ }
36
+
37
+ //# sourceMappingURL=fieldLevel.js.map
@@ -1,3 +1,4 @@
1
1
  export type { TranslationLevel } from "./types";
2
2
  export { documentLevel } from "./documentLevel";
3
3
  export { collectionLevel } from "./collectionLevel";
4
+ export { fieldLevel } from "./fieldLevel";
@@ -1,4 +1,5 @@
1
1
  export { documentLevel } from "./documentLevel";
2
2
  export { collectionLevel } from "./collectionLevel";
3
+ export { fieldLevel } from "./fieldLevel";
3
4
 
4
5
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,8 @@
1
1
  import type { CollectionConfig, Endpoint } from "payload";
2
2
  import type { AccessGuard } from "../../../types/AccessGuard";
3
3
  import type { RawPayloadComponentExport } from "../../../client/shared/types/PayloadComponentExport";
4
+ import type { CollectionSchemaMap } from "../../features/translate-document";
5
+ import type { TranslationProvider } from "../translation-providers";
4
6
  import type { TaskRunnerFactory } from "../task-runner";
5
7
  export type CollectionAdminSlot = "beforeDocumentControls" | "beforeListTable";
6
8
  /**
@@ -27,7 +29,6 @@ export interface TranslationLevel {
27
29
  *
28
30
  * A level contributes via these generic primitives; it never mutates the raw
29
31
  * Payload config. The plugin deduplicates endpoints by method + path on apply.
30
- * (fieldLevel — Phase 2 — will also read `schemaMap` + `translationProvider`.)
31
32
  * @internal
32
33
  */
33
34
  export interface LevelContext {
@@ -35,6 +36,10 @@ export interface LevelContext {
35
36
  readonly basePath: string;
36
37
  readonly access?: AccessGuard;
37
38
  readonly taskRunnerFactory: TaskRunnerFactory;
39
+ /** Deep-cloned localized field schema per managed collection slug. */
40
+ readonly schemaMap: CollectionSchemaMap;
41
+ /** The configured translation backend (used by the synchronous field level). */
42
+ readonly translationProvider: TranslationProvider;
38
43
  /** Register endpoints. Deduplicated by method + path when applied. */
39
44
  addEndpoints(endpoints: Endpoint[]): void;
40
45
  /** Attach an admin component to a slot on every managed collection. */
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * A level contributes via these generic primitives; it never mutates the raw
5
5
  * Payload config. The plugin deduplicates endpoints by method + path on apply.
6
- * (fieldLevel — Phase 2 — will also read `schemaMap` + `translationProvider`.)
7
6
  * @internal
8
7
  */ export { };
9
8
 
@@ -1,5 +1,5 @@
1
1
  import { isEmpty, isObject } from "../../../../shared";
2
- import { resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
2
+ import { matchElementById, resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
3
3
  const asObject = (value)=>isObject(value) ? value : {};
4
4
  /**
5
5
  * Reconcile walker: produces the full document shape from source + target, with target
@@ -7,6 +7,9 @@ const asObject = (value)=>isObject(value) ? value : {};
7
7
  * no source value is dropped, `id` is stripped from array/block elements (Postgres rejects it
8
8
  * on update), `blockType` is preserved, and non-object array items / unknown blocks pass
9
9
  * through unchanged.
10
+ *
11
+ * Array/block elements are paired with their target counterpart by `id`, not by position (see
12
+ * {@link matchElementById}); output order always follows `source`.
10
13
  */ const reconcileWalker = {
11
14
  enterObject (field, cursor) {
12
15
  const sourceValue = cursor.source[field.name];
@@ -21,15 +24,17 @@ const asObject = (value)=>isObject(value) ? value : {};
21
24
  if (!Array.isArray(sourceValue)) return "skip";
22
25
  const targetValue = cursor.target[field.name];
23
26
  const targetArr = Array.isArray(targetValue) ? targetValue : [];
27
+ const isBlocks = field.type === "blocks";
24
28
  const children = [];
25
29
  sourceValue.forEach((item, index)=>{
26
30
  if (!isObject(item)) return; // non-object element → passthrough (rebuilt in combine)
27
- const fields = field.type === "blocks" ? resolveBlockFields(field, item) : field.fields;
31
+ const fields = isBlocks ? resolveBlockFields(field, item) : field.fields;
28
32
  if (!fields) return; // unknown blockType → passthrough
33
+ // Pair by id, not position: target[index] may be a different element under per-locale ordering.
29
34
  children.push({
30
35
  cursor: {
31
36
  source: item,
32
- target: asObject(targetArr[index])
37
+ target: matchElementById(targetArr, item, isBlocks)
33
38
  },
34
39
  fields,
35
40
  key: index
@@ -45,7 +50,8 @@ const asObject = (value)=>isObject(value) ? value : {};
45
50
  },
46
51
  combine (container, children, cursor) {
47
52
  if (container.kind === "list") {
48
- // Rebuild the full array: reconciled objects where present (by index), raw source items otherwise.
53
+ // Rebuild the full array in source order: reconciled objects keyed by source position, raw
54
+ // source items (non-object / unknown block) otherwise.
49
55
  const sourceArr = cursor.source[container.key] ?? [];
50
56
  const byIndex = new Map(children.map((child)=>[
51
57
  child.key,
@@ -1,4 +1,4 @@
1
- import type { PipelineContext, PipelineStage } from '../../types';
1
+ import type { PipelineContext, PipelineStage } from "../../types";
2
2
  /**
3
3
  * Deep merges source and target data with target priority.
4
4
  * Stage 1 of the translation pipeline.
@@ -1,4 +1,4 @@
1
- import { DataReconciler } from './DataReconciler';
1
+ import { DataReconciler } from "./DataReconciler";
2
2
  /**
3
3
  * Deep merges source and target data with target priority.
4
4
  * Stage 1 of the translation pipeline.
@@ -8,6 +8,11 @@ import type { FieldChunk } from "../../types";
8
8
  * mutable reference to its parent object (for later write-back). Collect-only — `combine` is a
9
9
  * no-op; results accumulate in a closure.
10
10
  *
11
+ * Array/block elements pair their `source` by position (`filteredData` is built in source order)
12
+ * but their `target` by `id` (see {@link matchElementById}) — the target locale's elements may be
13
+ * independently ordered, so a positional target would feed `strategy.shouldTranslate` the wrong
14
+ * element's value and skip/re-translate the wrong leaf.
15
+ *
11
16
  * IMPORTANT: Expects ORIGINAL collection schemas (before Payload sanitization). Original
12
17
  * schemas preserve `localized: true` on nested fields.
13
18
  */
@@ -1,5 +1,5 @@
1
1
  import { isFieldExcludedFromTranslation, isLocalizedField, isObject, isTranslatableField } from "../../../../shared";
2
- import { resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
2
+ import { matchElementById, resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
3
3
  const asObject = (value)=>isObject(value) ? value : {};
4
4
  /**
5
5
  * Collects FieldChunks by walking schema + data with the shared {@link walkFields} engine.
@@ -8,6 +8,11 @@ const asObject = (value)=>isObject(value) ? value : {};
8
8
  * mutable reference to its parent object (for later write-back). Collect-only — `combine` is a
9
9
  * no-op; results accumulate in a closure.
10
10
  *
11
+ * Array/block elements pair their `source` by position (`filteredData` is built in source order)
12
+ * but their `target` by `id` (see {@link matchElementById}) — the target locale's elements may be
13
+ * independently ordered, so a positional target would feed `strategy.shouldTranslate` the wrong
14
+ * element's value and skip/re-translate the wrong leaf.
15
+ *
11
16
  * IMPORTANT: Expects ORIGINAL collection schemas (before Payload sanitization). Original
12
17
  * schemas preserve `localized: true` on nested fields.
13
18
  */ export class FieldChunkCollector {
@@ -47,16 +52,20 @@ const asObject = (value)=>isObject(value) ? value : {};
47
52
  const targetValue = cursor.target[field.name];
48
53
  const sourceArr = Array.isArray(sourceValue) ? sourceValue : [];
49
54
  const targetArr = Array.isArray(targetValue) ? targetValue : [];
55
+ const isBlocks = field.type === "blocks";
50
56
  const children = [];
51
57
  value.forEach((item, index)=>{
52
58
  if (!isObject(item)) return;
53
- const fields = field.type === "blocks" ? resolveBlockFields(field, item) : field.fields;
59
+ const fields = isBlocks ? resolveBlockFields(field, item) : field.fields;
54
60
  if (!fields) return; // unknown blockType → skip element
61
+ // source pairs by index (filteredData shares source order); target by the source
62
+ // element's id, since the target locale may be reordered independently.
63
+ const sourceItem = asObject(sourceArr[index]);
55
64
  children.push({
56
65
  cursor: {
57
66
  data: item,
58
- source: asObject(sourceArr[index]),
59
- target: asObject(targetArr[index]),
67
+ source: sourceItem,
68
+ target: matchElementById(targetArr, sourceItem, isBlocks),
60
69
  path: [
61
70
  ...cursor.path,
62
71
  field.name,
@@ -76,6 +76,23 @@ export type OpenAIProviderConfig = {
76
76
  * @default false
77
77
  */
78
78
  dryRun?: boolean | DryRunConfig;
79
+ /**
80
+ * Per-request timeout in milliseconds for the OpenAI client. A translation job blocks on this
81
+ * call, so the OpenAI SDK default (10 minutes) is usually too long. Omit to keep the SDK default.
82
+ *
83
+ * @example
84
+ * timeout: 60_000 // 60s
85
+ *
86
+ * @since 0.6.0
87
+ */
88
+ timeout?: number;
89
+ /**
90
+ * Maximum automatic retries the OpenAI client performs on transient errors (429, 5xx, network).
91
+ * Omit to keep the SDK default (2). Set `0` to disable retries.
92
+ *
93
+ * @since 0.6.0
94
+ */
95
+ maxRetries?: number;
79
96
  };
80
97
  /** @deprecated Use `createOpenAIProvider` function instead */
81
98
  export declare class OpenAITranslationProvider implements TranslationProvider {
@@ -4,8 +4,12 @@ import { isObject } from "../../shared";
4
4
  openAiClient;
5
5
  config;
6
6
  constructor(config){
7
+ // `timeout`/`maxRetries` are passed through to the OpenAI SDK; `undefined` keeps the SDK
8
+ // defaults (10 min timeout, 2 retries). A blocking translation job rarely wants the full 10 min.
7
9
  this.openAiClient = new OpenAI({
8
- apiKey: config.apiKey
10
+ apiKey: config.apiKey,
11
+ timeout: config.timeout,
12
+ maxRetries: config.maxRetries
9
13
  });
10
14
  this.config = config;
11
15
  }
@@ -43,7 +47,9 @@ import { isObject } from "../../shared";
43
47
  type: "json_object"
44
48
  }
45
49
  });
46
- const translatedContent = chatCompletion.choices[0].message.content;
50
+ // Guard `choices[0]`: an empty `choices` array (e.g. content-filtered response) would otherwise
51
+ // throw a TypeError instead of the intended graceful `null`.
52
+ const translatedContent = chatCompletion.choices[0]?.message?.content;
47
53
  if (!translatedContent) return null;
48
54
  try {
49
55
  return JSON.parse(translatedContent);
@@ -17,6 +17,6 @@
17
17
  * ```
18
18
  */ /**
19
19
  * Key used to store TranslateKit configuration in field.custom
20
- */ export const TRANSLATE_KIT_CUSTOM_KEY = 'translateKit';
20
+ */ export const TRANSLATE_KIT_CUSTOM_KEY = "translateKit";
21
21
 
22
22
  //# sourceMappingURL=types.js.map
@@ -4,10 +4,15 @@ import type { LeafField } from "./types";
4
4
  * Outcome of navigating a field schema by a path of segment names (see {@link findFieldByPath}).
5
5
  *
6
6
  * - `leaf` — the path lands on a data-affecting leaf field (carries it).
7
- * - `container` — the path lands on a `group`/`array`/`blocks` field or a named tab (a container,
8
- * not a leaf).
9
- * - `inside-blocks` — the path descends THROUGH a `blocks` field, which can't be resolved from the
10
- * schema alone (the block's `blockType` lives in the data, not the schema).
7
+ * - `container` — the path lands on a `group`/`array`/`blocks` field, a single block element, or a
8
+ * named tab (a container, not a leaf).
9
+ * - `inside-blocks` — the path descends THROUGH a `blocks` field that can't be resolved: no
10
+ * document data was supplied (so the element's `blockType` is unknown), the data is missing at
11
+ * that index, or the `blockType` matches no defined block.
12
+ * - `localized-list-ancestor` — the path descends THROUGH a `localized` `blocks`/`array` field.
13
+ * Such fields are stored independently per locale (different order/content), so an element index
14
+ * is not a stable cross-locale identity; a caller translating *by path across locales* should
15
+ * refuse rather than resolve positionally. (See the "cross-locale block identity" design.)
11
16
  * - `not-found` — no field matches a segment, or the path continues past a leaf.
12
17
  *
13
18
  * @public
@@ -19,6 +24,8 @@ export type FieldPathResult = {
19
24
  status: "container";
20
25
  } | {
21
26
  status: "inside-blocks";
27
+ } | {
28
+ status: "localized-list-ancestor";
22
29
  } | {
23
30
  status: "not-found";
24
31
  };
@@ -26,15 +33,23 @@ export type FieldPathResult = {
26
33
  * Navigate a field schema by a path of segment NAMES, descending one matching branch at a time
27
34
  * with early-exit (targeted navigation, not an exhaustive walk). Presentational containers
28
35
  * (`row`/`collapsible`/unnamed `group`) and unnamed tabs are transparent — searched in the same
29
- * path scope. Built on {@link classifyField} / {@link tabScopes} so the structural dispatch lives
30
- * in one place.
36
+ * path scope. Built on {@link classifyField} / {@link tabScopes} / {@link resolveBlockFields} so
37
+ * the structural dispatch lives in one place.
31
38
  *
32
- * The caller supplies already-prepared name segments (split, trimmed, with array indices dropped
33
- * a field's config is shared across array items, so indices never appear in the schema).
39
+ * **Array indices and `blocks`.** Array element configs are shared, so a numeric segment after an
40
+ * array name selects the data item but not the schema (the schema continues into `array.fields`).
41
+ * A `blocks` field is polymorphic — which block (and thus which fields) sits at an index lives in
42
+ * the *data*, not the schema. So to descend through `blocks` the caller must pass the document
43
+ * `data` and keep the element index in the path: this function reads `data[name][index].blockType`
44
+ * and resolves the matching block via {@link resolveBlockFields}. Without `data` (or with a
45
+ * non-indexed path), descending through `blocks` returns `inside-blocks`.
34
46
  *
35
47
  * @param fields - The schema level to search.
36
- * @param segments - Remaining path segments (names) to match, head-first.
48
+ * @param segments - Remaining path segments to match, head-first. Keep array/block element indices
49
+ * in the path when navigating with `data`.
50
+ * @param data - The document (sub)tree aligned with `fields`, used only to disambiguate `blocks`.
51
+ * Omit for schema-only navigation.
37
52
  * @returns A {@link FieldPathResult}.
38
53
  * @public
39
54
  */
40
- export declare function findFieldByPath(fields: Field[], segments: string[]): FieldPathResult;
55
+ export declare function findFieldByPath(fields: Field[], segments: string[], data?: unknown): FieldPathResult;
@@ -1,19 +1,29 @@
1
- import { classifyField, tabScopes } from "./kernel";
1
+ import { classifyField, resolveBlockFields, tabScopes } from "./kernel";
2
+ const isIndexSegment = (segment)=>/^\d+$/u.test(segment);
3
+ /** Safely read `key` off an object/array; `undefined` for non-objects (arrays index by string key). */ const childData = (data, key)=>data != null && typeof data === "object" ? data[key] : undefined;
2
4
  /**
3
5
  * Navigate a field schema by a path of segment NAMES, descending one matching branch at a time
4
6
  * with early-exit (targeted navigation, not an exhaustive walk). Presentational containers
5
7
  * (`row`/`collapsible`/unnamed `group`) and unnamed tabs are transparent — searched in the same
6
- * path scope. Built on {@link classifyField} / {@link tabScopes} so the structural dispatch lives
7
- * in one place.
8
+ * path scope. Built on {@link classifyField} / {@link tabScopes} / {@link resolveBlockFields} so
9
+ * the structural dispatch lives in one place.
8
10
  *
9
- * The caller supplies already-prepared name segments (split, trimmed, with array indices dropped
10
- * a field's config is shared across array items, so indices never appear in the schema).
11
+ * **Array indices and `blocks`.** Array element configs are shared, so a numeric segment after an
12
+ * array name selects the data item but not the schema (the schema continues into `array.fields`).
13
+ * A `blocks` field is polymorphic — which block (and thus which fields) sits at an index lives in
14
+ * the *data*, not the schema. So to descend through `blocks` the caller must pass the document
15
+ * `data` and keep the element index in the path: this function reads `data[name][index].blockType`
16
+ * and resolves the matching block via {@link resolveBlockFields}. Without `data` (or with a
17
+ * non-indexed path), descending through `blocks` returns `inside-blocks`.
11
18
  *
12
19
  * @param fields - The schema level to search.
13
- * @param segments - Remaining path segments (names) to match, head-first.
20
+ * @param segments - Remaining path segments to match, head-first. Keep array/block element indices
21
+ * in the path when navigating with `data`.
22
+ * @param data - The document (sub)tree aligned with `fields`, used only to disambiguate `blocks`.
23
+ * Omit for schema-only navigation.
14
24
  * @returns A {@link FieldPathResult}.
15
25
  * @public
16
- */ export function findFieldByPath(fields, segments) {
26
+ */ export function findFieldByPath(fields, segments, data) {
17
27
  const [head, ...rest] = segments;
18
28
  if (head === undefined) return {
19
29
  status: "not-found"
@@ -28,10 +38,10 @@ import { classifyField, tabScopes } from "./kernel";
28
38
  if (scope.tab.name === head) {
29
39
  return rest.length === 0 ? {
30
40
  status: "container"
31
- } : findFieldByPath(scope.tab.fields, rest);
41
+ } : findFieldByPath(scope.tab.fields, rest, childData(data, head));
32
42
  }
33
43
  } else {
34
- const found = findFieldByPath(scope.fields, segments); // unnamed tab → same path scope
44
+ const found = findFieldByPath(scope.fields, segments, data); // unnamed tab → same path + data scope
35
45
  if (found.status !== "not-found") return found;
36
46
  }
37
47
  }
@@ -39,28 +49,63 @@ import { classifyField, tabScopes } from "./kernel";
39
49
  }
40
50
  case "transparent":
41
51
  {
42
- const found = findFieldByPath(structure.fields, segments); // row/collapsible/unnamed group → same scope
52
+ const found = findFieldByPath(structure.fields, segments, data); // row/collapsible/unnamed group → same scope
43
53
  if (found.status !== "not-found") return found;
44
54
  break;
45
55
  }
46
56
  case "presentational":
47
57
  break;
48
58
  case "group":
49
- case "array":
50
59
  {
51
60
  if (structure.name !== head) break;
52
61
  return rest.length === 0 ? {
53
62
  status: "container"
54
- } : findFieldByPath(structure.fields, rest);
63
+ } : findFieldByPath(structure.fields, rest, childData(data, head));
64
+ }
65
+ case "array":
66
+ {
67
+ if (structure.name !== head) break;
68
+ if (rest.length === 0) return {
69
+ status: "container"
70
+ };
71
+ // A localized array is an independent per-locale structure — its element index is not a
72
+ // stable identity across locales, so refuse to navigate THROUGH it by path.
73
+ if (structure.field.localized) return {
74
+ status: "localized-list-ancestor"
75
+ };
76
+ // A numeric segment selects the data element; the schema is shared across elements.
77
+ const [next, ...tail] = rest;
78
+ if (isIndexSegment(next)) {
79
+ return tail.length === 0 ? {
80
+ status: "container"
81
+ } : findFieldByPath(structure.fields, tail, childData(childData(data, head), next));
82
+ }
83
+ return findFieldByPath(structure.fields, rest, childData(data, head));
55
84
  }
56
85
  case "blocks":
57
86
  {
58
87
  if (structure.name !== head) break;
59
- return rest.length === 0 ? {
88
+ if (rest.length === 0) return {
60
89
  status: "container"
61
- } : {
90
+ };
91
+ // A localized blocks field is an independent per-locale structure — its element index is
92
+ // not a stable identity across locales, so refuse to navigate THROUGH it by path.
93
+ if (structure.field.localized) return {
94
+ status: "localized-list-ancestor"
95
+ };
96
+ // Polymorphic: need the element index + its data to pick the block schema by `blockType`.
97
+ const [next, ...tail] = rest;
98
+ if (!isIndexSegment(next)) return {
99
+ status: "inside-blocks"
100
+ };
101
+ const item = childData(childData(data, head), next);
102
+ const blockFields = resolveBlockFields(structure.field, item);
103
+ if (!blockFields) return {
62
104
  status: "inside-blocks"
63
105
  };
106
+ return tail.length === 0 ? {
107
+ status: "container"
108
+ } : findFieldByPath(blockFields, tail, item);
64
109
  }
65
110
  case "leaf":
66
111
  {
@@ -1,5 +1,5 @@
1
1
  export { findFieldByPath } from "./findFieldByPath";
2
2
  export type { FieldPathResult } from "./findFieldByPath";
3
- export { classifyField, resolveBlockFields, tabScopes } from "./kernel";
3
+ export { classifyField, matchElementById, resolveBlockFields, tabScopes } from "./kernel";
4
4
  export type { ChildCursor, ChildOutput, ContainerInfo, FieldStructure, FieldWalker, LeafField, TabScope, WalkSignal } from "./types";
5
5
  export { walkFields } from "./walkFields";
@@ -1,5 +1,5 @@
1
1
  export { findFieldByPath } from "./findFieldByPath";
2
- export { classifyField, resolveBlockFields, tabScopes } from "./kernel";
2
+ export { classifyField, matchElementById, resolveBlockFields, tabScopes } from "./kernel";
3
3
  export { walkFields } from "./walkFields";
4
4
 
5
5
  //# sourceMappingURL=index.js.map
@@ -83,3 +83,25 @@ export declare function tabScopes(field: TabsField): TabScope[];
83
83
  * @public
84
84
  */
85
85
  export declare function resolveBlockFields(field: BlocksField, item: unknown): Field[] | null;
86
+ /**
87
+ * Find the element of a parallel data array that corresponds to `refItem` by `id` — not by
88
+ * position.
89
+ *
90
+ * Localized `blocks`/`array` fields are stored independently per locale, so the locales can be
91
+ * reordered or differ entirely; pairing two parallel trees by position then cross-contaminates
92
+ * unrelated elements ("blocks don't line up across locales"). Matching by `id` is correct in both
93
+ * regimes: a non-localized field is one shared row with the same `id` across locales (so id-match
94
+ * equals the old positional match), while independent localized content has diverging ids (no
95
+ * match → the caller falls back to `refItem`'s own values, never another element's). For blocks the
96
+ * `blockType` must also match, so an id collision across types can't graft mismatched fields.
97
+ *
98
+ * Shared by the translation pipeline's two multi-tree walkers — reconcile and collect — to pair
99
+ * their `target` array against the source/reference element currently being visited.
100
+ *
101
+ * @param arr - The array to search (e.g. the target-locale elements).
102
+ * @param refItem - The element being matched, carrying the canonical `id` (and `blockType`).
103
+ * @param isBlocks - Whether the field is a `blocks` field (then `blockType` must also match).
104
+ * @returns The matched element, or `{}` when there is no counterpart (or `refItem` carries no `id`).
105
+ * @public
106
+ */
107
+ export declare function matchElementById(arr: unknown[], refItem: Record<string, unknown>, isBlocks: boolean): Record<string, unknown>;
@@ -1,5 +1,6 @@
1
1
  import { fieldAffectsData, fieldIsArrayType, fieldIsBlockType, fieldIsGroupType, tabHasName } from "payload/shared";
2
2
  import { hasFields, isBlockItem, isTabsField } from "../guards";
3
+ import { isObject } from "../utils";
3
4
  /**
4
5
  * Classify a single Payload {@link Field} into a discriminated {@link FieldStructure} — the
5
6
  * one place that encodes how Payload field types map onto data boundaries.
@@ -141,5 +142,31 @@ import { hasFields, isBlockItem, isTabsField } from "../guards";
141
142
  const block = field.blocks.find((candidate)=>candidate.slug === item.blockType);
142
143
  return block ? block.fields : null;
143
144
  }
145
+ /**
146
+ * Find the element of a parallel data array that corresponds to `refItem` by `id` — not by
147
+ * position.
148
+ *
149
+ * Localized `blocks`/`array` fields are stored independently per locale, so the locales can be
150
+ * reordered or differ entirely; pairing two parallel trees by position then cross-contaminates
151
+ * unrelated elements ("blocks don't line up across locales"). Matching by `id` is correct in both
152
+ * regimes: a non-localized field is one shared row with the same `id` across locales (so id-match
153
+ * equals the old positional match), while independent localized content has diverging ids (no
154
+ * match → the caller falls back to `refItem`'s own values, never another element's). For blocks the
155
+ * `blockType` must also match, so an id collision across types can't graft mismatched fields.
156
+ *
157
+ * Shared by the translation pipeline's two multi-tree walkers — reconcile and collect — to pair
158
+ * their `target` array against the source/reference element currently being visited.
159
+ *
160
+ * @param arr - The array to search (e.g. the target-locale elements).
161
+ * @param refItem - The element being matched, carrying the canonical `id` (and `blockType`).
162
+ * @param isBlocks - Whether the field is a `blocks` field (then `blockType` must also match).
163
+ * @returns The matched element, or `{}` when there is no counterpart (or `refItem` carries no `id`).
164
+ * @public
165
+ */ export function matchElementById(arr, refItem, isBlocks) {
166
+ const id = refItem.id;
167
+ if (id === undefined || id === null) return {};
168
+ const match = arr.find((candidate)=>isObject(candidate) && candidate.id === id && (!isBlocks || candidate.blockType === refItem.blockType));
169
+ return isObject(match) ? match : {};
170
+ }
144
171
 
145
172
  //# sourceMappingURL=kernel.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
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",
@@ -50,7 +50,7 @@
50
50
  "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
51
51
  "copyfiles": "copyfiles -u 1 \"src/**/*.scss\" dist",
52
52
  "clean": "rm -rf dist",
53
- "dev": "bun run build:swc -- --watch",
53
+ "dev": "bun run build:swc -- --watch --copy-files",
54
54
  "lint": "ultracite check",
55
55
  "lint:fix": "ultracite fix",
56
56
  "test": "vitest run",