@focus-reactive/payload-plugin-translator 0.5.1 → 0.6.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 (64) hide show
  1. package/README.md +46 -8
  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,62 @@
1
+ import { z } from "zod";
2
+ import type { CollectionSchemaMap } from "../translate-document";
3
+ import type { TranslationProvider } from "../../modules/translation-providers";
4
+ /**
5
+ * Max serialized size of the value the endpoint will translate — the value read from the
6
+ * source-locale document (the request body no longer carries one; this is from-locale only). The
7
+ * field endpoint is synchronous — it holds the request for the whole translation — so an unbounded
8
+ * payload is a DoS surface. 256 KiB comfortably covers a long `richText` document; still a guard,
9
+ * not yet configurable.
10
+ */
11
+ export declare const MAX_FIELD_VALUE_BYTES: number;
12
+ /**
13
+ * Request body for `POST {basePath}/field`. Snake_case to match the other translator routes.
14
+ *
15
+ * Single mode — **from locale**: the server reads the saved document (`doc_id`) in `source_lng`,
16
+ * takes its value at `field_path`, and translates it into `target_lng`. Both `source_lng` and
17
+ * `doc_id` are therefore required (a saved document). `doc_id` reuses {@link JobIdSchema} — the
18
+ * canonical id guard, ID-format-agnostic (string UUID/ObjectId or numeric autoincrement),
19
+ * normalized to a string.
20
+ */
21
+ export declare const FieldTranslationInputSchema: z.ZodObject<{
22
+ collection_slug: z.ZodString;
23
+ field_path: z.ZodString;
24
+ target_lng: z.ZodString;
25
+ source_lng: z.ZodString;
26
+ doc_id: z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>;
27
+ }, "strip", z.ZodTypeAny, {
28
+ collection_slug: string;
29
+ source_lng: string;
30
+ target_lng: string;
31
+ field_path: string;
32
+ doc_id: string;
33
+ }, {
34
+ collection_slug: string;
35
+ source_lng: string;
36
+ target_lng: string;
37
+ field_path: string;
38
+ doc_id: string | number;
39
+ }>;
40
+ export type FieldTranslationInput = z.infer<typeof FieldTranslationInputSchema>;
41
+ /** Handler dependencies — the schema source of truth + the translation backend. */
42
+ export type FieldTranslationConfig = {
43
+ schemaMap: CollectionSchemaMap;
44
+ translationProvider: TranslationProvider;
45
+ };
46
+ export type FieldTranslationNotice = {
47
+ level: "info" | "warning";
48
+ message: string;
49
+ };
50
+ /**
51
+ * Successful response. Never an error for "couldn't translate": a field with no
52
+ * localized content (or a path our resolver can't handle yet) is a `noop` with a
53
+ * calm notice, not an HTTP error.
54
+ */
55
+ export type FieldTranslationResult = {
56
+ status: "translated";
57
+ value: unknown;
58
+ } | {
59
+ status: "noop";
60
+ value: unknown;
61
+ notice: FieldTranslationNotice;
62
+ };
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ import { JobIdSchema } from "../../shared";
3
+ /**
4
+ * Max serialized size of the value the endpoint will translate — the value read from the
5
+ * source-locale document (the request body no longer carries one; this is from-locale only). The
6
+ * field endpoint is synchronous — it holds the request for the whole translation — so an unbounded
7
+ * payload is a DoS surface. 256 KiB comfortably covers a long `richText` document; still a guard,
8
+ * not yet configurable.
9
+ */ export const MAX_FIELD_VALUE_BYTES = 256 * 1024;
10
+ /**
11
+ * Request body for `POST {basePath}/field`. Snake_case to match the other translator routes.
12
+ *
13
+ * Single mode — **from locale**: the server reads the saved document (`doc_id`) in `source_lng`,
14
+ * takes its value at `field_path`, and translates it into `target_lng`. Both `source_lng` and
15
+ * `doc_id` are therefore required (a saved document). `doc_id` reuses {@link JobIdSchema} — the
16
+ * canonical id guard, ID-format-agnostic (string UUID/ObjectId or numeric autoincrement),
17
+ * normalized to a string.
18
+ */ export const FieldTranslationInputSchema = z.object({
19
+ collection_slug: z.string().nonempty(),
20
+ field_path: z.string().nonempty(),
21
+ target_lng: z.string().nonempty(),
22
+ source_lng: z.string().nonempty(),
23
+ doc_id: JobIdSchema
24
+ });
25
+
26
+ //# sourceMappingURL=model.js.map
@@ -6,8 +6,11 @@ import type { Field } from "payload";
6
6
  * are ready for `translateContent`, and `fieldName` is the key to unwrap the result.
