@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,48 @@
1
+ .popup {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.375rem;
5
+ }
6
+
7
+ .row {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 0.375rem;
11
+ }
12
+
13
+ /* The from → to direction group; lays the source select, arrow and target chip out inline. */
14
+ .direction {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 0.375rem;
18
+ }
19
+
20
+ .arrow {
21
+ display: flex;
22
+ align-items: center;
23
+ color: var(--theme-elevation-400);
24
+ }
25
+
26
+ /* The fixed target locale — framed like the source Select (same border/radius/height/font) for
27
+ a consistent two-box "source → target" look, but with a muted bg to read as read-only. */
28
+ .current {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ box-sizing: border-box;
32
+ height: 24px;
33
+ padding-inline: 0.5rem;
34
+ border: 1px solid var(--theme-elevation-150);
35
+ border-radius: var(--style-radius-s);
36
+ background: var(--theme-elevation-50);
37
+ font-size: 0.875rem;
38
+ font-weight: 500;
39
+ color: var(--theme-elevation-800);
40
+ }
41
+
42
+ .trigger {
43
+ /* inline-flex so the wrapper hugs the icon button — a block/flex div stretches full width,
44
+ which drags the popover anchor across the field. */
45
+ display: inline-flex;
46
+ width: fit-content;
47
+ margin-bottom: 0.25rem;
48
+ }
@@ -0,0 +1,25 @@
1
+ import type { Field } from "payload";
2
+ import type { RawPayloadComponentExport } from "./client/shared/types/PayloadComponentExport";
3
+ /**
4
+ * Strategy for placing a per-field control into a field's admin config — the seam
5
+ * between *what* to render (a control component) and *where* it goes (positioning).
6
+ *
7
+ * Today the only implementation is {@link beforeInputPositioner}, built on Payload's
8
+ * native `admin.components.beforeInput` slot. A future field-slots plugin can supply its
9
+ * own positioner (e.g. placing the trigger in the field label, next to the locale) by
10
+ * implementing this same interface — the control component and the wiring in
11
+ * `withFieldTranslation` stay untouched. This is the connector we expose later; for now
12
+ * it lives internal, with the default below as the built-in fallback.
13
+ *
14
+ * @template T - The concrete field type, preserved through positioning.
15
+ * @param field - The field to attach the control to.
16
+ * @param control - Import-map reference to the control component.
17
+ * @returns The field with the control positioned.
18
+ */
19
+ export type FieldControlPositioner = <T extends Field>(field: T, control: RawPayloadComponentExport) => T;
20
+ /**
21
+ * Default positioner: append the control to the field's `admin.components.beforeInput`,
22
+ * rendering it just above the input. `beforeInput` is an array slot, so we append —
23
+ * preserving any components already declared on the field.
24
+ */
25
+ export declare const beforeInputPositioner: FieldControlPositioner;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Default positioner: append the control to the field's `admin.components.beforeInput`,
3
+ * rendering it just above the input. `beforeInput` is an array slot, so we append —
4
+ * preserving any components already declared on the field.
5
+ */ export const beforeInputPositioner = (field, control)=>{
6
+ const withAdmin = field;
7
+ const existing = withAdmin.admin?.components?.beforeInput ?? [];
8
+ return {
9
+ ...field,
10
+ admin: {
11
+ ...withAdmin.admin,
12
+ components: {
13
+ ...withAdmin.admin?.components,
14
+ beforeInput: [
15
+ ...existing,
16
+ control
17
+ ]
18
+ }
19
+ }
20
+ };
21
+ };
22
+
23
+ //# sourceMappingURL=field-actions.js.map
@@ -1,46 +1,46 @@
1
- import type { Field } from 'payload';
2
- import type { FieldTranslationConfig } from './server/shared/field-config';
1
+ import type { Field, RichTextField, TextareaField, TextField } from "payload";
2
+ import type { FieldTranslationConfig } from "./server/shared/field-config";
3
3
  export type { FieldTranslationConfig };
4
+ /**
5
+ * Field types eligible for the per-field translate control — the same text-bearing
6
+ * leaves the server's `TranslatableField` handles. `richText` writes back by re-mounting
7
+ * the Lexical editor (see the control); `text` / `textarea` write straight to form state.
8
+ */
9
+ type FieldControlCandidate = TextField | TextareaField | RichTextField;
4
10
  /**
5
11
  * @deprecated Use `FieldTranslationConfig` instead
6
12
  */
7
13
  export type { FieldTranslationConfig as TranslateKitFieldConfig };
