diff --git a/apps/dev/src/payload.config.ts b/apps/dev/src/payload.config.ts
index 507cb588..a98d374e 100644
--- a/apps/dev/src/payload.config.ts
+++ b/apps/dev/src/payload.config.ts
@@ -11,6 +11,7 @@ import {
documentLevel,
collectionLevel,
fieldLevel,
+ withAutoTranslate,
} from "@focus-reactive/payload-plugin-translator";
import { analyticsPlugin } from "@focus-reactive/payload-plugin-analytics";
import { seoPlugin } from "@focus-reactive/payload-plugin-seo";
@@ -91,7 +92,13 @@ export default buildConfig({
usernameFieldPath: "name",
}),
translatorPlugin({
- collections: [Pages, Articles, Playground],
+ // Articles is opted in to auto-translate: editing + saving its source-locale (en) content
+ // auto-queues translations into de/fr/es. No drafts on this collection, so every save fires.
+ collections: [
+ Pages,
+ withAutoTranslate(Articles, { targets: ["de", "fr", "es"], debounceMs: 2000 }),
+ withAutoTranslate(Playground, { targets: ["de", "fr", "es"], debounceMs: 2000 }),
+ ],
runner: resolveTranslatorRunner(),
translationProvider: createOpenAIProvider({
apiKey: process.env.OPENAI_API_KEY ?? "",
diff --git a/packages/payload-plugin-translator/README.md b/packages/payload-plugin-translator/README.md
index 98b8b7e7..ec23811b 100644
--- a/packages/payload-plugin-translator/README.md
+++ b/packages/payload-plugin-translator/README.md
@@ -196,6 +196,43 @@ Dismiss acknowledges the drift without re-translating; the marker stays hidden u
changes again. When `provenance` is disabled nothing is shown. Note the fingerprint is text-only, so
formatting-only edits to rich text do not mark a locale stale.
+### Auto-translate on source change
+
+_Since v0.9.0._
+
+Opt in per collection with `withAutoTranslate` and the plugin queues translations automatically when a
+document's source-locale content changes — no manual trigger. Off by default; a collection is enabled
+only by wrapping it.
+
+```ts
+import { translatorPlugin, withAutoTranslate, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
+
+translatorPlugin({
+ collections: [withAutoTranslate(Posts, { targets: ["de", "fr"], debounceMs: 2000 })],
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
+ runner: createPayloadJobsRunner(),
+});
+```
+
+| Option | Type | Default | Meaning |
+| ------ | ---- | ------- | ------- |
+| `targets` | `string[]` | — | Locales to translate into. The source locale is always excluded. |
+| `strategy` | `"overwrite" \| "skip_existing"` | `"overwrite"` | How target content is written. |
+| `debounceMs` | `number` | `0` | Delay before the job runs, coalescing rapid edits (see below). |
+| `sourceLocale` | `string` | `localization.defaultLocale` | Override the source locale for this collection. |
+
+Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection
+without drafts treats every save as published); skips when no translatable content actually changed
+(same fingerprint as stale-detection); coalesces rapid edits via `debounceMs`; the translation is saved
+with the source document's status (published source → published translation); never re-triggers on its
+own translation writes; and never fails the editor's save (best-effort — failures are logged).
+
+> **Requires a working job runner.** Auto-translate only **enqueues** jobs — they run via the task
+> runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as **Vercel**,
+> cron-based autorun may not run automatically, so enqueued translations can sit unexecuted until
+> triggered — e.g. an external cron hitting the run endpoint, or a self-hosted worker. Make sure your
+> deployment actually executes queued jobs before relying on auto-translate.
+
### Lifecycle callbacks
_Since v0.7.0._
diff --git a/packages/payload-plugin-translator/docs/plans/2026-07-15-auto-translate-on-source-change-51.md b/packages/payload-plugin-translator/docs/plans/2026-07-15-auto-translate-on-source-change-51.md
new file mode 100644
index 00000000..300f5437
--- /dev/null
+++ b/packages/payload-plugin-translator/docs/plans/2026-07-15-auto-translate-on-source-change-51.md
@@ -0,0 +1,113 @@
+# Design — auto-translate on source content change (#51)
+
+**Date:** 2026-07-15
+**Status:** designed (forward architecture, multi-candidate + adjudicated); implementation pending approval.
+**Method:** system map → 3 candidate designs (max-reuse · min-new-abstraction · cleanest-boundaries) → adjudication against the invariant checklist. Spine = cleanest-boundaries, with `withAutoTranslate` grafted from max-reuse.
+**Kind:** feature (opt-in) → `feat:` (minor).
+**Depends on:** #47 provenance (landed) · #50 stale detection (landed). Builds on the `configure()` seam reserved by the reshape (`2026-07-14-translate-collection-plugin-reshape.md`, D4).
+
+---
+
+## The ask (ground truth)
+
+An **opt-in** mode that automatically queues translations for configured target locales when a
+document's **source-locale** content changes. Off by default. Configured by the **developer, in code**
+(v1); a future phase adds a manager-facing document-level toggle.
+
+In-scope requirements (each maps to a design element below): **R1** opt-in per-collection config
+(targets, strategy, debounce), off by default · **R2** source-locale change enqueues configured targets ·
+**R3** drift-gate skips unchanged content · **R4** no infinite loops · **R5** serverless-cost aware
+(debounce + drift) · **R6** best-effort (never fails the editor's save) · **R7** the public interface must
+warn that the feature depends on a working job runner / autorun (Vercel/serverless caveat) · **R8** lay a
+foundation for the future document-level manager layer **without** building UI/storage/permissions now.
+
+NFRs: consistency = best-effort; permissions = none in v1; migration = none (no schema); API = additive
+to `src/index.ts`, `@since` minor; performance = R5.
+
+## How it lands on the system
+
+One new module + one `configure()` entry in `plugin.ts` — the seam the reshape reserved. Reuse points:
+- **Config-time module + hook injection:** mirror `src/server/modules/provenance/` — `Provenance.wiring.ts`
+ (`configure(managedSlugs) → ConfigModifier`), `ProvenanceCleanup.hook.ts` (marker idempotency), `Provenance.shapes.ts` (narrow config slice).
+- **Per-collection config via `custom`:** the field level already does exactly this — `withFieldTranslation`
+ (`src/field-config.ts`) + `getFieldTranslationConfig` (`src/core/field-config/getFieldConfig.ts`) with a typed
+ `custom` key. `collection.custom` survives the `schemaMap` clone (`plugin.ts` clones only `.fields`).
+- **Enqueue:** `wireTranslateRunner.ts` builds the request-scoped `TaskRunnerFactory`; `enqueue(TaskInput[])`;
+ `PayloadJobsTaskRunner.enqueue` already supersedes the prior pending job per `(collectionId, targetLng)`.
+- **Drift:** `src/core/content-projection/computeSourceFingerprint.ts`; predicate style sibling `src/core/provenance/staleness.ts` `isRecordStale`.
+- **Pipeline reuse:** auto-enqueued jobs run through the existing `TranslateDocumentHandler` (provenance recording unchanged).
+
+## The design
+
+**Abstractions** (responsibility · invariant · callers):
+- `withAutoTranslate(collection, opts)` — public config writer; stamps `collection.custom[AUTO_TRANSLATE_CUSTOM_KEY]`, returns a new object (no mutation), exactly like `withFieldTranslation`. Callers = each opted-in user collection (the opt-in mechanism itself).
+- `getAutoTranslateConfig(collection)` — payload-free reader over the `custom` slice (sibling of `getFieldTranslationConfig`).
+- `hasSourceContentChanged(prevDoc, nextDoc, schema)` — pure payload-free predicate = `computeSourceFingerprint(prev) !== computeSourceFingerprint(next)`; create (no prev) = changed. Extraction justified by the `isRecordStale` testability norm; 2nd caller = the future R8 catch-up check.
+- `AutoTranslatePolicyResolver: (collectionSlug, doc) => NormalizedAutoTranslatePolicy | null` — the R8 seam; v1 impl reads collection config and **ignores `doc`**; `null` = off. Module-internal (not a plugin-injected dependency, to avoid a zero-caller seam). 2nd impl = the future document-level manager.
+- `configureAutoTranslate(...) → { configure(managedSlugs) → ConfigModifier }` — config-time wiring, mirrors `configureProvenance`.
+- Thin **hook** (`AutoTranslateEnqueue.hook.ts`, best-effort, orchestration only) + pure **policy** (`AutoTranslate.policy.ts`: normalization, `TaskInput` assembly, `waitUntil` math).
+
+**Dependency direction** (all downward, acyclic — verified by the adjudicator):
+`plugin.ts → server/modules/auto-translate/* → { core/auto-translate (predicate), core/auto-translate-config (reader), server/modules/task-runner (TaskInput/factory types), types/ (ConfigModifier, loop-guard key) }`; `core/auto-translate → core/content-projection`; `src/auto-translate-config.ts` (public `withAutoTranslate`) → `core/auto-translate-config`; `server/features/translate-document/handler → types/` (loop-guard key only — **no** import of the auto-translate module). task-runner is a shared infra kernel (sideways-but-acyclic, like `PluginConfigBuilder → task-runner`).
+
+**Placement:**
+- `src/core/auto-translate/hasSourceContentChanged.ts` (+ test) — payload-free.
+- `src/core/auto-translate-config/` — types + `AUTO_TRANSLATE_CUSTOM_KEY` + `getAutoTranslateConfig` (payload-free).
+- `src/auto-translate-config.ts` — `withAutoTranslate` (top-level, imports Payload `Field` type like `field-config.ts`), exported from `src/index.ts`.
+- `src/types/` — the loop-guard `req.context` key constant (leaf, mirrors `ConfigModifier` placement → kills the `features → module` cycle).
+- `src/server/modules/auto-translate/` — `AutoTranslate.shapes.ts` · `AutoTranslate.policy.ts` · `AutoTranslateEnqueue.hook.ts` · `AutoTranslate.wiring.ts` · `index.ts`.
+- `src/server/modules/task-runner/types.ts` (`TaskInput.waitUntil?`) + `PayloadJobsTaskRunner.ts` (passthrough).
+- `src/plugin.ts` — one `configureAutoTranslate(...)` call after `wireTranslateRunner`, one `builder.addConfigModifier(...)`.
+
+**Data flow & ownership (one source of truth each):** per-collection config → `collection.custom[key]` · source locale → `policy.sourceLocale ?? config.localization.defaultLocale` · loop-guard → transient `req.context` flag · debounce → the pending job's `waitUntil` (owned by payload-jobs) · drift baseline → computed on the fly from prev/next, never stored.
+
+## Requirement coverage
+
+| Req | Design element | Status |
+|-----|----------------|--------|
+| R1 | `withAutoTranslate` stamps `custom`; absent = off | met |
+| R2 | `AutoTranslateEnqueue.hook` (afterChange) → policy → `taskRunnerFactory.enqueue` | met |
+| R3 | `hasSourceContentChanged` top-of-hook | met |
+| R4 | source-locale check + `req.context` skip-flag | met |
+| R5 | `waitUntil` debounce + drift-gate | met |
+| R6 | hook in try/catch, log-only, marker idempotency | met |
+| R7 | JSDoc on `withAutoTranslate` + README, point at existing run route | met |
+| R8 | `AutoTranslatePolicyResolver(slug, doc)`, `doc` ignored in v1 | met (foundation) |
+
+## Decisions (ADRs)
+
+- **D0 — who configures = phased hybrid.** v1 developer/code-level; future manager-facing document-level toggle bolts on via D7 without reworking the base. No UI/storage/permissions in v1.
+- **D1 — config surface = `withAutoTranslate(collection, opts)`** (not a plugin-level map). Single source of truth on the collection; config travels with it; the field-level precedent exists. The cleanest-boundaries objection (that user-space + plugin both claim `hooks.afterChange`) was **verified as a misread** — the helper writes only `custom`; hook injection stays in `configure()`.
+- **D2 — module shape = full split** under `server/modules/auto-translate/` (shapes · policy · hook · wiring · index), own narrow `.shapes.ts` (no relocating provenance's shape). Thin best-effort hook + pure testable policy.
+- **D3 — drift-gate = extract** `core/auto-translate/hasSourceContentChanged` (not inline), per the `isRecordStale` testability norm; reusable by R8.
+- **D4 — debounce = `waitUntil` + existing supersession.** Add optional `waitUntil?: Date` to `TaskInput`, pass through in `PayloadJobsTaskRunner.enqueue`; `waitUntil` computed in the pure policy. A fresh edit cancels+replaces the pending delayed job → real debounce. (`waitUntil` confirmed supported in Payload 3.84.1.)
+- **D5 — loop-guard = source-locale check + `req.context` flag** (both). Source = `policy.sourceLocale ?? config.localization.defaultLocale` (zero-config default, optional per-collection override; rejected a mandatory per-collection `sourceLng` as a 2nd source of truth). Flag key lives in `types/`.
+- **D6 — Vercel/job-runner = documentation only.** JSDoc on `withAutoTranslate` + a README "Serverless / Vercel" note pointing at the existing manual run route. No runtime detection.
+- **D7 — foundation = `AutoTranslatePolicyResolver(slug, doc) → policy | null`**, `doc` ignored in v1, kept module-internal. The future manager supplies a 2nd impl; hook/drift/debounce/wiring untouched; `null` already means off.
+- **D8 — trigger = publish-only** *(product decision)*. Auto-translate fires only when the source is published (`_status === "published"`); autosave/draft saves are ignored. Collections without drafts: every save counts (no `_status`). Keeps cost down and avoids autosave storms.
+- **D9 — auto-translated target status = mirror the source** *(product decision)*. The auto path sets `publishOnTranslation` from the source doc's status: published source → published translation. Combined with D8, the source is always published when we fire, so translations publish; consistent for no-drafts collections too. (Overridable later via a per-collection opt.)
+
+## Build sequence (all incremental; off by default, additive, no migration)
+
+1. `core/auto-translate/hasSourceContentChanged.ts` + test (D3) — the drift contract.
+2. `core/auto-translate-config/` (types + key + reader) and `src/auto-translate-config.ts` (`withAutoTranslate`), export from `src/index.ts` (D1) — the config source of truth.
+3. `src/types/` loop-guard context-key constant (D5).
+4. `TaskInput.waitUntil?` + `PayloadJobsTaskRunner.enqueue` passthrough (D4) — **needs regression coverage on the manual enqueue path** (additive-optional; no migration).
+5. `TranslateDocumentHandler.saveTranslatedDocument` sets the loop-guard `context` flag on its `payload.update` (D5).
+6. `server/modules/auto-translate/` (shapes · policy[resolver + task assembly + `waitUntil` + D8 publish-gate + D9 status] · enqueue hook · wiring · index) (D2/D5/D7/D8/D9) — the feature body.
+7. `plugin.ts` — `configureAutoTranslate(...)` after `wireTranslateRunner`, register via `addConfigModifier`; resolve `localization.defaultLocale` in the modifier.
+8. Docs — JSDoc (R7 Vercel warning) on `withAutoTranslate`, README `Since vX.Y.Z`, reference the existing run route.
+
+## Open questions
+
+- **Create vs update:** creating an already-published source doc → enqueue? Default yes (fires on any `afterChange` that passes the D8 publish-gate). Confirm at impl.
+- **`SyncTaskRunner` ignores `waitUntil`** — in dev, debounce is a no-op (immediate translation on each qualifying save). Intended (sync runner = dev); noted.
+- **R7 execution reality (accepted limitation):** on serverless without a running autorun/external trigger, enqueued jobs may sit unexecuted. Not code-resolvable — surfaced via D6 only.
+
+## Public-interface requirement (R7) — verbatim intent
+
+`withAutoTranslate`'s JSDoc and the README must state clearly: auto-translate only *enqueues*; execution
+depends on the job runner's autorun (or an external trigger). On serverless platforms like **Vercel**,
+cron-based autorun may not run, so enqueued translations can remain unexecuted until triggered (e.g. an
+external cron hitting the existing run-translation route, or a self-hosted worker). This is a
+documentation contract, not runtime behaviour.
diff --git a/packages/payload-plugin-translator/docs/plans/2026-07-17-auto-translate-collection-indicator.md b/packages/payload-plugin-translator/docs/plans/2026-07-17-auto-translate-collection-indicator.md
new file mode 100644
index 00000000..ac4b804d
--- /dev/null
+++ b/packages/payload-plugin-translator/docs/plans/2026-07-17-auto-translate-collection-indicator.md
@@ -0,0 +1,141 @@
+# Design — "auto-translate enabled" indicator inside the translation popups (#51 follow-up)
+
+**Date:** 2026-07-17
+**Status:** designed; implementation pending approval.
+**Depends on:** #51 auto-translate (`withAutoTranslate`, on the same unreleased `0.9.0` branch).
+**Kind:** internal UI addition, **no public API change** → ships in the same `0.9.0` as `withAutoTranslate`.
+
+---
+
+## The ask (ground truth)
+
+When a collection is opted into auto-translate (`withAutoTranslate`), the editor should be able to
+**see that it's on** — a static marker (icon + tooltip with a short description), not a progress
+indicator. It must appear **inside the translation popup in both places the popup opens**:
+
+- the **document** popup (`beforeDocumentControls` → `TranslateDocument`), and
+- the **collection / bulk** popup (`beforeListTable` → `BulkTranslationDashboard`).
+
+Out of scope: a list-view chip, a sidebar-nav marker, any document-level toggle (that is the future
+manager layer, #51 D7).
+
+## How it lands on the system
+
+Both popups already share the same shape (`title → section "Translate" → section "Status"`):
+- doc: `src/client/widgets/translate-document/ui/TranslateDocument.tsx:79-107`
+- bulk: `src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx:78-102`
+
+Both are fed by a server boundary that already reads the collection config:
+- `TranslateDocument.server.tsx:23` — has `props.collection` (a `CollectionConfig`), passes `hasDrafts` down.
+- `BulkTranslationDashboard.server.tsx:22` — reads `props.payload.collections[slug]?.config`, passes `hasDrafts` down.
+
+So the shape is: **server boundary resolves the auto-translate config for the collection → passes it
+as a prop → the client popup renders one shared badge, shown only when enabled.**
+
+Reuse points:
+- Tooltip: `src/client/shared/ui/Tooltip/Tooltip.tsx` (`@radix-ui/react-tooltip`, already a dep).
+- Icon: `src/client/shared/lib/assets/icons/LanguageTranslateIcon.tsx` (inline SVG, `currentColor`).
+- Icon+tooltip pattern to copy: `src/client/widgets/translate-field-control/ui/TranslateFieldControl.tsx:134-146`.
+- Config reader: `getAutoTranslateConfig(collection)` (`src/core/auto-translate-config/getAutoTranslateConfig.ts`), reads `collection.custom[AUTO_TRANSLATE_CUSTOM_KEY]`.
+
+## The correctness trap (why this needs a design, not a one-liner)
+
+`withAutoTranslate` stamps the opt-in onto `collection.custom` of the object passed to the **plugin's
+`collections` param**. The **registered** collection (top-level `buildConfig.collections`) can be a
+**different, unwrapped** object — the dev config proves it: `apps/dev/src/payload.config.ts` lists an
+unwrapped `Articles` at the top level and the wrapped one only inside `translatorPlugin({ collections })`.
+
+Both popup server boundaries read the **registered** collection at runtime. So a naive
+`getAutoTranslateConfig(props.collection)` returns `null` there even though auto-translate is on — the
+icon would silently disagree with the actual behaviour. The indicator must derive "enabled" from the
+**same source of truth as the wiring itself**, not from whatever object Payload happened to register.
+
+## The design
+
+**A quiet header marker, two mount points.** One component, rendered by both popups. This is ambient
+config the editor rarely needs, so it does not occupy the popup's main vertical flow (title / Translate
+/ Status) at all — it's a single muted icon pushed to the **right edge of the title row**, with the
+detail in a tooltip. No border, no fill, no extra section (the popup's own sections separate with a plain
+divider; a bordered panel fought that language).
+
+- `src/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.tsx` — `"use client"`,
+ props `{ targets: string[]; sourceLocale: string }`. A muted `AutoTranslateIcon` (a lightning bolt —
+ "fires automatically", distinct from the manual translate glyph) wrapped in `Tooltip`
+ (`@radix-ui/react-tooltip`), `tabIndex={0}` + `aria-label` for keyboard/AT. Tooltip copy states the
+ source, the targets (locale codes), and the trigger. Lives under `entities/translation`.
+- The popups wrap their `
` title in a `.header` flex row (`space-between`) so the marker sits at the
+ right edge without pushing the flow.
+
+**Data reaches the badge via the registered collection's `custom` — made reliable by propagation.**
+The auto-translate module's `configure()` ConfigModifier already injects the `afterChange` hook onto
+each enabled+managed collection (`AutoTranslate.wiring.ts:44-51`). It will **also stamp the normalized
+opt-in onto that same registered collection's `custom[AUTO_TRANSLATE_CUSTOM_KEY]`**, so at runtime both
+server boundaries can uniformly call `getAutoTranslateConfig(payload.collections[slug].config)` and get
+the truth. Propagation is idempotent (re-stamping the same value is a no-op) and additive (behaviour
+wiring still reads from the plugin param, unchanged).
+
+**Placement in the popup:** a thin row directly under the popup title, above the "Translate" section,
+rendered only when enabled. Same in both popups so the two surfaces read as one system.
+
+## Decisions (ADRs)
+
+- **D1 — one shared component, not two.** Both popups render the same `AutoTranslateMarker`. Identical
+ UX, single place to change. Rejected duplicating a marker per popup.
+- **D2 — data source = propagate the opt-in onto the registered collection's `custom`** (in the
+ auto-translate `ConfigModifier`), then read it at runtime via `getAutoTranslateConfig`. This unifies
+ the source for both server boundaries and any future runtime reader. *Alternative considered:* thread
+ a `Map` through the `LevelContext` into each component's serverProps — rejected: more
+ plumbing (ctx + two levels + two export classes), and it doesn't help the bulk boundary, which reads
+ `payload.collections` at runtime rather than at config time.
+- **D3 — a quiet header marker, out of the main flow** (revised twice against review). The detail is
+ ambient config the editor rarely needs, so it belongs in a tooltip on a muted icon at the right edge
+ of the title row — not in the vertical flow. Uses a dedicated `AutoTranslateIcon` (a lightning bolt =
+ "automatic"; chosen over the language glyph, which reads as manual translate, and over sync-arrows,
+ which read as a manual refresh action — compared as rendered candidates at real 14px size); static, no
+ fill/border. *Rejected iteration 1:* an inline icon+label row between title and form (read as an
+ unlabelled extra section). *Rejected iteration 2:* a bordered/tinted "Automation" panel (the border +
+ fill fought the popup's divider-only section language, and it kept always-on detail in the flow that
+ isn't needed there). If a second automation setting appears later, revisit a grouped surface then —
+ don't build the container for a single fact now.
+- **D4 — internal only, no public API.** No new `src/index.ts` export; `withAutoTranslate` stays the
+ only public surface (`@since 0.9.0`). The badge ships inside the same unreleased `0.9.0`. Commit type
+ `feat(translator): …` (folds into the pending 0.9.0).
+- **D5 — tooltip copy (UX):** state what's on, the source, the targets, and the trigger, concisely —
+ e.g. *"Auto-translate is on for this collection. Publishing changes to the source (`en`) content
+ automatically queues translations into de · fr · es."* Source/targets come from the config; keep it
+ one or two sentences. (No serverless caveat in the tooltip — that lives in `withAutoTranslate` JSDoc/README.)
+
+## Build sequence
+
+1. **Propagation (D2):** in `server/modules/auto-translate/AutoTranslate.wiring.ts` (or its hook-inject
+ helper), stamp `custom[AUTO_TRANSLATE_CUSTOM_KEY]` onto each enabled+managed registered collection in
+ the `ConfigModifier`. + unit test asserting the registered collection carries the config after init,
+ even when only the plugin-param object was wrapped (the dev scenario).
+2. **Shared marker:** `entities/translation/ui/AutoTranslateMarker/` (component + styles + barrel). A pure
+ `resolveAutoTranslateSummary` helper (`client/shared/lib/autoTranslate/`) turns a collection + default
+ locale into `{ targets, sourceLocale } | null` — unit-tested (`.test.ts`). No render test: the package
+ has no client test infra (node env, `*.test.ts` only), so client UI is covered by type-check + manual.
+3. **Doc popup:** `TranslateDocument.server.tsx` resolves the summary and passes
+ `autoTranslate={{ targets, sourceLocale }}` (or `null`); `TranslateDocument.tsx` renders the panel
+ under the title when present.
+4. **Bulk popup:** same in `BulkTranslationDashboard.server.tsx` + `BulkTranslationDashboard.tsx`.
+5. **Verify:** check-types + tests + lint; then manual check in `apps/dev` (Articles/Playground are
+ already wrapped) — badge visible in both popups, tooltip lists de/fr/es, source `en`.
+
+## Requirement coverage
+
+| Req | Design element | Status |
+|-----|----------------|--------|
+| Marker visible when auto-translate is on | `AutoTranslateMarker`, conditional on config | met |
+| In the document popup | rendered in `TranslateDocument.tsx` header row | met |
+| In the collection popup | rendered in `BulkTranslationDashboard.tsx` header row | met |
+| Icon + description | muted `LanguageTranslateIcon` + tooltip copy (D5) | met |
+| Marker agrees with real behaviour | D2 propagation → single runtime source | met |
+| No public API / version churn | internal only, ships in 0.9.0 | met |
+
+## Open questions
+
+- **Badge label vs. icon-only:** show a short text label next to the icon, or icon-only with everything
+ in the tooltip? Leaning icon + short "Auto-translate" label for discoverability; confirm at impl.
+- **Targets locale labels:** show locale **codes** (`de`) or Payload locale **labels** (`Deutsch`)?
+ Codes are simplest and match the rest of the plugin UI; revisit if labels are wanted.
diff --git a/packages/payload-plugin-translator/src/auto-translate-config.test.ts b/packages/payload-plugin-translator/src/auto-translate-config.test.ts
new file mode 100644
index 00000000..e63b2222
--- /dev/null
+++ b/packages/payload-plugin-translator/src/auto-translate-config.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from "vitest";
+import type { CollectionConfig } from "payload";
+
+import { getAutoTranslateConfig } from "./core/auto-translate-config";
+import { withAutoTranslate } from "./auto-translate-config";
+
+const collection = (over: Partial = {}): CollectionConfig =>
+ ({ slug: "posts", fields: [], ...over }) as CollectionConfig;
+
+describe("withAutoTranslate / getAutoTranslateConfig round-trip", () => {
+ it("stamps config on custom and reads it back", () => {
+ const configured = withAutoTranslate(collection(), { targets: ["de", "fr"], debounceMs: 2000 });
+ expect(getAutoTranslateConfig(configured)).toEqual({ targets: ["de", "fr"], debounceMs: 2000 });
+ });
+
+ it("returns null when the collection is not opted in", () => {
+ expect(getAutoTranslateConfig(collection())).toBeNull();
+ expect(getAutoTranslateConfig({})).toBeNull();
+ });
+
+ it("does not mutate the input collection", () => {
+ const input = collection();
+ withAutoTranslate(input, { targets: ["de"] });
+ expect(input.custom).toBeUndefined();
+ });
+
+ it("preserves existing custom keys", () => {
+ const configured = withAutoTranslate(collection({ custom: { other: 1 } }), {
+ targets: ["de"],
+ });
+ expect((configured.custom as Record).other).toBe(1);
+ });
+});
diff --git a/packages/payload-plugin-translator/src/auto-translate-config.ts b/packages/payload-plugin-translator/src/auto-translate-config.ts
new file mode 100644
index 00000000..cade4e84
--- /dev/null
+++ b/packages/payload-plugin-translator/src/auto-translate-config.ts
@@ -0,0 +1,55 @@
+import type { CollectionConfig } from "payload";
+
+import type { AutoTranslateConfig } from "./core/auto-translate-config";
+import { AUTO_TRANSLATE_CUSTOM_KEY } from "./core/auto-translate-config";
+
+export type { AutoTranslateConfig };
+export type { AutoTranslateStrategy } from "./core/auto-translate-config";
+
+/**
+ * Enable opt-in **auto-translate** for a collection: when a document's source-locale content changes
+ * (and is published), the plugin automatically queues translations into the configured target locales.
+ * Off by default — a collection is opted in only by wrapping it with this helper. The rule is stamped
+ * onto `collection.custom` (the input is not mutated; a new collection is returned), mirroring
+ * `withFieldTranslation` at the field level.
+ *
+ * Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection
+ * without drafts treats every save as published); skips when no translatable content actually changed
+ * (drift-gate); coalesces rapid edits via `debounceMs`; never re-triggers on its own translation
+ * writes; and never fails the editor's save (best-effort).
+ *
+ * **Requires a working job runner.** Auto-translate only ENQUEUES jobs — they run via the plugin's
+ * task runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as
+ * **Vercel**, cron-based autorun may not run automatically, so enqueued translations can sit
+ * unexecuted until triggered — e.g. an external cron hitting the run-translation endpoint, or a
+ * self-hosted worker. Ensure your deployment actually executes queued jobs before relying on this.
+ *
+ * @param collection - The collection to opt in.
+ * @param config - Target locales (+ optional strategy, debounce, source-locale override).
+ * @returns A new collection with the auto-translate rule applied (the input is not mutated).
+ *
+ * @example
+ * ```ts
+ * import { withAutoTranslate } from '@focus-reactive/payload-plugin-translator'
+ *
+ * translatorPlugin({
+ * collections: [withAutoTranslate(Posts, { targets: ['de', 'fr'], debounceMs: 2000 })],
+ * translationProvider,
+ * runner,
+ * })
+ * ```
+ *
+ * @since 0.9.0
+ */
+export function withAutoTranslate(
+ collection: CollectionConfig,
+ config: AutoTranslateConfig
+): CollectionConfig {
+ return {
+ ...collection,
+ custom: {
+ ...(collection.custom ?? {}),
+ [AUTO_TRANSLATE_CUSTOM_KEY]: config,
+ },
+ };
+}
diff --git a/packages/payload-plugin-translator/src/client/entities/translation/index.ts b/packages/payload-plugin-translator/src/client/entities/translation/index.ts
index 8a8374d2..873fc5d5 100644
--- a/packages/payload-plugin-translator/src/client/entities/translation/index.ts
+++ b/packages/payload-plugin-translator/src/client/entities/translation/index.ts
@@ -8,6 +8,7 @@ import { useDocumentStaleness } from "./api/queries/useDocumentStaleness";
import { useDocumentTranslation } from "./api/queries/useDocumentTranslation";
export { TranslationStatusList } from "./ui/TranslationStatusList";
+export { AutoTranslateMarker } from "./ui/AutoTranslateMarker";
export const TranslationsApi = {
useRunDocumentTranslation,
diff --git a/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.tsx b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.tsx
new file mode 100644
index 00000000..df43baf1
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { AutoTranslateIcon } from "../../../../shared/lib/assets/icons/AutoTranslateIcon";
+import Tooltip from "../../../../shared/ui/Tooltip";
+
+import styles from "./styles.module.scss";
+
+type AutoTranslateMarkerProps = {
+ /** Target locale codes this collection auto-translates into (source already excluded). */
+ targets: string[];
+ /** The resolved source locale code changes are watched on. */
+ sourceLocale: string;
+};
+
+/**
+ * A quiet, off-to-the-side marker in the translation popups' header (document + collection): a single
+ * muted icon that a collection is opted into auto-translate. The detail lives in the tooltip, not the
+ * layout — this is ambient config the editor rarely needs, so it stays out of the popup's main vertical
+ * flow (title / Translate / Status). Rendered only when auto-translate is enabled.
+ */
+export function AutoTranslateMarker({ targets, sourceLocale }: AutoTranslateMarkerProps) {
+ return (
+
+ Auto-translate is on. Publishing changes to the source ({sourceLocale})
+ content queues translations into {targets.join(" · ")}.
+
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/index.ts b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/index.ts
new file mode 100644
index 00000000..f5ec32b2
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/index.ts
@@ -0,0 +1 @@
+export { AutoTranslateMarker } from "./AutoTranslateMarker";
diff --git a/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/styles.module.scss b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/styles.module.scss
new file mode 100644
index 00000000..daa1a4ec
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/entities/translation/ui/AutoTranslateMarker/styles.module.scss
@@ -0,0 +1,30 @@
+// A muted, low-emphasis header marker — no border, no fill; it sits at the edge of the title row and
+// carries all detail in its tooltip, so it never competes with the popup's main flow.
+.marker {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ color: var(--theme-elevation-400, #a5a5a5);
+ cursor: help;
+ outline: none;
+}
+
+.marker:hover {
+ color: var(--theme-elevation-600, #6d6d6d);
+}
+
+.marker:focus-visible {
+ outline: 2px solid var(--theme-elevation-400, #a5a5a5);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+
+.tip {
+ display: block;
+ max-width: 15rem;
+ line-height: 1.35;
+}
+
+.tip code {
+ font-family: var(--font-mono, ui-monospace, monospace);
+}
diff --git a/packages/payload-plugin-translator/src/client/shared/lib/assets/icons/AutoTranslateIcon.tsx b/packages/payload-plugin-translator/src/client/shared/lib/assets/icons/AutoTranslateIcon.tsx
new file mode 100644
index 00000000..2d2de5b4
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/shared/lib/assets/icons/AutoTranslateIcon.tsx
@@ -0,0 +1,13 @@
+/** A lightning bolt — connotes "fires automatically" (on the publish event). Used by the
+ * auto-translate marker to distinguish automatic translation from the manual translate control. */
+export const AutoTranslateIcon = () => (
+
+);
diff --git a/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.test.ts b/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.test.ts
new file mode 100644
index 00000000..98e65a33
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+
+import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../../core/auto-translate-config";
+import type { AutoTranslateConfig } from "../../../../core/auto-translate-config";
+
+import { resolveAutoTranslateSummary } from "./resolveAutoTranslateSummary";
+
+const withConfig = (config: AutoTranslateConfig) => ({
+ custom: { [AUTO_TRANSLATE_CUSTOM_KEY]: config },
+});
+
+describe("resolveAutoTranslateSummary", () => {
+ it("returns null for a collection that is not opted in", () => {
+ expect(resolveAutoTranslateSummary({ custom: {} }, "en")).toBeNull();
+ expect(resolveAutoTranslateSummary(undefined, "en")).toBeNull();
+ });
+
+ it("resolves the source from the default locale and excludes it from the targets", () => {
+ const result = resolveAutoTranslateSummary(withConfig({ targets: ["en", "de", "fr"] }), "en");
+ expect(result).toEqual({ targets: ["de", "fr"], sourceLocale: "en" });
+ });
+
+ it("honours a per-collection sourceLocale override", () => {
+ const result = resolveAutoTranslateSummary(
+ withConfig({ targets: ["en", "de"], sourceLocale: "de" }),
+ "en"
+ );
+ expect(result).toEqual({ targets: ["en"], sourceLocale: "de" });
+ });
+
+ it("returns null when no source locale is resolvable", () => {
+ expect(resolveAutoTranslateSummary(withConfig({ targets: ["de"] }), undefined)).toBeNull();
+ });
+
+ it("returns null when the only target is the source (nothing left to show)", () => {
+ expect(resolveAutoTranslateSummary(withConfig({ targets: ["en"] }), "en")).toBeNull();
+ });
+});
diff --git a/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.ts b/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.ts
new file mode 100644
index 00000000..c1f7e7ed
--- /dev/null
+++ b/packages/payload-plugin-translator/src/client/shared/lib/autoTranslate/resolveAutoTranslateSummary.ts
@@ -0,0 +1,25 @@
+import { getAutoTranslateConfig } from "../../../../core/auto-translate-config";
+
+/** The auto-translate facts the Automation panel renders — resolved from a collection's opt-in config. */
+export type AutoTranslateSummary = { targets: string[]; sourceLocale: string };
+
+/**
+ * Resolve a collection's auto-translate summary for the Automation panel, or `null` when it should not
+ * render. Reads the opt-in from the collection's `custom` (propagated onto the registered collection at
+ * init), resolves the source locale (per-collection override else the config default), and drops the
+ * source from the displayed targets. Returns `null` when off, when no source locale is resolvable, or
+ * when no target remains — so the caller renders the panel only when it is both enabled and meaningful.
+ */
+export function resolveAutoTranslateSummary(
+ collection: { custom?: Record } | undefined,
+ defaultLocale: string | undefined
+): AutoTranslateSummary | null {
+ if (!collection) return null;
+ const config = getAutoTranslateConfig(collection);
+ if (!config) return null;
+ const sourceLocale = config.sourceLocale ?? defaultLocale;
+ if (!sourceLocale) return null;
+ const targets = config.targets.filter((target) => target !== sourceLocale);
+ if (targets.length === 0) return null;
+ return { targets, sourceLocale };
+}
diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx
index d377e2f8..55b157fa 100644
--- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx
+++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx
@@ -3,6 +3,7 @@ import type { BeforeListTableServerProps } from "payload";
import type { AccessGuard } from "../../../../types/AccessGuard";
import { collectionHasDrafts } from "../../../../server/shared/guards";
+import { resolveAutoTranslateSummary } from "../../../shared/lib/autoTranslate/resolveAutoTranslateSummary";
import BulkTranslationDashboard from "./BulkTranslationDashboard";
@@ -21,8 +22,12 @@ const BulkTranslationDashboardServer = async (props: BulkTranslationDashboardSer
const collection = props.payload.collections[props.collectionSlug]?.config;
const hasDrafts = collection ? collectionHasDrafts(collection) : false;
+ const autoTranslate = resolveAutoTranslateSummary(
+ collection,
+ props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined
+ );
- return ;
+ return ;
};
export default BulkTranslationDashboardServer;
diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx
index 69e559b7..9ff980b2 100644
--- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx
+++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx
@@ -4,7 +4,12 @@ import { toast, useLocale, useSelection } from "@payloadcms/ui";
import { SelectAllStatus } from "@payloadcms/ui/providers/Selection";
import { useEffect, useMemo } from "react";
-import { deriveCollectionPanelStatus, TranslationsApi } from "../../../entities/translation";
+import {
+ AutoTranslateMarker,
+ deriveCollectionPanelStatus,
+ TranslationsApi,
+} from "../../../entities/translation";
+import type { AutoTranslateSummary } from "../../../shared/lib/autoTranslate/resolveAutoTranslateSummary";
import {
CollectionTranslationForm,
FORM_FIELDS,
@@ -20,9 +25,13 @@ import styles from "./styles.module.scss";
type BulkTranslationDashboardProps = {
hasDrafts: boolean;
+ autoTranslate: AutoTranslateSummary | null;
};
-export default function BulkTranslationDashboard({ hasDrafts }: BulkTranslationDashboardProps) {
+export default function BulkTranslationDashboard({
+ hasDrafts,
+ autoTranslate,
+}: BulkTranslationDashboardProps) {
const locale = useLocale();
const { collection } = useCollectionDashboardUrlParams();
const documentsSelection = useSelection();
@@ -77,7 +86,15 @@ export default function BulkTranslationDashboard({ hasDrafts }: BulkTranslationD
return (
-
Bulk translation
+
+
Bulk translation
+ {autoTranslate && (
+
+ )}
+
Translate
diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/styles.module.scss b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/styles.module.scss
index f45e0975..53f50bff 100644
--- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/styles.module.scss
+++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/styles.module.scss
@@ -1,3 +1,12 @@
+// Title row: the section heading on the left, ambient markers (e.g. auto-translate) pushed to the
+// right edge so they stay out of the popup's main vertical flow.
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
.title {
margin: 0;
font-size: 0.8125rem;
diff --git a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.server.tsx b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.server.tsx
index 89749c6a..97d7686c 100644
--- a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.server.tsx
+++ b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.server.tsx
@@ -3,6 +3,7 @@ import type { BeforeDocumentControlsServerProps, CollectionConfig } from "payloa
import type { AccessGuard } from "../../../../types/AccessGuard";
import { collectionHasDrafts } from "../../../../server/shared/guards";
+import { resolveAutoTranslateSummary } from "../../../shared/lib/autoTranslate/resolveAutoTranslateSummary";
import TranslateDocument from "./TranslateDocument";
@@ -21,8 +22,12 @@ async function TranslateDocumentServer(props: TranslateDocumentServerProps) {
if (!props.id) return null;
const hasDrafts = collectionHasDrafts(props.collection);
+ const autoTranslate = resolveAutoTranslateSummary(
+ props.collection,
+ props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined
+ );
- return ;
+ return ;
}
export default TranslateDocumentServer;
diff --git a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.tsx b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.tsx
index d2f62cfb..ad1ce1f6 100644
--- a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.tsx
+++ b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/TranslateDocument.tsx
@@ -4,12 +4,14 @@ import { toast, useLocale } from "@payloadcms/ui";
import { useEffect, useMemo } from "react";
import {
+ AutoTranslateMarker,
buildTranslationStatusRows,
deriveDocumentRunStatus,
derivePanelStatus,
TranslationsApi,
TranslationStatusList,
} from "../../../entities/translation";
+import type { AutoTranslateSummary } from "../../../shared/lib/autoTranslate/resolveAutoTranslateSummary";
import { OpenDocumentTranslationPopup } from "../../../features/open-document-translation-popup";
import { DocumentTranslationForm, FORM_FIELDS } from "../../../features/translate-document-form";
import type { FormValues } from "../../../features/translate-document-form";
@@ -21,9 +23,10 @@ import styles from "./styles.module.scss";
type TranslateDocumentProps = {
hasDrafts: boolean;
+ autoTranslate: AutoTranslateSummary | null;
};
-const TranslateDocument = ({ hasDrafts }: TranslateDocumentProps) => {
+const TranslateDocument = ({ hasDrafts, autoTranslate }: TranslateDocumentProps) => {
const locale = useLocale();
const params = useCollectionDocumentUrlParams();
@@ -80,7 +83,15 @@ const TranslateDocument = ({ hasDrafts }: TranslateDocumentProps) => {
{({ close }) => (
<>
-
Document translation
+
+
Document translation
+ {autoTranslate && (
+
+ )}
+
Translate
diff --git a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/styles.module.scss b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/styles.module.scss
index f45e0975..53f50bff 100644
--- a/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/styles.module.scss
+++ b/packages/payload-plugin-translator/src/client/widgets/translate-document/ui/styles.module.scss
@@ -1,3 +1,12 @@
+// Title row: the section heading on the left, ambient markers (e.g. auto-translate) pushed to the
+// right edge so they stay out of the popup's main vertical flow.
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
.title {
margin: 0;
font-size: 0.8125rem;
diff --git a/packages/payload-plugin-translator/src/core/auto-translate-config/getAutoTranslateConfig.ts b/packages/payload-plugin-translator/src/core/auto-translate-config/getAutoTranslateConfig.ts
new file mode 100644
index 00000000..75385fbe
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate-config/getAutoTranslateConfig.ts
@@ -0,0 +1,20 @@
+import type { AutoTranslateConfig } from "./types";
+import { AUTO_TRANSLATE_CUSTOM_KEY } from "./types";
+
+/**
+ * Read a collection's auto-translate rule from its `custom` bag, or `null` when the collection is not
+ * opted in. Payload-free (only the `custom` extension point is read), mirroring
+ * `getFieldTranslationConfig` at the field level.
+ *
+ * @param collection - The collection to read (only its `custom` extension point is inspected).
+ * @returns The auto-translate config, or `null` when absent/malformed.
+ * @since 0.9.0
+ */
+export function getAutoTranslateConfig(collection: {
+ custom?: Record;
+}): AutoTranslateConfig | null {
+ if (!collection.custom || typeof collection.custom !== "object") return null;
+ const config = (collection.custom as Record)[AUTO_TRANSLATE_CUSTOM_KEY];
+ if (!config || typeof config !== "object") return null;
+ return config as AutoTranslateConfig;
+}
diff --git a/packages/payload-plugin-translator/src/core/auto-translate-config/index.ts b/packages/payload-plugin-translator/src/core/auto-translate-config/index.ts
new file mode 100644
index 00000000..a6a5ac75
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate-config/index.ts
@@ -0,0 +1,3 @@
+export { getAutoTranslateConfig } from "./getAutoTranslateConfig";
+export { AUTO_TRANSLATE_CUSTOM_KEY } from "./types";
+export type { AutoTranslateConfig, AutoTranslateStrategy } from "./types";
diff --git a/packages/payload-plugin-translator/src/core/auto-translate-config/types.ts b/packages/payload-plugin-translator/src/core/auto-translate-config/types.ts
new file mode 100644
index 00000000..d0595550
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate-config/types.ts
@@ -0,0 +1,31 @@
+/**
+ * Key under a collection's `custom` bag where its auto-translate rule is stamped by `withAutoTranslate`
+ * and read back by {@link getAutoTranslateConfig}. Mirrors the field-level `TRANSLATE_KIT_CUSTOM_KEY`
+ * pattern — a single typed key so writer and reader never diverge.
+ */
+export const AUTO_TRANSLATE_CUSTOM_KEY = "translatorAutoTranslate";
+
+/**
+ * Translation strategy for auto-enqueued jobs (matches the task-runner's strategy union).
+ * @since 0.9.0
+ */
+export type AutoTranslateStrategy = "overwrite" | "skip_existing";
+
+/**
+ * A collection's opt-in auto-translate rule (developer-configured, v1). Presence of this config on a
+ * collection = opted in; absence = off (the default).
+ * @since 0.9.0
+ */
+export type AutoTranslateConfig = {
+ /** Target locales to translate into when the source changes. The source locale is always excluded. */
+ targets: string[];
+ /** Translation strategy; defaults to `"overwrite"`. */
+ strategy?: AutoTranslateStrategy;
+ /**
+ * Delay (ms) before the queued job runs, coalescing rapid edits via the job runner's
+ * per-(document, locale) supersession. `0`/omitted = enqueue immediately.
+ */
+ debounceMs?: number;
+ /** Override the source locale for this collection; defaults to `localization.defaultLocale`. */
+ sourceLocale?: string;
+};
diff --git a/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.test.ts b/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.test.ts
new file mode 100644
index 00000000..41b5edc6
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+
+import type { FieldLike } from "../field-traversal";
+
+import { hasSourceContentChanged } from "./hasSourceContentChanged";
+
+describe("hasSourceContentChanged", () => {
+ const schema: FieldLike[] = [{ name: "title", type: "text", localized: true }];
+
+ it("true when translatable content changed", () => {
+ expect(hasSourceContentChanged({ title: "A" }, { title: "B" }, schema)).toBe(true);
+ });
+
+ it("false when only non-translatable fields changed", () => {
+ expect(
+ hasSourceContentChanged({ title: "A", views: 1 }, { title: "A", views: 2 }, schema)
+ ).toBe(false);
+ });
+
+ it("true on create (no previousDoc)", () => {
+ expect(hasSourceContentChanged(undefined, { title: "A" }, schema)).toBe(true);
+ expect(hasSourceContentChanged(null, { title: "A" }, schema)).toBe(true);
+ });
+});
diff --git a/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.ts b/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.ts
new file mode 100644
index 00000000..29046647
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate/hasSourceContentChanged.ts
@@ -0,0 +1,32 @@
+import { computeSourceFingerprint } from "../content-projection/computeSourceFingerprint";
+import type { FieldLike } from "../field-traversal";
+
+/**
+ * Whether a source-locale save actually changed translatable content — the auto-translate drift-gate,
+ * in one place. Compares the translatable-content fingerprint of the previous vs the current document
+ * (the same hash the provenance write/read path uses via {@link computeSourceFingerprint}), so a save
+ * that touched only non-translatable fields never triggers a re-translation.
+ *
+ * A create (no `previousDoc`) counts as changed: there is nothing to diff against, and the new
+ * document's translatable content is by definition not yet translated.
+ *
+ * Pure and payload-free (sibling of `core/provenance` `isRecordStale`) so the gate is testable without
+ * a database; the server hook supplies the two documents Payload already hands to `afterChange` plus
+ * the original field schema.
+ *
+ * @param previousDoc - The source document before this save, or `null`/`undefined` on create.
+ * @param nextDoc - The source document after this save.
+ * @param schema - The ORIGINAL (un-sanitized) field schema for the collection.
+ * @returns `true` when translatable content changed (or on create); `false` when unchanged.
+ * @since 0.9.0
+ */
+export function hasSourceContentChanged(
+ previousDoc: Record | null | undefined,
+ nextDoc: Record,
+ schema: FieldLike[]
+): boolean {
+ if (!previousDoc) return true;
+ return (
+ computeSourceFingerprint(previousDoc, schema) !== computeSourceFingerprint(nextDoc, schema)
+ );
+}
diff --git a/packages/payload-plugin-translator/src/core/auto-translate/index.ts b/packages/payload-plugin-translator/src/core/auto-translate/index.ts
new file mode 100644
index 00000000..e665a892
--- /dev/null
+++ b/packages/payload-plugin-translator/src/core/auto-translate/index.ts
@@ -0,0 +1 @@
+export { hasSourceContentChanged } from "./hasSourceContentChanged";
diff --git a/packages/payload-plugin-translator/src/index.ts b/packages/payload-plugin-translator/src/index.ts
index 56df66a0..2b250c2b 100644
--- a/packages/payload-plugin-translator/src/index.ts
+++ b/packages/payload-plugin-translator/src/index.ts
@@ -30,6 +30,12 @@ export type { TranslationLevel } from "./server/modules/translation-levels";
export { withFieldTranslation } from "./field-config";
export type { FieldTranslationConfig } from "./field-config";
+// Auto-translate — opt-in, per-collection auto-translation on source-locale change (#51). Wrap a
+// collection with `withAutoTranslate`; requires a working job runner/autorun to execute (see the
+// JSDoc for the Vercel/serverless caveat). Since v0.9.0.
+export { withAutoTranslate } from "./auto-translate-config";
+export type { AutoTranslateConfig, AutoTranslateStrategy } from "./auto-translate-config";
+
// Deprecated exports (for backwards compatibility)
export { createTranslatePlugin, TranslateCollectionPlugin } from "./plugin";
export type { TranslateCollectionPluginConfig } from "./plugin";
diff --git a/packages/payload-plugin-translator/src/plugin.test.ts b/packages/payload-plugin-translator/src/plugin.test.ts
index 3c3225ad..89d05e6b 100644
--- a/packages/payload-plugin-translator/src/plugin.test.ts
+++ b/packages/payload-plugin-translator/src/plugin.test.ts
@@ -3,6 +3,7 @@ import type { Config, Payload } from "payload";
import { translatorPlugin } from "./plugin";
import type { TranslatorPluginConfig } from "./plugin";
+import { withAutoTranslate } from "./auto-translate-config";
import { documentLevel, fieldLevel } from "./composition/levels";
import { TranslateDocumentExport } from "./client/widgets/translate-document";
import { BulkDocumentTranslationDashboard } from "./client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export";
@@ -260,6 +261,59 @@ describe("translatorPlugin — provenance (opt-in)", () => {
});
});
+const markedAfterChange = (col: Record | undefined) =>
+ (col?.hooks?.afterChange ?? []).filter(
+ (h: { __translatorAutoTranslate?: boolean }) => h.__translatorAutoTranslate === true
+ );
+
+describe("translatorPlugin — auto-translate (opt-in wiring)", () => {
+ // Run the plugin end-to-end with a caller-supplied collection set (same object used for the
+ // incoming config, mirroring how buildConfig passes collections), returning the built result.
+ const runWith = async (collections: unknown[]) => {
+ const pluginConfig = {
+ collections,
+ translationProvider: { translate: vi.fn() },
+ runner: makeRunner(),
+ } as unknown as TranslatorPluginConfig;
+ return translatorPlugin(pluginConfig)({ collections } as unknown as Config);
+ };
+
+ it("attaches exactly one marked afterChange hook to a collection wrapped with withAutoTranslate", async () => {
+ const posts = withAutoTranslate(makeCollection() as never, { targets: ["de", "fr"] });
+ const result = await runWith([posts]);
+ const built = result.collections?.find((c) => c.slug === "posts") as Record;
+ expect(markedAfterChange(built)).toHaveLength(1);
+ });
+
+ it("does not attach the hook to a collection that was not wrapped", async () => {
+ const result = await runWith([makeCollection()]);
+ const built = result.collections?.find((c) => c.slug === "posts") as Record;
+ expect(markedAfterChange(built)).toHaveLength(0);
+ });
+
+ it("does not attach the hook to any collection when none opted in", async () => {
+ const result = await runWith([makeCollection()]);
+ for (const c of result.collections ?? []) {
+ expect(markedAfterChange(c as Record)).toHaveLength(0);
+ }
+ });
+
+ it("does not stack duplicate hooks when the same config is run through the plugin twice", async () => {
+ const posts = withAutoTranslate(makeCollection() as never, { targets: ["de"] });
+ const pluginConfig = {
+ collections: [posts],
+ translationProvider: { translate: vi.fn() },
+ runner: makeRunner(),
+ } as unknown as TranslatorPluginConfig;
+ const once = await translatorPlugin(pluginConfig)({
+ collections: [posts],
+ } as unknown as Config);
+ const twice = await translatorPlugin(pluginConfig)(once);
+ const built = twice.collections?.find((c) => c.slug === "posts") as Record;
+ expect(markedAfterChange(built)).toHaveLength(1);
+ });
+});
+
describe("translatorPlugin — lifecycle callbacks", () => {
// The runner's execution handler is what the plugin wraps for completed/failed. It is handed to
// `runner.configure(context)` at init, so we capture it from the configure mock and invoke it.
diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts
index 9807bc21..737c3506 100644
--- a/packages/payload-plugin-translator/src/plugin.ts
+++ b/packages/payload-plugin-translator/src/plugin.ts
@@ -1,6 +1,7 @@
import type { CollectionConfig, Config } from "payload";
import { CacheProviderExport } from "./client/app/cache/CacheProvider.export";
+import { configureAutoTranslate } from "./server/modules/auto-translate";
import { configureProvenance } from "./server/modules/provenance";
import type { AccessGuard } from "./types/AccessGuard";
import type { TranslationProvider } from "./core/translation-providers";
@@ -129,6 +130,9 @@ export class TranslateCollectionPlugin {
lifecycle: lifecycle ?? {},
collections: Array.from(collectionSlugs),
});
+ // Auto-translate (#51) reads its opt-in from each collection's `custom` (via `withAutoTranslate`);
+ // needs the runner's factory, so it wires after `wireTranslateRunner`.
+ const autoTranslateModule = configureAutoTranslate(collections, schemaMap, taskRunnerFactory);
const activeLevels = levels ?? [documentLevel(), collectionLevel()];
const builder = new PluginConfigBuilder({
@@ -144,6 +148,7 @@ export class TranslateCollectionPlugin {
builder.addConfigModifier(runnerConfigModifier);
builder.addConfigModifier(provenanceModule.configure(collectionSlugs));
+ builder.addConfigModifier(autoTranslateModule.configure(collectionSlugs));
builder.addAdminProvider(new CacheProviderExport(basePath));
// The single place the Payload config is mutated.
diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/handler.test.ts
index d3f868a5..69c93c4a 100644
--- a/packages/payload-plugin-translator/src/server/features/translate-document/handler.test.ts
+++ b/packages/payload-plugin-translator/src/server/features/translate-document/handler.test.ts
@@ -4,6 +4,7 @@ import { APIError } from "payload";
import { TranslateDocumentHandler } from "./handler";
import type { TranslationProvider } from "../../../core/translation-providers";
import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
+import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
import type { ProvenanceStore } from "../../../core/provenance";
import { ProvenanceService } from "../../modules/provenance";
import type { ProvenanceServiceFactory } from "../../modules/provenance";
@@ -192,6 +193,18 @@ describe("TranslateDocumentHandler", () => {
);
});
+ it("marks its write with the auto-translate skip flag so the hook never re-triggers (loop guard set-side)", async () => {
+ const input = createInput();
+
+ await handler.handle(mockPayload, input);
+
+ expect(mockPayload.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: { [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true },
+ })
+ );
+ });
+
it("saves document without autosave when versions not enabled", async () => {
const input = createInput();
diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts
index b9555082..0356a8bb 100644
--- a/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts
+++ b/packages/payload-plugin-translator/src/server/features/translate-document/handler.ts
@@ -8,6 +8,7 @@ import type { ProvenanceServiceFactory } from "../../modules/provenance";
import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
+import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
import type { TranslateDocumentInput, TranslateDocumentOutput } from "./model";
/**
@@ -123,6 +124,11 @@ export class TranslateDocumentHandler implements Handler<
autosave: isAutosaveEnabled,
locale: targetLng,
fallbackLocale: sourceLng,
+ // Mark this as a translator-authored write so the auto-translate afterChange hook (#51) skips it
+ // — the loop guard's second barrier, alongside the source-locale check. This write always targets
+ // the TARGET locale, so it is already exempt by locale; the flag also covers any future write
+ // path that could touch the source locale.
+ context: { [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true },
});
}
}
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.test.ts
new file mode 100644
index 00000000..7495af67
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ buildAutoTranslateTasks,
+ makeCollectionPolicyResolver,
+ normalizeAutoTranslateConfig,
+ passesPublishGate,
+ resolvePublishOnTranslation,
+} from "./AutoTranslate.policy";
+import type { NormalizedAutoTranslatePolicy } from "./AutoTranslate.policy";
+
+const policy = (
+ over: Partial = {}
+): NormalizedAutoTranslatePolicy => ({
+ targets: ["de", "fr"],
+ strategy: "overwrite",
+ debounceMs: 0,
+ ...over,
+});
+
+describe("normalizeAutoTranslateConfig", () => {
+ it("applies defaults (strategy overwrite, debounce 0)", () => {
+ expect(normalizeAutoTranslateConfig({ targets: ["de"] })).toEqual({
+ targets: ["de"],
+ strategy: "overwrite",
+ debounceMs: 0,
+ sourceLocale: undefined,
+ });
+ });
+
+ it("keeps provided values", () => {
+ expect(
+ normalizeAutoTranslateConfig({
+ targets: ["de"],
+ strategy: "skip_existing",
+ debounceMs: 1000,
+ sourceLocale: "en",
+ })
+ ).toEqual({ targets: ["de"], strategy: "skip_existing", debounceMs: 1000, sourceLocale: "en" });
+ });
+});
+
+describe("makeCollectionPolicyResolver (v1 — doc ignored)", () => {
+ it("resolves a configured slug, null otherwise, regardless of doc", () => {
+ const resolve = makeCollectionPolicyResolver(new Map([["posts", policy()]]));
+ expect(resolve("posts", { anything: true })).toEqual(policy());
+ expect(resolve("pages", {})).toBeNull();
+ });
+});
+
+describe("passesPublishGate (D8)", () => {
+ it("no-drafts collection: any save qualifies", () => {
+ expect(passesPublishGate({}, false)).toBe(true);
+ expect(passesPublishGate({ _status: "draft" }, false)).toBe(true);
+ });
+ it("drafts collection: only a published doc qualifies", () => {
+ expect(passesPublishGate({ _status: "published" }, true)).toBe(true);
+ expect(passesPublishGate({ _status: "draft" }, true)).toBe(false);
+ expect(passesPublishGate({}, true)).toBe(false);
+ });
+});
+
+describe("resolvePublishOnTranslation (D9 — mirror source)", () => {
+ it("mirrors the source status", () => {
+ expect(resolvePublishOnTranslation({ _status: "published" }, true)).toBe(true);
+ expect(resolvePublishOnTranslation({ _status: "draft" }, true)).toBe(false);
+ expect(resolvePublishOnTranslation({}, false)).toBe(true);
+ });
+});
+
+describe("buildAutoTranslateTasks", () => {
+ it("builds one task per target, excluding the source locale", () => {
+ const tasks = buildAutoTranslateTasks({
+ policy: policy({ targets: ["en", "de", "fr"] }),
+ collectionSlug: "posts",
+ documentId: "1",
+ sourceLocale: "en",
+ doc: { _status: "published" },
+ hasDrafts: true,
+ now: 0,
+ });
+ expect(tasks.map((t) => t.targetLng)).toEqual(["de", "fr"]);
+ expect(tasks[0]).toMatchObject({
+ collectionSlug: "posts",
+ collectionId: "1",
+ sourceLng: "en",
+ strategy: "overwrite",
+ publishOnTranslation: true,
+ });
+ expect(tasks[0].waitUntil).toBeUndefined();
+ });
+
+ it("sets waitUntil from debounceMs against the injected now", () => {
+ const tasks = buildAutoTranslateTasks({
+ policy: policy({ debounceMs: 5000 }),
+ collectionSlug: "posts",
+ documentId: "1",
+ sourceLocale: "en",
+ doc: { _status: "published" },
+ hasDrafts: true,
+ now: 1000,
+ });
+ expect(tasks[0].waitUntil).toEqual(new Date(6000));
+ });
+});
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts
new file mode 100644
index 00000000..202029cb
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.policy.ts
@@ -0,0 +1,101 @@
+import type { CollectionSlug } from "payload";
+
+import type { AutoTranslateConfig } from "../../../core/auto-translate-config";
+import type { TaskInput } from "../task-runner/types";
+
+/** A collection's auto-translate rule with defaults resolved — the shape the hook consumes. */
+export type NormalizedAutoTranslatePolicy = {
+ targets: string[];
+ strategy: "overwrite" | "skip_existing";
+ debounceMs: number;
+ sourceLocale?: string;
+};
+
+/**
+ * Resolve the effective auto-translate policy for a document, or `null` when off. The `doc` parameter
+ * is the reserved seam for the future document-level manager override (#51 D7): v1 resolves at the
+ * collection level and IGNORES `doc`; a later phase supplies a second implementation that consults the
+ * document, without changing the hook, drift-gate, debounce, or wiring.
+ */
+export type AutoTranslatePolicyResolver = (
+ collectionSlug: string,
+ doc: Record
+) => NormalizedAutoTranslatePolicy | null;
+
+/** Apply defaults to a raw config: strategy → "overwrite", debounce → 0. Duplicate target locales are
+ * de-duplicated so a misconfigured `targets: ["de","de"]` never enqueues two racing jobs for the same
+ * (document, locale) in one batch (the runner's supersession only dedupes against already-stored jobs). */
+export function normalizeAutoTranslateConfig(
+ config: AutoTranslateConfig
+): NormalizedAutoTranslatePolicy {
+ return {
+ targets: [...new Set(config.targets)],
+ strategy: config.strategy ?? "overwrite",
+ debounceMs: config.debounceMs ?? 0,
+ sourceLocale: config.sourceLocale,
+ };
+}
+
+/**
+ * The v1 (collection-level) resolver. Ignores `doc` — see {@link AutoTranslatePolicyResolver}. This is
+ * the single seam a future document-level manager replaces.
+ */
+export function makeCollectionPolicyResolver(
+ policies: Map
+): AutoTranslatePolicyResolver {
+ return (collectionSlug, _doc) => policies.get(collectionSlug) ?? null;
+}
+
+/**
+ * Publish-gate (#51 D8): a drafts-enabled collection auto-translates only on a **published** save;
+ * autosave/draft saves are ignored. A collection without drafts has no `_status`, so every save
+ * qualifies. Applies uniformly to create and update.
+ */
+export function passesPublishGate(doc: Record, hasDrafts: boolean): boolean {
+ if (!hasDrafts) return true;
+ return doc._status === "published";
+}
+
+/**
+ * Mirror the source document's status onto the translation (#51 D9). Combined with the publish-gate,
+ * the source is published whenever we reach enqueue, so translations publish; a no-drafts collection
+ * publishes too. Deliberately identical to {@link passesPublishGate} today — kept as a SEPARATE
+ * function (not merged) because it diverges once a future document-level manager (R8) can bypass the
+ * gate and translate a still-draft source; do not collapse the two.
+ */
+export function resolvePublishOnTranslation(
+ doc: Record,
+ hasDrafts: boolean
+): boolean {
+ if (!hasDrafts) return true;
+ return doc._status === "published";
+}
+
+/**
+ * Build one {@link TaskInput} per configured target locale (the source locale is always excluded).
+ * `waitUntil` encodes the debounce; `now` is injected so the timestamp is deterministic in tests.
+ */
+export function buildAutoTranslateTasks(args: {
+ policy: NormalizedAutoTranslatePolicy;
+ collectionSlug: CollectionSlug;
+ documentId: string;
+ sourceLocale: string;
+ doc: Record;
+ hasDrafts: boolean;
+ now: number;
+}): TaskInput[] {
+ const { policy, collectionSlug, documentId, sourceLocale, doc, hasDrafts, now } = args;
+ const publishOnTranslation = resolvePublishOnTranslation(doc, hasDrafts);
+ const waitUntil = policy.debounceMs > 0 ? new Date(now + policy.debounceMs) : undefined;
+ return policy.targets
+ .filter((target) => target !== sourceLocale)
+ .map((target) => ({
+ collectionSlug,
+ collectionId: documentId,
+ sourceLng: sourceLocale,
+ targetLng: target,
+ strategy: policy.strategy,
+ publishOnTranslation,
+ waitUntil,
+ }));
+}
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts
new file mode 100644
index 00000000..dac214a3
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.shapes.ts
@@ -0,0 +1,22 @@
+import type { CollectionAfterChangeHook } from "payload";
+
+/**
+ * The minimal slice of a Payload collection the auto-translate wiring mutates: its `slug` and the
+ * `afterChange` hook slot. A real `CollectionConfig` is structurally assignable to this, so call sites
+ * pass the live collection with no adapter and tests pass a `{ slug: "posts" }` literal. Keeps
+ * `injectAutoTranslateHook` off the god-`CollectionConfig` type (own shape — provenance's is not reused,
+ * per the module-owns-its-shape convention). The only Payload type imported is the hook callback
+ * contract, which legitimately stays framework-typed.
+ */
+export type AutoTranslateManagedEntry = {
+ slug: string;
+ hooks?: { afterChange?: CollectionAfterChangeHook[] };
+ /** The `custom` bag — the opt-in is propagated here so the admin UI can read it back off the
+ * REGISTERED collection (which may be a different object than the one `withAutoTranslate` wrapped). */
+ custom?: Record;
+};
+
+/** The minimal config host: a mutable `collections` array. A real Payload `Config` plugs straight in. */
+export type AutoTranslateManagedConfig = {
+ collections?: AutoTranslateManagedEntry[];
+};
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts
new file mode 100644
index 00000000..731e8ede
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts
@@ -0,0 +1,59 @@
+import { getAutoTranslateConfig } from "../../../core/auto-translate-config";
+import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
+import type { ConfigModifier } from "../../../types/ConfigModifier";
+import type { TaskRunnerFactory } from "../task-runner";
+
+import { makeCollectionPolicyResolver, normalizeAutoTranslateConfig } from "./AutoTranslate.policy";
+import type { NormalizedAutoTranslatePolicy } from "./AutoTranslate.policy";
+import {
+ injectAutoTranslateHook,
+ makeAutoTranslateHook,
+ propagateAutoTranslateCustom,
+} from "./AutoTranslateEnqueue.hook";
+
+/** A collection as the plugin receives it — only `slug` + `custom` are read to resolve the opt-in. */
+type ConfigurableCollection = { slug: string; custom?: Record };
+
+/** Everything the auto-translate module contributes at config time (mirrors `ProvenanceModule`). */
+export type AutoTranslateModule = {
+ configure(managedSlugs: Set): ConfigModifier;
+};
+
+const NOOP: ConfigModifier = (config) => config;
+
+/**
+ * Turn the opt-in `withAutoTranslate` config (read from each collection's `custom`) into a
+ * self-contained {@link AutoTranslateModule} — mirrors `configureProvenance`. Builds the per-collection
+ * policy map + resolver once, then returns a `configure(managedSlugs) → ConfigModifier` that injects a
+ * single best-effort `afterChange` hook onto every enabled + managed collection. When no collection
+ * opted in, `configure` is a no-op (no hook, no behaviour change).
+ */
+export function configureAutoTranslate(
+ collections: ConfigurableCollection[],
+ schemaMap: CollectionSchemaMap,
+ taskRunnerFactory: TaskRunnerFactory
+): AutoTranslateModule {
+ const policies = new Map();
+ for (const collection of collections) {
+ const config = getAutoTranslateConfig(collection);
+ if (config) policies.set(collection.slug, normalizeAutoTranslateConfig(config));
+ }
+ if (policies.size === 0) return { configure: () => NOOP };
+
+ const enabledSlugs = new Set(policies.keys());
+ const resolvePolicy = makeCollectionPolicyResolver(policies);
+ const hook = makeAutoTranslateHook({ resolvePolicy, schemaMap, taskRunnerFactory });
+
+ return {
+ configure:
+ (managedSlugs: Set): ConfigModifier =>
+ (config) => {
+ // Inject only onto collections that both opted in AND are plugin-managed.
+ const slugs = new Set([...enabledSlugs].filter((slug) => managedSlugs.has(slug)));
+ injectAutoTranslateHook(config, slugs, hook);
+ // Mirror the opt-in onto the registered collection's `custom` so the admin UI can read it back.
+ propagateAutoTranslateCustom(config, slugs, policies);
+ return config;
+ },
+ };
+}
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.test.ts
new file mode 100644
index 00000000..a689cee1
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.test.ts
@@ -0,0 +1,192 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { CollectionSlug, Field } from "payload";
+
+import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
+import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
+
+import {
+ AUTO_TRANSLATE_CUSTOM_KEY,
+ getAutoTranslateConfig,
+} from "../../../core/auto-translate-config";
+
+import { makeCollectionPolicyResolver } from "./AutoTranslate.policy";
+import type { NormalizedAutoTranslatePolicy } from "./AutoTranslate.policy";
+import { makeAutoTranslateHook, propagateAutoTranslateCustom } from "./AutoTranslateEnqueue.hook";
+
+const schema: Field[] = [{ name: "title", type: "text", localized: true }];
+const schemaMap: CollectionSchemaMap = new Map([["posts" as CollectionSlug, schema]]);
+const policy: NormalizedAutoTranslatePolicy = {
+ targets: ["de", "fr"],
+ strategy: "overwrite",
+ debounceMs: 0,
+};
+
+const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
+
+function setup(
+ opts: {
+ policies?: Map;
+ enqueue?: ReturnType;
+ } = {}
+) {
+ const enqueue = opts.enqueue ?? vi.fn().mockResolvedValue(undefined);
+ const taskRunnerFactory = { create: vi.fn().mockReturnValue({ enqueue }) };
+ const resolvePolicy = makeCollectionPolicyResolver(opts.policies ?? new Map([["posts", policy]]));
+ const hook = makeAutoTranslateHook({
+ resolvePolicy,
+ schemaMap,
+ taskRunnerFactory: taskRunnerFactory as never,
+ });
+ return { hook, enqueue };
+}
+
+// A CollectionAfterChangeHook args object with sensible defaults (published source-locale update).
+function hookArgs(over: Record = {}) {
+ return {
+ doc: (over.doc as object) ?? { id: "1", title: "NEW", _status: "published" },
+ previousDoc:
+ "previousDoc" in over ? over.previousDoc : { id: "1", title: "OLD", _status: "published" },
+ collection: (over.collection as object) ?? { slug: "posts", versions: { drafts: true } },
+ operation: over.operation ?? "update",
+ req: {
+ locale: over.locale ?? "en",
+ context: over.context ?? {},
+ payload: {
+ logger,
+ config: {
+ localization:
+ over.localization === undefined
+ ? { defaultLocale: "en", locales: ["en", "de", "fr"] }
+ : over.localization,
+ },
+ },
+ },
+ } as never;
+}
+
+describe("makeAutoTranslateHook", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("enqueues one job per target on a published source-locale change (R2)", async () => {
+ const { hook, enqueue } = setup();
+ await hook(hookArgs());
+ expect(enqueue).toHaveBeenCalledTimes(1);
+ const tasks = enqueue.mock.calls[0][0] as Array<{ targetLng: string; sourceLng: string }>;
+ expect(tasks.map((t) => t.targetLng)).toEqual(["de", "fr"]);
+ expect(tasks.every((t) => t.sourceLng === "en")).toBe(true);
+ });
+
+ it("does not enqueue when translatable content is unchanged (drift-gate, R3)", async () => {
+ const { hook, enqueue } = setup();
+ await hook(
+ hookArgs({
+ doc: { id: "1", title: "SAME", _status: "published" },
+ previousDoc: { id: "1", title: "SAME", _status: "published" },
+ })
+ );
+ expect(enqueue).not.toHaveBeenCalled();
+ });
+
+ it("does not enqueue for a non-source-locale write (loop guard, R4)", async () => {
+ const { hook, enqueue } = setup();
+ await hook(hookArgs({ locale: "de" }));
+ expect(enqueue).not.toHaveBeenCalled();
+ });
+
+ it("does not enqueue when the translator's own skip flag is set — loop CLOSES via the shared constant (R4/AC15)", async () => {
+ const { hook, enqueue } = setup();
+ await hook(hookArgs({ context: { [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true } }));
+ expect(enqueue).not.toHaveBeenCalled();
+ });
+
+ it("is best-effort: swallows an enqueue error, logs, never throws (R6)", async () => {
+ const enqueue = vi.fn().mockRejectedValue(new Error("queue down"));
+ const { hook } = setup({ enqueue });
+ await expect(hook(hookArgs())).resolves.toBeDefined();
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ it("publish-gate: an update to a draft does NOT enqueue; a published update does (D8)", async () => {
+ const draft = setup();
+ await draft.hook(hookArgs({ doc: { id: "1", title: "NEW", _status: "draft" } }));
+ expect(draft.enqueue).not.toHaveBeenCalled();
+
+ const published = setup();
+ await published.hook(hookArgs({ doc: { id: "1", title: "NEW", _status: "published" } }));
+ expect(published.enqueue).toHaveBeenCalledTimes(1);
+ });
+
+ it("publish-gate on create: create-of-published enqueues, create-of-draft does not (D8)", async () => {
+ const published = setup();
+ await published.hook(
+ hookArgs({
+ operation: "create",
+ previousDoc: undefined,
+ doc: { id: "1", title: "NEW", _status: "published" },
+ })
+ );
+ expect(published.enqueue).toHaveBeenCalledTimes(1);
+
+ const draft = setup();
+ await draft.hook(
+ hookArgs({
+ operation: "create",
+ previousDoc: undefined,
+ doc: { id: "1", title: "NEW", _status: "draft" },
+ })
+ );
+ expect(draft.enqueue).not.toHaveBeenCalled();
+ });
+
+ it("no-drafts collection: any published-less save enqueues", async () => {
+ const { hook, enqueue } = setup();
+ await hook(
+ hookArgs({
+ collection: { slug: "posts", versions: { drafts: false } },
+ doc: { id: "1", title: "NEW" },
+ })
+ );
+ expect(enqueue).toHaveBeenCalledTimes(1);
+ });
+
+ it("no-ops (no enqueue, no throw, warns) when no source locale is resolvable (R2/AC14)", async () => {
+ const { hook, enqueue } = setup();
+ await expect(hook(hookArgs({ localization: false }))).resolves.toBeDefined();
+ expect(enqueue).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalled();
+ });
+
+ it("does nothing for a collection that did not opt in", async () => {
+ const { hook, enqueue } = setup({ policies: new Map() });
+ await hook(hookArgs());
+ expect(enqueue).not.toHaveBeenCalled();
+ });
+});
+
+describe("propagateAutoTranslateCustom", () => {
+ const policies = new Map([["posts", policy]]);
+ type TestConfig = { collections: Array<{ slug: string; custom?: Record }> };
+
+ it("stamps the opt-in onto a registered collection that has no custom yet (the dev scenario, D2)", () => {
+ // The REGISTERED collection is a different object than the wrapped one — starts with no custom.
+ const config: TestConfig = { collections: [{ slug: "posts" }] };
+ propagateAutoTranslateCustom(config, new Set(["posts"]), policies);
+ const read = getAutoTranslateConfig(config.collections[0]);
+ expect(read).not.toBeNull();
+ expect(read?.targets).toEqual(["de", "fr"]);
+ });
+
+ it("preserves other custom keys when stamping", () => {
+ const config: TestConfig = { collections: [{ slug: "posts", custom: { other: 1 } }] };
+ propagateAutoTranslateCustom(config, new Set(["posts"]), policies);
+ const stamped = config.collections[0].custom as Record;
+ expect(stamped.other).toBe(1);
+ expect(stamped[AUTO_TRANSLATE_CUSTOM_KEY]).toBeDefined();
+ });
+
+ it("does not stamp a collection that is not in the enabled set", () => {
+ const config: TestConfig = { collections: [{ slug: "pages" }] };
+ propagateAutoTranslateCustom(config, new Set(["posts"]), policies);
+ expect(getAutoTranslateConfig(config.collections[0])).toBeNull();
+ });
+});
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts
new file mode 100644
index 00000000..ffd37602
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts
@@ -0,0 +1,141 @@
+import type { CollectionAfterChangeHook } from "payload";
+
+import { hasSourceContentChanged } from "../../../core/auto-translate";
+import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../core/auto-translate-config";
+import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
+import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap";
+import type { TaskRunnerFactory } from "../task-runner";
+
+import type {
+ AutoTranslatePolicyResolver,
+ NormalizedAutoTranslatePolicy,
+} from "./AutoTranslate.policy";
+import { buildAutoTranslateTasks, passesPublishGate } from "./AutoTranslate.policy";
+import type { AutoTranslateManagedConfig } from "./AutoTranslate.shapes";
+
+/**
+ * Marks the plugin's own auto-translate hook so a repeated `init()` recognises an already-injected hook
+ * and stays idempotent (same idea as the provenance cleanup hook's marker). A bare function has no
+ * `custom` bag, so the marker lives as a property on the function itself.
+ */
+type MarkedHook = CollectionAfterChangeHook & { __translatorAutoTranslate?: boolean };
+
+type AutoTranslateHookDeps = {
+ resolvePolicy: AutoTranslatePolicyResolver;
+ schemaMap: CollectionSchemaMap;
+ taskRunnerFactory: TaskRunnerFactory;
+};
+
+/**
+ * Build the `afterChange` hook that auto-enqueues translations when a document's source-locale content
+ * changes. Thin orchestration only — every decision lives in `AutoTranslate.policy.ts` or the core
+ * drift predicate. Best-effort by contract: any failure is logged and swallowed, never failing the
+ * editor's save.
+ *
+ * Order (cheap guards first): (1) skip the translator's own writes via the `req.context` flag;
+ * (2) resolve the policy — off ⇒ skip; (3) resolve the source locale (per-collection override else
+ * `localization.defaultLocale`) — unresolved ⇒ log + skip; (4) skip non-source-locale writes (the
+ * pipeline's target writes never match); (5) publish-gate (D8); (6) drift-gate (D3); then enqueue one
+ * job per target locale.
+ */
+export function makeAutoTranslateHook(deps: AutoTranslateHookDeps): CollectionAfterChangeHook {
+ const { resolvePolicy, schemaMap, taskRunnerFactory } = deps;
+
+ const hook: MarkedHook = async ({ doc, previousDoc, req, collection }) => {
+ try {
+ if (req.context?.[AUTO_TRANSLATE_SKIP_CONTEXT_KEY]) return doc;
+
+ const policy = resolvePolicy(collection.slug, doc);
+ if (!policy) return doc;
+
+ const localization = req.payload.config.localization;
+ const sourceLocale =
+ policy.sourceLocale ?? (localization ? localization.defaultLocale : undefined);
+ if (!sourceLocale) {
+ req.payload.logger.warn({
+ collection: collection.slug,
+ documentId: String(doc.id),
+ msg: "translator: auto-translate skipped — no source locale resolvable (set localization.defaultLocale or a per-collection sourceLocale)",
+ });
+ return doc;
+ }
+
+ if (req.locale !== sourceLocale) return doc;
+
+ const hasDrafts = Boolean(collection.versions && collection.versions.drafts);
+ if (!passesPublishGate(doc, hasDrafts)) return doc;
+
+ const schema = schemaMap.get(collection.slug);
+ if (schema && !hasSourceContentChanged(previousDoc, doc, schema)) return doc;
+
+ const tasks = buildAutoTranslateTasks({
+ policy,
+ collectionSlug: collection.slug,
+ documentId: String(doc.id),
+ sourceLocale,
+ doc,
+ hasDrafts,
+ now: Date.now(),
+ });
+ if (tasks.length === 0) return doc;
+
+ await taskRunnerFactory.create(req.payload).enqueue(tasks);
+ } catch (error) {
+ req.payload.logger.error({
+ err: error,
+ collection: collection.slug,
+ documentId: String(doc.id),
+ msg: "translator: auto-translate hook failed",
+ });
+ }
+ return doc;
+ };
+
+ hook.__translatorAutoTranslate = true;
+ return hook;
+}
+
+/**
+ * Attach the auto-translate hook to every enabled collection on `config`, appending to any
+ * consumer-supplied `afterChange` array. Idempotent: a collection that already carries the marked hook
+ * is skipped, so a repeated `init()` never stacks duplicates.
+ */
+export function injectAutoTranslateHook(
+ config: AutoTranslateManagedConfig,
+ enabledSlugs: Set,
+ hook: CollectionAfterChangeHook
+): void {
+ for (const collection of config.collections ?? []) {
+ if (!enabledSlugs.has(collection.slug)) continue;
+ collection.hooks ??= {};
+ collection.hooks.afterChange ??= [];
+ const alreadyInjected = collection.hooks.afterChange.some(
+ (existing) => (existing as MarkedHook).__translatorAutoTranslate === true
+ );
+ if (!alreadyInjected) collection.hooks.afterChange.push(hook);
+ }
+}
+
+/**
+ * Propagate each enabled collection's resolved policy onto the REGISTERED collection's `custom` bag, so
+ * the admin UI can read the opt-in back via `getAutoTranslateConfig`. This is required because
+ * `withAutoTranslate` stamps `custom` on the object passed to the plugin's `collections` param, which
+ * can be a DIFFERENT object than the one registered in `buildConfig.collections` (the reader would
+ * otherwise see no config and the indicator would disagree with the behaviour). Idempotent + additive:
+ * re-stamping the same value is a no-op and the behaviour wiring still reads from the plugin param.
+ */
+export function propagateAutoTranslateCustom(
+ config: AutoTranslateManagedConfig,
+ enabledSlugs: Set,
+ policies: Map
+): void {
+ for (const collection of config.collections ?? []) {
+ if (!enabledSlugs.has(collection.slug)) continue;
+ const policy = policies.get(collection.slug);
+ if (!policy) continue;
+ collection.custom = {
+ ...(collection.custom ?? {}),
+ [AUTO_TRANSLATE_CUSTOM_KEY]: policy,
+ };
+ }
+}
diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts
new file mode 100644
index 00000000..bfb9be8e
--- /dev/null
+++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/index.ts
@@ -0,0 +1,10 @@
+// Auto-translate adapter (Payload-backed). The payload-free drift predicate + config reader live in
+// the core (src/core/auto-translate*, src/core/content-projection); this module is the config-time
+// wiring + the afterChange hook that enqueues translations on a source-locale change (#51).
+export { configureAutoTranslate } from "./AutoTranslate.wiring";
+export type { AutoTranslateModule } from "./AutoTranslate.wiring";
+export { makeAutoTranslateHook, injectAutoTranslateHook } from "./AutoTranslateEnqueue.hook";
+export type {
+ AutoTranslatePolicyResolver,
+ NormalizedAutoTranslatePolicy,
+} from "./AutoTranslate.policy";
diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts
index ae38ed41..a41f38c0 100644
--- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts
+++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts
@@ -88,6 +88,22 @@ describe("PayloadJobsTaskRunner", () => {
});
});
+ it("passes waitUntil through to payload.jobs.queue when set (debounce)", async () => {
+ const when = new Date("2024-06-01T00:00:00Z");
+ await runner.enqueue([createInput({ waitUntil: when })]);
+
+ expect(mockPayload.jobs.queue).toHaveBeenCalledWith(
+ expect.objectContaining({ waitUntil: when })
+ );
+ });
+
+ it("leaves waitUntil undefined for the manual path (no debounce)", async () => {
+ await runner.enqueue([createInput()]);
+
+ const arg = (mockPayload.jobs.queue as ReturnType).mock.calls[0][0];
+ expect(arg.waitUntil).toBeUndefined();
+ });
+
it("queues multiple tasks", async () => {
const inputs = [
createInput({ collectionId: "doc-1" }),
diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts
index eb5eb7fd..47cbdf92 100644
--- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts
+++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts
@@ -47,6 +47,10 @@ export class PayloadJobsTaskRunner implements TaskRunner {
this.payload.jobs.queue({
task: this.config.taskName,
queue: this.config.queueName,
+ // Debounce: when set, Payload holds the job until this instant. A superseding enqueue for
+ // the same (document, targetLng) cancels the pending delayed job first (see enqueue above),
+ // so rapid source edits coalesce to the final one. Undefined for the manual path.
+ waitUntil: task.waitUntil,
input: {
// Flat text reference (ID-agnostic). Stored as a string — no
// relationship type validation against the collection's ID type,
diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.test.ts
index b7ea7e29..10470bd3 100644
--- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.test.ts
+++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.test.ts
@@ -46,6 +46,15 @@ describe("SyncTaskRunner", () => {
});
});
+ it("ignores waitUntil and runs immediately (dev runner — no debounce)", async () => {
+ const input = createInput({ waitUntil: new Date("2999-01-01T00:00:00Z") });
+ await runner.enqueue([input]);
+
+ // Executed now despite the far-future waitUntil; task is completed synchronously.
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(tasks.get("posts:doc-123:de")?.status).toBe("completed");
+ });
+
it("stores completed task in tasks map", async () => {
const input = createInput();
await runner.enqueue([input]);
diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts
index 72a9095c..43e73f41 100644
--- a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts
+++ b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts
@@ -26,6 +26,13 @@ export type TaskInput = {
targetLng: string;
strategy: "overwrite" | "skip_existing";
publishOnTranslation: boolean;
+ /**
+ * Optional scheduled-run time (debounce). When set, the job runs no earlier than this instant;
+ * omitted/`undefined` = run as soon as the runner picks it up (every existing caller). Honored by
+ * the Payload Jobs runner via `payload.jobs.queue({ waitUntil })`; the sync (dev) runner ignores it
+ * and runs immediately.
+ */
+ waitUntil?: Date;
};
/**
diff --git a/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts b/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts
new file mode 100644
index 00000000..25784557
--- /dev/null
+++ b/packages/payload-plugin-translator/src/types/AutoTranslateContext.ts
@@ -0,0 +1,10 @@
+/**
+ * The `req.context` flag the translator sets on its OWN document writes so the auto-translate
+ * `afterChange` hook skips them — the loop guard's second barrier (#51 D5). A single exported constant
+ * shared by the setter (`TranslateDocumentHandler.saveTranslatedDocument`) and the reader (the
+ * auto-translate hook), so the set-side and honor-side keys can never diverge.
+ *
+ * Lives in `types/` (a leaf contract) so both `server/features/translate-document` and
+ * `server/modules/auto-translate` import it without creating a cross-module edge.
+ */
+export const AUTO_TRANSLATE_SKIP_CONTEXT_KEY = "translatorSkipAutoTranslate";