7
7
  * - `not-found` — the path resolves to no field at all (a typo) → caller returns 400.
8
8
  * - `not-translatable` — resolves to a field that isn't a text-like leaf → caller no-ops.
9
- * - `inside-blocks` — the path descends through a polymorphic `blocks` field, which
10
- * can't be resolved from the path alone (the blockType lives in the data) → caller no-ops.
9
+ * - `inside-blocks` — the path descends through a polymorphic `blocks` field that couldn't be
10
+ * resolved: no `doc` was supplied to read the element's `blockType` from → caller no-ops.
11
+ * - `localized-list-ancestor` — the path descends through a `localized` `blocks`/`array` field,
12
+ * whose per-locale order/content is independent, so a positional path can't be matched across
13
+ * locales → caller no-ops (translate the whole document instead).
11
14
  */
12
15
  export type FieldSubtreeResolution = {
13
16
  status: "resolved";
@@ -20,12 +23,17 @@ export type FieldSubtreeResolution = {
20
23
  status: "not-translatable";
21
24
  } | {
22
25
  status: "inside-blocks";
26
+ } | {
27
+ status: "localized-list-ancestor";
23
28
  };
24
29
  /**
25
30
  * Map a declared `fieldPath` to the `{ schema, sourceData }` pair `translateContent` expects.
26
- * Walks the schema by field name (via the shared {@link findFieldByPath}), dropping array indices
27
- * (a field's config is shared across array items). Descends transparently through presentational
28
- * containers (row, collapsible, unnamed tabs) and through named group/array/tab containers. A path
29
- * that descends through a `blocks` field returns `inside-blocks`.
31
+ * Walks the schema via the shared {@link findFieldByPath}. Descends transparently through
32
+ * presentational containers (row, collapsible, unnamed tabs) and through named group/array/tab
33
+ * containers; array element indices select the data item but not the schema (shared across items).
34
+ *
35
+ * Pass `doc` (the document the path belongs to) to descend through polymorphic `blocks` fields:
36
+ * the element's `blockType` in `doc` picks the block schema. Without `doc`, a path through a
37
+ * `blocks` field returns `inside-blocks`.
30
38
  */
31
- export declare function resolveFieldSubtree(rootFields: Field[], fieldPath: string, value: unknown): FieldSubtreeResolution;
39
+ export declare function resolveFieldSubtree(rootFields: Field[], fieldPath: string, value: unknown, doc?: unknown): FieldSubtreeResolution;
@@ -1,18 +1,20 @@
1
1
  import { isTranslatableField } from "../../shared";
2
2
  import { findFieldByPath } from "../../shared/field-traversal";
3
- const isIndexSegment = (segment)=>/^\d+$/u.test(segment);
4
3
  /**
5
4
  * Map a declared `fieldPath` to the `{ schema, sourceData }` pair `translateContent` expects.
6
- * Walks the schema by field name (via the shared {@link findFieldByPath}), dropping array indices
7
- * (a field's config is shared across array items). Descends transparently through presentational
8
- * containers (row, collapsible, unnamed tabs) and through named group/array/tab containers. A path
9
- * that descends through a `blocks` field returns `inside-blocks`.
10
- */ export function resolveFieldSubtree(rootFields, fieldPath, value) {
11
- const segments = fieldPath.split(".").map((segment)=>segment.trim()).filter((segment)=>segment.length > 0 && !isIndexSegment(segment));
5
+ * Walks the schema via the shared {@link findFieldByPath}. Descends transparently through
6
+ * presentational containers (row, collapsible, unnamed tabs) and through named group/array/tab
7
+ * containers; array element indices select the data item but not the schema (shared across items).
8
+ *
9
+ * Pass `doc` (the document the path belongs to) to descend through polymorphic `blocks` fields:
10
+ * the element's `blockType` in `doc` picks the block schema. Without `doc`, a path through a
11
+ * `blocks` field returns `inside-blocks`.
12
+ */ export function resolveFieldSubtree(rootFields, fieldPath, value, doc) {
13
+ const segments = fieldPath.split(".").map((segment)=>segment.trim()).filter((segment)=>segment.length > 0);
12
14
  if (segments.length === 0) return {
13
15
  status: "not-found"
14
16
  };
15
- const result = findFieldByPath(rootFields, segments);
17
+ const result = findFieldByPath(rootFields, segments, doc);
16
18
  switch(result.status){
17
19
  case "leaf":
18
20
  return isTranslatableField(result.field) ? {
@@ -35,6 +37,10 @@ const isIndexSegment = (segment)=>/^\d+$/u.test(segment);
35
37
  return {
36
38
  status: "inside-blocks"
37
39
  };
40
+ case "localized-list-ancestor":
41
+ return {
42
+ status: "localized-list-ancestor"
43
+ };
38
44
  case "not-found":
39
45
  return {
40
46
  status: "not-found"
@@ -0,0 +1,12 @@
1
+ import type { Endpoint } from "payload";
2
+ import type { AccessGuard } from "../../shared";
3
+ import type { FieldTranslationConfig } from "./model";
4
+ export type CreateFieldRouteArgs = FieldTranslationConfig & {
5
+ access?: AccessGuard;
6
+ basePath?: string;
7
+ };
8
+ /**
9
+ * Creates the synchronous field-translation endpoint: `POST {basePath}/field`.
10
+ * Wired with the same access + error-envelope plumbing as the document routes.
11
+ */
12
+ export declare function createFieldRoute({ schemaMap, translationProvider, access, basePath }: CreateFieldRouteArgs): Endpoint;
@@ -0,0 +1,18 @@
1
+ import { withAccessCheck, withErrorHandler } from "../../shared";
2
+ import { TranslateFieldHandler } from "./handler";
3
+ /**
4
+ * Creates the synchronous field-translation endpoint: `POST {basePath}/field`.
5
+ * Wired with the same access + error-envelope plumbing as the document routes.
6
+ */ export function createFieldRoute({ schemaMap, translationProvider, access, basePath = "/translate" }) {
7
+ const handler = new TranslateFieldHandler({
8
+ schemaMap,
9
+ translationProvider
10
+ });
11
+ return {
12
+ path: `${basePath}/field`,
13
+ method: "post",
14
+ handler: withAccessCheck(withErrorHandler(handler.handle.bind(handler)), access)
15
+ };
16
+ }
17
+
18
+ //# sourceMappingURL=route.js.map
@@ -1,6 +1,8 @@
1
1
  import type { CollectionConfig, Config, 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
  import type { CollectionAdminSlot, LevelContext } from "./types";
6
8
  type ConfigModifier = (config: Config) => Config;
@@ -9,6 +11,8 @@ export type PluginConfigBuilderDeps = {
9
11
  basePath: string;
10
12
  access?: AccessGuard;
11
13
  taskRunnerFactory: TaskRunnerFactory;
14
+ schemaMap: CollectionSchemaMap;
15
+ translationProvider: TranslationProvider;
12
16
  };
13
17
  /**
14
18
  * The single place that mutates the Payload `config`. Levels (through the narrow
@@ -26,6 +30,8 @@ export declare class PluginConfigBuilder implements LevelContext {
26
30
  readonly basePath: string;
27
31
  readonly access?: AccessGuard;
28
32
  readonly taskRunnerFactory: TaskRunnerFactory;
33
+ readonly schemaMap: CollectionSchemaMap;
34
+ readonly translationProvider: TranslationProvider;
29
35
  private readonly endpoints;
30
36
  private readonly collectionComponents;
31
37
  private readonly adminProviders;
@@ -32,6 +32,8 @@ function attachToSlot(collection, slot, component) {
32
32
  basePath;
33
33
  access;
34
34
  taskRunnerFactory;
35
+ schemaMap;
36
+ translationProvider;
35
37
  endpoints = [];
36
38
  collectionComponents = [];
37
39
  adminProviders = [];
@@ -41,6 +43,8 @@ function attachToSlot(collection, slot, component) {
41
43
  this.basePath = deps.basePath;
42
44
  this.access = deps.access;
43
45
  this.taskRunnerFactory = deps.taskRunnerFactory;
46
+ this.schemaMap = deps.schemaMap;
47
+ this.translationProvider = deps.translationProvider;
44
48
  }
45
49
  addEndpoints(endpoints) {
46
50
  this.endpoints.push(...endpoints);
@@ -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;