8
14
  /**
9
- * Apply translation configuration to a Payload field.
15
+ * Configure a Payload field for translation.
10
16
  *
11
- * Use this helper to configure how fields should be handled during translation.
17
+ * - **Add a translate control** wrap a `text` / `textarea` field with no second
18
+ * argument. A per-field Translate control is positioned on the field (today, just
19
+ * above the input). Requires `fieldLevel()` in the plugin's `levels` to register the
20
+ * endpoint. Control-injection behavior is `@since 0.6.0`; field exclusion predates it.
21
+ * - **Exclude from translation** — pass `{ exclude: true }` (any field type). The field
22
+ * value is never sent to the translation provider and no control is added.
12
23
  *
13
24
  * @param field - The Payload field to configure
14
- * @param config - Translation configuration options
15
- * @returns The field with translation configuration applied
25
+ * @param config - Pass `{ exclude: true }` to exclude; omit to add a control
26
+ * @returns A new field with the configuration applied (the input is not mutated)
16
27
  *
17
28
  * @example
18
29
  * ```ts
19
30
  * import { withFieldTranslation } from '@focus-reactive/payload-plugin-translator'
20
31
  *
21
- * // Exclude a field from translation
22
- * withFieldTranslation(
23
- * { name: 'sku', type: 'text', localized: true },
24
- * { exclude: true }
25
- * )
26
- * ```
32
+ * // Add a per-field translate control
33
+ * withFieldTranslation({ name: 'title', type: 'text', localized: true })
27
34
  *
28
- * @example
29
- * ```ts
30
- * // In a collection definition
31
- * const Products: CollectionConfig = {
32
- * slug: 'products',
33
- * fields: [
34
- * { name: 'title', type: 'text', localized: true },
35
- * withFieldTranslation(
36
- * { name: 'sku', type: 'text', localized: true },
37
- * { exclude: true }
38
- * ),
39
- * ],
40
- * }
35
+ * // Exclude a field from translation
36
+ * withFieldTranslation({ name: 'sku', type: 'text', localized: true }, { exclude: true })
41
37
  * ```
42
38
  */
43
- export declare function withFieldTranslation<T extends Field>(field: T, config: FieldTranslationConfig): T;
39
+ export declare function withFieldTranslation<T extends Field>(field: T, ...rest: T extends FieldControlCandidate ? [config?: {
40
+ exclude?: boolean;
41
+ }] : [config: {
42
+ exclude: true;
43
+ }]): T;
44
44
  /**
45
45
  * @deprecated Use `withFieldTranslation` instead
46
46
  */
