@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.
- package/README.md +191 -287
- package/dist/client/entities/translation/api/mutations/useTranslateField.d.ts +22 -0
- package/dist/client/entities/translation/api/mutations/useTranslateField.js +31 -0
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -3
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +15 -13
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.d.ts +2 -2
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.js +10 -10
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.d.ts +1 -1
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.js +9 -9
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +2 -2
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +10 -8
- package/dist/client/shared/lib/assets/icons/SendIcon.d.ts +1 -0
- package/dist/client/shared/lib/assets/icons/SendIcon.js +17 -0
- package/dist/client/shared/ui/Button/Button.d.ts +2 -1
- package/dist/client/shared/ui/Button/Button.js +1 -1
- package/dist/client/shared/ui/Button/styles.module.scss +26 -3
- package/dist/client/widgets/translate-field-control/index.d.ts +1 -0
- package/dist/client/widgets/translate-field-control/index.js +3 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.d.ts +26 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.d.ts +13 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.js +14 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +204 -0
- package/dist/client/widgets/translate-field-control/ui/styles.module.scss +48 -0
- package/dist/field-actions.d.ts +25 -0
- package/dist/field-actions.js +23 -0
- package/dist/field-config.d.ts +26 -26
- package/dist/field-config.js +14 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +3 -1
- package/dist/server/features/translate-field/handler.d.ts +17 -0
- package/dist/server/features/translate-field/handler.js +85 -0
- package/dist/server/features/translate-field/index.d.ts +6 -0
- package/dist/server/features/translate-field/index.js +5 -0
- package/dist/server/features/translate-field/model.d.ts +62 -0
- package/dist/server/features/translate-field/model.js +26 -0
- package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +15 -7
- package/dist/server/features/translate-field/resolveFieldSubtree.js +14 -8
- package/dist/server/features/translate-field/route.d.ts +12 -0
- package/dist/server/features/translate-field/route.js +18 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +6 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.js +4 -0
- package/dist/server/modules/translation-levels/fieldLevel.d.ts +23 -0
- package/dist/server/modules/translation-levels/fieldLevel.js +37 -0
- package/dist/server/modules/translation-levels/index.d.ts +1 -0
- package/dist/server/modules/translation-levels/index.js +1 -0
- package/dist/server/modules/translation-levels/types.d.ts +6 -1
- package/dist/server/modules/translation-levels/types.js +0 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.js +10 -4
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.d.ts +1 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.js +1 -1
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts +5 -0
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js +13 -4
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +17 -0
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +8 -2
- package/dist/server/shared/field-config/types.js +1 -1
- package/dist/server/shared/field-traversal/findFieldByPath.d.ts +25 -10
- package/dist/server/shared/field-traversal/findFieldByPath.js +59 -14
- package/dist/server/shared/field-traversal/index.d.ts +1 -1
- package/dist/server/shared/field-traversal/index.js +1 -1
- package/dist/server/shared/field-traversal/kernel.d.ts +22 -0
- package/dist/server/shared/field-traversal/kernel.js +27 -0
- 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,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 =
|
|
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:
|
|
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
|
|
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,
|
package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
59
|
-
target:
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
|
8
|
-
* not a leaf).
|
|
9
|
-
* - `inside-blocks` — the path descends THROUGH a `blocks` field
|
|
10
|
-
*
|
|
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}
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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}
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|