@focus-reactive/payload-plugin-translator 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,20 +18,23 @@ import { resolveTargetLocales } from "./resolveTargetLocales";
18
18
  const { source_lng, target_lng, collection_slug, collection_id, select_all, strategy, publish_on_translation } = validationResult.data;
19
19
  const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections);
20
20
  if (!collectionSlug) return ServerResponse.badRequest("Content of this collection is not available for translation");
21
- // Normalize the scalar-or-array target into the concrete locales to fan out to: de-dup, exclude the
22
- // source, and drop locales that are not configured (unknown locales would burn a provider call and
23
- // corrupt data Postgres locale enum / orphaned Mongo rows). `config` is optional-chained because a
24
- // localization-less (or minimally-mocked) payload has none, which correctly disables the filter.
21
+ // A localization-less config has no valid target locale: a phantom locale would burn a provider
22
+ // call and corrupt data orphaned rows on Mongo/SQLite, a locale-enum error on Postgres, or (with
23
+ // no localization at all) overwrite the single unlocalized field and wipe the source. Reject before
24
+ // anything is enqueued.
25
25
  const knownLocales = extractLocaleCodes(req.payload.config?.localization);
26
+ if (!knownLocales) return ServerResponse.badRequest("Localization is not enabled in this Payload config; there are no target locales to translate into");
27
+ // Normalize the scalar-or-array target into the concrete locales to fan out to: de-dup, exclude the
28
+ // source, and drop locales that are not configured.
26
29
  const { targets, droppedUnknown } = resolveTargetLocales({
27
30
  target_lng,
28
31
  source_lng,
29
32
  knownLocales
30
33
  });
31
34
  if (droppedUnknown.length > 0) {
32
- req.payload.logger?.warn(`[payload-plugin-translator] enqueue on "${collectionSlug}": ignoring unknown target locale(s) ${droppedUnknown.join(", ")} (configured locales: ${knownLocales ? [
35
+ req.payload.logger?.warn(`[payload-plugin-translator] enqueue on "${collectionSlug}": ignoring unknown target locale(s) ${droppedUnknown.join(", ")} (configured locales: ${[
33
36
  ...knownLocales
34
- ].join(", ") : "n/a"}).`);
37
+ ].join(", ")}).`);
35
38
  }
36
39
  if (targets.length === 0) return ServerResponse.badRequest("No valid target locales to translate into (all requested locales were the source or unknown)");
37
40
  const collectionIds = select_all ? await getAllCollectionIds(req.payload, collectionSlug) : collection_id;