@@ -1,46 +1,23 @@
1
- import { TRANSLATE_KIT_CUSTOM_KEY } from './server/shared/field-config';
1
+ import { TRANSLATE_KIT_CUSTOM_KEY } from "./server/shared/field-config";
2
+ import { TranslateFieldControlExport } from "./client/widgets/translate-field-control/ui/TranslateFieldControl.export";
3
+ import { beforeInputPositioner } from "./field-actions";
2
4
  /**
3
- * Apply translation configuration to a Payload field.
4
- *
5
- * Use this helper to configure how fields should be handled during translation.
6
- *
7
- * @param field - The Payload field to configure
8
- * @param config - Translation configuration options
9
- * @returns The field with translation configuration applied
10
- *
11
- * @example
12
- * ```ts
13
- * import { withFieldTranslation } from '@focus-reactive/payload-plugin-translator'
14
- *
15
- * // Exclude a field from translation
16
- * withFieldTranslation(
17
- * { name: 'sku', type: 'text', localized: true },
18
- * { exclude: true }
19
- * )
20
- * ```
21
- *
22
- * @example
23
- * ```ts
24
- * // In a collection definition
25
- * const Products: CollectionConfig = {
26
- * slug: 'products',
27
- * fields: [
28
- * { name: 'title', type: 'text', localized: true },
29
- * withFieldTranslation(
30
- * { name: 'sku', type: 'text', localized: true },
31
- * { exclude: true }
32
- * ),
33
- * ],
34
- * }
35
- * ```
36
- */ export function withFieldTranslation(field, config) {
37
- return {
5
+ * Where the translate control is placed. The single swap point: when a dedicated
6
+ * field-slots plugin lands, point this at its positioner (or take one as config) and
7
+ * the control moves e.g. into the field label without touching anything else.
8
+ * @see FieldControlPositioner
9
+ */ const activeFieldControlPositioner = beforeInputPositioner;
10
+ export function withFieldTranslation(field, config) {
11
+ const configured = {
38
12
  ...field,
39
13
  custom: {
40
14
  ...field.custom ?? {},
41
- [TRANSLATE_KIT_CUSTOM_KEY]: config
15
+ [TRANSLATE_KIT_CUSTOM_KEY]: config ?? {}
42
16
  }
43
17
  };
18
+ // No `exclude` = control intent: position the control via the active positioner.
19
+ // `exclude: true` = no control (the field is left out of translation entirely).
20
+ return config?.exclude ? configured : activeFieldControlPositioner(configured, new TranslateFieldControlExport());
44
21
  }
45
22
  /**
46
23
  * @deprecated Use `withFieldTranslation` instead
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { createOpenAIProvider } from "./server/modules/translation-providers";
5
5
  export type { TranslationProvider, TranslationInput, TranslationOutput, OpenAIProviderConfig, DryRunConfig } from "./server/modules/translation-providers";
6
6
  export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
7
7
  export type { TaskRunnerProvider, PayloadJobsRunnerOptions } from "./server/modules/task-runner";
8
- export { documentLevel, collectionLevel } from "./server/modules/translation-levels";
8
+ export { documentLevel, collectionLevel, fieldLevel } from "./server/modules/translation-levels";
9
9
  export type { TranslationLevel } from "./server/modules/translation-levels";
10
10
  export { withFieldTranslation } from "./field-config";
11
11
  export type { FieldTranslationConfig } from "./field-config";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { createOpenAIProvider } from "./server/modules/translation-providers";
5
5
  // Task runners
6
6
  export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
7
7
  // Translation levels
8
- export { documentLevel, collectionLevel } from "./server/modules/translation-levels";
8
+ export { documentLevel, collectionLevel, fieldLevel } from "./server/modules/translation-levels";
9
9
  // Field config
10
10
  export { withFieldTranslation } from "./field-config";
11
11
  // Deprecated exports (for backwards compatibility)
package/dist/plugin.d.ts CHANGED
@@ -35,7 +35,7 @@ export type TranslatorPluginConfig = {
35
35
  basePath?: string;
36
36
  /**
37
37
  * Which translation surfaces to enable, as level factories — `documentLevel()`,
38
- * `collectionLevel()` (and, from a later phase, `fieldLevel()`). Omit for the
38
+ * `collectionLevel()`, `fieldLevel()`. Omit for the
39
39
  * default `[documentLevel(), collectionLevel()]`, which is exactly today's behaviour.
40
40
  * Each level should appear at most once: endpoints are deduplicated, but admin
41
41
  * components are not, so a duplicated level renders a duplicated control.
package/dist/plugin.js CHANGED
@@ -54,7 +54,9 @@ import { normalizePath } from "./server/shared";
54
54
  collections,
55
55
  basePath,
56
56
  access,
57
- taskRunnerFactory
57
+ taskRunnerFactory,
58
+ schemaMap,
59
+ translationProvider
58
60
  });
59
61
  for (const level of activeLevels)level.extend(builder);
60
62
  // Plugin-level contributions, routed through the same single config-writer:
@@ -0,0 +1,17 @@
1
+ import type { PayloadRequest } from "payload";
2
+ import type { FieldTranslationConfig } from "./model";
3
+ /**
4
+ * Synchronous single-field translation: read the field's value from the saved document in the
5
+ * chosen source locale, resolve the declared field path to its schema subtree, run
6
+ * `translateContent`, and return the translated value. No persistence — the result is written to
7
+ * form state by the caller.
8
+ *
9
+ * From-locale only: `source_lng` + `doc_id` are required (validated by the schema), so there is
10
+ * always exactly one DB read. Reserves HTTP errors for genuine errors — "nothing to translate"
11
+ * and "couldn't resolve the block" come back as a 200 `noop` with a notice.
12
+ */
13
+ export declare class TranslateFieldHandler {
14
+ private readonly config;
15
+ constructor(config: FieldTranslationConfig);
16
+ handle(req: PayloadRequest): Promise<Response>;
17
+ }
@@ -0,0 +1,85 @@
1
+ import { getByPath, ServerResponse } from "../../shared";
2
+ import { translateContent } from "../../modules/translation-pipeline";
3
+ import { FieldTranslationInputSchema, MAX_FIELD_VALUE_BYTES } from "./model";
4
+ import { resolveFieldSubtree } from "./resolveFieldSubtree";
5
+ const byteLength = (value)=>new TextEncoder().encode(JSON.stringify(value) ?? "").length;
6
+ const noop = (value, level, message)=>({
7
+ status: "noop",
8
+ value,
9
+ notice: {
10
+ level,
11
+ message
12
+ }
13
+ });
14
+ /**
15
+ * Synchronous single-field translation: read the field's value from the saved document in the
16
+ * chosen source locale, resolve the declared field path to its schema subtree, run
17
+ * `translateContent`, and return the translated value. No persistence — the result is written to
18
+ * form state by the caller.
19
+ *
20
+ * From-locale only: `source_lng` + `doc_id` are required (validated by the schema), so there is
21
+ * always exactly one DB read. Reserves HTTP errors for genuine errors — "nothing to translate"
22
+ * and "couldn't resolve the block" come back as a 200 `noop` with a notice.
23
+ */ export class TranslateFieldHandler {
24
+ config;
25
+ constructor(config){
26
+ this.config = config;
27
+ }
28
+ async handle(req) {
29
+ const parsed = FieldTranslationInputSchema.safeParse(await req.json?.());
30
+ if (parsed.error) return ServerResponse.validationError(parsed.error.issues);
31
+ const { collection_slug, field_path, target_lng, source_lng, doc_id } = parsed.data;
32
+ const fields = this.config.schemaMap.get(collection_slug);
33
+ if (!fields) return ServerResponse.badRequest(`Collection "${collection_slug}" is not available for translation`);
34
+ // Read the source value from the saved document in `source_lng` (fallbackLocale: false so an
35
+ // empty source reads as empty → noop, not a fallback). The doc also lets the resolver
36
+ // disambiguate `blocks` — their `blockType` lives in the data.
37
+ const sourceDoc = await req.payload.findByID({
38
+ collection: collection_slug,
39
+ id: doc_id,
40
+ locale: source_lng,
41
+ fallbackLocale: false,
42
+ depth: 0
43
+ });
44
+ const sourceValue = getByPath(sourceDoc, field_path);
45
+ // Guard the *translated* payload (held synchronously through the provider call), not the
46
+ // request body, which now carries no field value.
47
+ if (byteLength(sourceValue) > MAX_FIELD_VALUE_BYTES) {
48
+ return ServerResponse.custom(`Field value exceeds the ${MAX_FIELD_VALUE_BYTES}-byte limit`, 413);
49
+ }
50
+ const resolution = resolveFieldSubtree(fields, field_path, sourceValue, sourceDoc);
51
+ if (resolution.status === "not-found") {
52
+ return ServerResponse.badRequest(`Field path "${field_path}" was not found in collection "${collection_slug}"`);
53
+ }
54
+ if (resolution.status === "inside-blocks") {
55
+ return ServerResponse.success(noop(sourceValue, "info", "Couldn't resolve the block for this field in the source document"));
56
+ }
57
+ if (resolution.status === "localized-list-ancestor") {
58
+ // Inside a localized blocks/array: its order/content is independent per locale, so the path
59
+ // index can't be matched to the source locale. Translate the whole document instead.
60
+ return ServerResponse.success(noop(sourceValue, "warning", "This field is inside a localized block — translate the whole document instead, so blocks stay aligned across locales"));
61
+ }
62
+ if (resolution.status === "not-translatable") {
63
+ return ServerResponse.success(noop(sourceValue, "info", "Nothing to translate in this field"));
64
+ }
65
+ // No `strategy`/`targetData`: a per-field translate is an explicit "translate this field now",
66
+ // so `translateContent` always overwrites (its default). skip_existing has no meaning here.
67
+ const translated = await translateContent({
68
+ schema: resolution.schema,
69
+ sourceData: resolution.sourceData,
70
+ sourceLng: source_lng,
71
+ targetLng: target_lng,
72
+ translationProvider: this.config.translationProvider
73
+ });
74
+ if (!translated) {
75
+ return ServerResponse.success(noop(sourceValue, "info", "Nothing to translate in this field"));
76
+ }
77
+ const result = {
78
+ status: "translated",
79
+ value: translated[resolution.fieldName]
80
+ };
81
+ return ServerResponse.success(result);
82
+ }
83
+ }
84
+
85
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1,6 @@
1
+ export { createFieldRoute } from "./route";
2
+ export type { CreateFieldRouteArgs } from "./route";
3
+ export { TranslateFieldHandler } from "./handler";
4
+ export { resolveFieldSubtree } from "./resolveFieldSubtree";
5
+ export type { FieldSubtreeResolution } from "./resolveFieldSubtree";
6
+ export type { FieldTranslationConfig, FieldTranslationInput, FieldTranslationNotice, FieldTranslationResult } from "./model";
@@ -0,0 +1,5 @@
1
+ export { createFieldRoute } from "./route";
2
+ export { TranslateFieldHandler } from "./handler";
3
+ export { resolveFieldSubtree } from "./resolveFieldSubtree";
4
+
5
+ //# sourceMappingURL=index.js.map
@@ -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);