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";