@@ -4,7 +4,7 @@
4
4
  export type ResolvedTargetLocales = {
5
5
  /** The concrete locales to fan out to — de-duplicated, source excluded, unknown removed. */
6
6
  targets: string[];
7
- /** Requested locales that are not configured (dropped) — empty when localization is unknown/disabled. */
7
+ /** Requested locales that are not configured (dropped). */
8
8
  droppedUnknown: string[];
9
9
  /** Whether the source locale was requested as a target and excluded. */
10
10
  droppedSource: boolean;
@@ -12,18 +12,19 @@ export type ResolvedTargetLocales = {
12
12
  /**
13
13
  * Normalize the enqueue `target_lng` input (scalar or array) into the concrete list of target locales
14
14
  * to translate into. Applies, in order: array-coercion, de-duplication (first-seen order preserved),
15
- * source-locale exclusion, and — when the configured locale set is known — dropping unknown locales.
15
+ * source-locale exclusion, and dropping unknown (unconfigured) locales.
16
16
  *
17
17
  * De-dup and unknown-dropping are the manual-enqueue counterparts of the auto-translate policy filter:
18
18
  * the runner only supersedes against already-stored jobs, so duplicates within one enqueue must be
19
19
  * collapsed here; and an unknown locale must never reach the pipeline — it burns a provider call and
20
20
  * either errors on a Postgres locale enum or writes orphaned, invisible data on Mongo/SQLite.
21
21
  *
22
- * @param knownLocales - the configured locale codes, or `null` when localization is disabled/absent
23
- * (then no unknown-dropping is applied every requested locale except the source is kept).
22
+ * @param knownLocales - the configured locale codes. Never null: the caller must reject a
23
+ * localization-less config before reaching here (translating without localization has no valid
24
+ * target and would corrupt data), so "no localization" is not representable as an input.
24
25
  */
25
26
  export declare function resolveTargetLocales(args: {
26
27
  target_lng: string | string[];
27
28
  source_lng: string;
28
- knownLocales: Set<string> | null;
29
+ knownLocales: Set<string>;
29
30
  }): ResolvedTargetLocales;
@@ -3,15 +3,16 @@
3
3
  */ /**
4
4
  * Normalize the enqueue `target_lng` input (scalar or array) into the concrete list of target locales
5
5
  * to translate into. Applies, in order: array-coercion, de-duplication (first-seen order preserved),
6
- * source-locale exclusion, and — when the configured locale set is known — dropping unknown locales.
6
+ * source-locale exclusion, and dropping unknown (unconfigured) locales.
7
7
  *
8
8
  * De-dup and unknown-dropping are the manual-enqueue counterparts of the auto-translate policy filter:
9
9
  * the runner only supersedes against already-stored jobs, so duplicates within one enqueue must be
10
10
  * collapsed here; and an unknown locale must never reach the pipeline — it burns a provider call and
11
11
  * either errors on a Postgres locale enum or writes orphaned, invisible data on Mongo/SQLite.
12
12
  *
13
- * @param knownLocales - the configured locale codes, or `null` when localization is disabled/absent
14
- * (then no unknown-dropping is applied every requested locale except the source is kept).
13
+ * @param knownLocales - the configured locale codes. Never null: the caller must reject a
14
+ * localization-less config before reaching here (translating without localization has no valid
15
+ * target and would corrupt data), so "no localization" is not representable as an input.
15
16
  */ export function resolveTargetLocales(args) {
16
17
  const { target_lng, source_lng, knownLocales } = args;
17
18
  const requested = Array.isArray(target_lng) ? target_lng : [
@@ -22,13 +23,6 @@
22
23
  ];
23
24
  const droppedSource = deduped.includes(source_lng);
24
25
  const withoutSource = deduped.filter((target)=>target !== source_lng);
25
- if (!knownLocales) {
26
- return {
27
- targets: withoutSource,
28
- droppedUnknown: [],
29
- droppedSource
30
- };
31
- }
32
26
  const droppedUnknown = withoutSource.filter((target)=>!knownLocales.has(target));
33
27
  const targets = withoutSource.filter((target)=>knownLocales.has(target));
34
28
  return {
@@ -62,6 +62,9 @@ const noop = (value, level, message)=>({
62
62
  if (resolution.status === "not-translatable") {
63
63
  return ServerResponse.success(noop(sourceValue, "info", "Nothing to translate in this field"));
64
64
  }
65
+ if (resolution.status === "excluded") {
66
+ return ServerResponse.success(noop(sourceValue, "info", "This field is excluded from translation"));
67
+ }
65
68
  // No `strategy`/`targetData`: a per-field translate is an explicit "translate this field now",
66
69
  // so `translateContent` always overwrites (its default). skip_existing has no meaning here.
67
70
  const translated = await translateContent({
@@ -6,6 +6,10 @@ import type { FieldLike } from "../../../core/kernel/field-traversal";
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
+ * - `excluded` — resolves to a translatable leaf that opted out via `withFieldTranslation({ exclude })`
10
+ * → caller no-ops. Kept distinct from `not-translatable` so the notice can say *why* (a deliberate
11
+ * opt-out, not a wrong type), and because this route must honor the same exclude the whole-document
12
+ * path honors via `isTranslatableLeaf` — otherwise naming the path directly bypasses the opt-out.
9
13
  * - `inside-blocks` — the path descends through a polymorphic `blocks` field that couldn't be
10
14
  * resolved: no `doc` was supplied to read the element's `blockType` from → caller no-ops.
11
15
  * - `localized-list-ancestor` — the path descends through a `localized` `blocks`/`array` field,
@@ -21,6 +25,8 @@ export type FieldSubtreeResolution = {
21
25
  status: "not-found";
22
26
  } | {
23
27
  status: "not-translatable";
28
+ } | {
29
+ status: "excluded";
24
30
  } | {
25
31
  status: "inside-blocks";
26
32
  } | {
@@ -1,4 +1,4 @@
1
- import { isTranslatableField } from "../../shared";
1
+ import { isFieldExcludedFromTranslation, isTranslatableField } from "../../shared";
2
2
  import { findFieldByPath } from "../../../core/kernel/field-traversal";
3
3
  /**
4
4
  * Map a declared `fieldPath` to the `{ schema, sourceData }` pair `translateContent` expects.
@@ -17,7 +17,13 @@ import { findFieldByPath } from "../../../core/kernel/field-traversal";
17
17
  const result = findFieldByPath(rootFields, segments, doc);
18
18
  switch(result.status){
19
19
  case "leaf":
20
- return isTranslatableField(result.field) ? {
20
+ if (!isTranslatableField(result.field)) return {
21
+ status: "not-translatable"
22
+ };
23
+ if (isFieldExcludedFromTranslation(result.field)) return {
24
+ status: "excluded"
25
+ };
26
+ return {
21
27
  status: "resolved",
22
28
  schema: [
23
29
  result.field
@@ -26,8 +32,6 @@ import { findFieldByPath } from "../../../core/kernel/field-traversal";
26
32
  [result.field.name]: value
27
33
  },
28
34
  fieldName: result.field.name
29
- } : {
30
- status: "not-translatable"
31
35
  };
32
36
  case "container":
33
37
  return {
@@ -1,4 +1,9 @@
1
1
  const endpointKey = (endpoint)=>`${endpoint.method} ${endpoint.path}`;
2
+ /**
3
+ * Identity of a collection admin component for dedup: its slot plus its module path (+ export name).
4
+ * `serverProps` (per-collection data) are excluded, so the same control registered by a level declared
5
+ * twice collapses to one — while genuinely different controls (distinct path or slot) do not.
6
+ */ const componentKey = (slot, component)=>`${slot} ${component.path}#${component.exportName ?? ""}`;
2
7
  function attachToSlot(collection, slot, component) {
3
8
  if (!collection.admin) collection.admin = {};
4
9
  if (!collection.admin.components) collection.admin.components = {};
@@ -92,8 +97,13 @@ function attachToSlot(collection, slot, component) {
92
97
  const managed = new Set(this.collections.map((collection)=>collection.slug));
93
98
  config.collections?.forEach((collection)=>{
94
99
  if (!managed.has(collection.slug)) return;
100
+ const seen = new Set();
95
101
  for (const { slot, make } of this.collectionComponents){
96
- attachToSlot(collection, slot, make(collection));
102
+ const component = make(collection);
103
+ const key = componentKey(slot, component);
104
+ if (seen.has(key)) continue;
105
+ seen.add(key);
106
+ attachToSlot(collection, slot, component);
97
107
  }
98
108
  });
99
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",