From 487d046daaf5a813623912e9ea32a7f57a6a0d1b Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 17:16:07 +0200 Subject: [PATCH 01/11] feat(translator): translate rich text one container at a time, behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph is translated one text node at a time today, each node in isolation. Word order therefore stays pinned to the source language and inline formatting lands on whichever word happens to occupy that position. Measured on a live model: `a **red** car` into French came back `un **rouge** voiture` — ungrammatical, and the emphasis on the wrong word. Container mode sends the whole paragraph as one string with numbered marks, `<1>a <2>red<3> car`, and rebuilds the container's children in the order the reply came back. The same sentence now returns `une **voiture rouge**`. Off by default, behind `experimental: { inlineMarks: true }`, and ignored unless the provider declares `capabilities.inlineMarks` — a transport that is not a language model would translate or strip the marks, so the capability is part of the provider contract rather than a config option. The mark instruction is appended **after** a `systemPrompt` override rather than inside `defaultPrompt`, because a builder is free to ignore the default and would otherwise silently drop the one rule the format depends on. A container the format cannot carry falls back to the per-node path, and says which: source text that already looks like a mark, a single leaf with nothing to reorder, no translatable text, or a wrapper shape whose per-leaf copy would drop a node. A reply whose marks come back damaged leaves that container in its source language rather than writing half a paragraph into the document — silently for now, since the report that records such places is the next change. One source wrapper holding several leaves becomes several adjacent wrappers in the result and they are not merged back (D12): a link with an emphasised word inside renders identically as two sibling links with the same href. Confirmed on a live run — `[read the **manual**](/docs)` came back as two adjacent links, each keeping the href, with the German word order rebuilt around them. The write path has one branch. An earlier draft kept a write-in-place fast path for replies that came back in the original order; it was removed before this change was split out, because the second branch cost a flag, a four-clause predicate and three tests that existed only to keep the two branches agreeing, and rebuilding an array per paragraph costs nothing measurable. Verification: 1488 unit tests, 19 new; check-types clean; lint 58 warnings and 0 errors, identical to main; declaration build passes. Live on a real key across French and German: word order rebuilt, emphasis carried to the right word, an emptied mark's node dropped, and a wrapper's href preserved on both halves. --- .gitignore | 3 +- .../docs/DEPRECATIONS.md | 21 + ...8-richtext-container-granularity-design.md | 403 ++++++++++++++++++ ...-08-richtext-container-granularity.task.md | 132 ++++++ .../2026-09-09-container-mode-wiring.task.md | 158 +++++++ .../src/composition/levels/fieldLevel.ts | 1 + .../TranslationProvider.interface.ts | 38 +- .../domain/translation-providers/index.ts | 1 + .../containerMode.test.ts | 385 +++++++++++++++++ .../core/translation-pipeline/stages/index.ts | 1 + .../text-expander/RichContainerExpander.ts | 88 ++++ .../stages/text-expander/index.ts | 1 + .../TranslationMutator.stage.ts | 2 +- .../TranslationMutator.ts | 46 +- .../stages/translation/Translation.stage.ts | 35 +- .../translateContent.test.ts | 22 + .../translation-pipeline/translateContent.ts | 15 + .../types/PipelineContext.ts | 6 + .../translation-pipeline/types/TextChunk.ts | 28 +- .../core/translation-pipeline/types/index.ts | 4 +- .../payload-plugin-translator/src/plugin.ts | 27 ++ .../features/translate-document/handler.ts | 6 +- .../translate-document/wireTranslateRunner.ts | 5 +- .../features/translate-field/handler.ts | 1 + .../server/features/translate-field/model.ts | 1 + .../translation-levels/PluginConfigBuilder.ts | 2 + .../modules/translation-levels/types.ts | 1 + .../openai/OpenAITranslation.provider.ts | 1 + .../OpenAITranslationLegacy.provider.test.ts | 6 + .../OpenAITranslationLegacy.provider.ts | 10 +- .../shared/CompletionProvider.provider.ts | 22 +- .../shared/buildSystemPrompt.test.ts | 26 ++ .../shared/buildSystemPrompt.ts | 26 +- 33 files changed, 1501 insertions(+), 23 deletions(-) create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity-design.md create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity.task.md create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-09-container-mode-wiring.task.md create mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts create mode 100644 packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts diff --git a/.gitignore b/.gitignore index 2996984df..af738a7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,9 @@ node_modules .env.test.local .env.production.local -# Personal, per-developer instructions — never committed +# Personal, per-developer instructions and working notes — never committed CLAUDE.local.md +*.local.md # Testing coverage diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index 38d822908..eb5ae8cd7 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -218,3 +218,24 @@ the single source of truth — code annotations link here by anchor instead of d - **Code refs:** `src/translation-providers/openai/OpenAITranslation.provider.ts`, `src/translation-providers/openai/loadOpenAIClient.ts`, `src/translation-providers/openai/OpenAITranslationLegacy.provider.ts` + +### experimental-inline-marks + +- **What:** `translatorPlugin({ experimental: { inlineMarks } })`. +- **Status:** live (`@deprecated` in code from the day it shipped) +- **Deprecated:** 2026-09-09 / #134 +- **Replacement:** none — the behaviour becomes the only mode, so the switch simply goes away. +- **Scope:** this entry only. The `experimental` option itself is permanent — it is where the + next transitional switch will live, so removing `inlineMarks` does not remove the object. +- **Remove in:** next major +- **Why:** a transitional switch, not a supported choice. Translating rich text node by node pins + every word to its source position, which is a defect, not a preference — so there is nothing to + keep choosing between. The flag exists only so an install can adopt the change on its own + schedule and step back if its provider misbehaves. The next major removes **the flag**, not the + per-node code: that stays as the internal fallback for a mark-shaped source, a single-fragment + container, and a corrupt reply. +- **Code refs:** + - `src/plugin.ts` (the option) + - `src/core/translation-pipeline/translateContent.ts` (the single switch between the two paths) + - `src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts` + - `docs/plans/2026-09-08-richtext-container-granularity-design.md` (D8, D8a) diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity-design.md b/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity-design.md new file mode 100644 index 000000000..8355c26de --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity-design.md @@ -0,0 +1,403 @@ +# Design — rich text container granularity (#134) + +- **Issue:** [#134](https://github.com/focusreactive/payload-plugins/issues/134) +- **Reads against:** [2026-08-21-translation-mechanism-kernel-extraction.md](./2026-08-21-translation-mechanism-kernel-extraction.md) — the language/traversal design this must not fight (§8) +- **Date:** 2026-09-08 · **Status:** decided, three questions open (§7) + +--- + +## 1. What is being built + +The unit of rich text translation moves from the **text node** to its **inline container** — +the paragraph, heading, list item or quote that holds it. Fragments inside a container are +wrapped in numbered marks, the model returns them in whatever order the target language +needs, and the container's `children` array is rebuilt in that order. + +Today each text node is translated alone and written back into the node it came from, so the +node order never changes: the translation keeps the source language's word order, and every +inline mark stays pinned to its source position. `a **red** car` becomes *une rouge voiture* +instead of *une voiture **rouge***, and no prompt can fix it — the model is never given the +chance to reorder anything. + +A mark number is **a pointer to the original inline node, not an address to write into**. +That single idea is what keeps this layer from having to understand Lexical formatting: +formatting travels inside the nodes themselves. + +``` +sent: { 7: "<1>a <2>red<3> car" } +returned: { 7: "<1>une <3>voiture <2>rouge" } +applied: children = [ node1("une "), node3("voiture "), node2("rouge") ] +``` + +The boundary between core and provider is the string. The core finds containers, glues +whitespace, emits marks, and afterwards parses, verifies and rebuilds; the provider takes a +string and returns a string, knowing nothing about nodes, gaps or Lexical. + +Reads stay within `type` / `text` / `children`. Writes are `text` on a leaf and `children` +on the container. `format`, `style`, `detail`, a link's `fields` and every other mark +representation are never read and never written — the same surface `kernel/lexical/types.ts` +already declares. + +--- + +## 2. Decisions + +| # | Decision | Why | +| --- | --- | --- | +| D1 | Unit of translation is the nearest node with at least one direct text child | Needs no knowledge of which node types are inline. Exotic trees degrade to today's behaviour instead of breaking | +| D2 | Mark syntax is `text`, self-closing `` for non-text inline nodes, always flat | Models have seen XLIFF and HTML; numeric names cannot collide with meaningful tags; flatness keeps the parser stackless and removes nesting as a failure mode | +| D3 | A container whose source text contains a mark-shaped sequence (`<12>`, ``, `<12/>`) falls back to per-node granularity | Removes the whole escaping problem for the price of losing the optimisation on content that is close to nonexistent. Plain `
` or `5 < 10` are unaffected — only digits between angle brackets collide | +| D4 | Every fragment is wrapped, including unformatted ones | The layer then never constructs a node from scratch — every output node is an existing node with new text | +| D5 | Marks returned in their original order take the current write-into-the-node path | Most content, and all close-language pairs, land here. The new path runs only where it changes the result | +| D6 | A container holding a single unformatted text node emits no marks at all | Typical paragraph pays nothing — not a single extra token | +| ~~D7~~ | **Reversed 2026-09-10 by the owner.** A corrupt reply leaves the container in its source language and is **reported**; it is not retranslated per-node | The per-node result is the defect this whole design exists to remove — falling back to it silently hands the editor a calque and calls it success. An honest gap the editor can see beats a quiet downgrade. The per-node path stays for D3 and D6, where it is not a downgrade but the correct handling | +| D8 | One transitional flag, off by default, **deprecated the day it ships**: it is the single switch between the two paths, and the next major deletes it — container mode becomes the only mode. No other option is added | One bottleneck to test, one line to delete. Off by default so an upgrade changes nothing; deprecated from the start so nobody builds on it. Precedent: `DryRunConfig` already ships deprecated | +| D8a | The next major removes the **flag**, not the per-node code — that stays as an internal fallback (D3, D6, D7) | "Single mode" means the operator has no choice left, not that the mechanism has no fallback | +| D9 | A provider declares `capabilities.inlineMarks`; without it the core stays per-node whatever the flag says. This is part of the provider contract, not a config option | Rung 03 of the provider ladder is explicitly "a service that is not a language model at all (DeepL, Google Translate)" — it would translate or strip marks. Tolerable while the flag is opt-in; after D8 removes the flag it is the only thing standing between such an install and permanent double-billing | +| D11 | The core appends the mark instruction **after** the override's return value, not inside `defaultPrompt` | `buildSystemPrompt` returns the builder's string whole (`buildSystemPrompt.ts:39-41`), and a builder is free to ignore `defaultPrompt`. Putting the instruction inside it lets an install silently drop the one rule the format depends on, and the operator would have no way to see why every container falls back | +| D12 | One source wrapper holding several leaves becomes several adjacent wrappers in the result; they are not merged back in v1 | A link with an emphasised word inside renders identically as two sibling links with the same href. Merging siblings that share an origin is polish; correctness does not depend on it | +| D13 | Every issued number must come back **exactly once**; order is free, empty content is how a merge is expressed | One set comparison, no occurrence counting. Rejecting a repeated mark is what removes copying from the design entirely (D17) | +| D14 | Plain values and single-node containers are sent unmarked; stray marks in their replies are stripped and warned | No markup inside them to preserve, so marks would be pure token cost | +| D15 | Whitespace-only nodes are glued onto the preceding fragment, never marked on their own | A mark of its own can come back empty, and the gap between two words would be gone. Glued, the space rides inside a fragment that carries text | +| D16 | Edge whitespace is restored by the core after the reply, not by the provider | Models trim edges. The provider's contract is string in, string out — it must not know about nodes or gaps. This is exactly the logic that ossified as a "Fix spaces" patch inside the Storyblok plugin's model call | +| D17 | A fragment holds two live references — `node` (the text leaf) and `top` (what goes into the rebuilt array). `top` is the container's direct child, except when that child holds more than one leaf: then each fragment gets its **own copy of the wrapper containing just its leaf**, prepared during collection | Pushing a shared wrapper twice would duplicate its whole text, not reorder it. Preparing the copy at collection time keeps the applicator uniform — write `node.text`, push `top`, no special case. Copying an existing node is still not *constructing* one: no `format`, `fields` or version is ever read | +| D20 | Two granularities, one home: container collection is its own function beside the per-node walk, both owning Lexical knowledge in one place. The existing walk, its whitespace filter and its join are untouched | §4.3 of the core-kernel design makes the *language* the shared layer, not the traversal — and keeps the projection's own walk so "hash the pristine source before any write" is structural. Leaving the join alone also means no stored fingerprint changes: no versioning, no migration, no false staleness, no surprise auto-translate bill | +| D23 | A container left untranslated is reported: which field, which container, and the parse failure that caused it. The report travels as the task's result, so it reaches the client through the runner's existing normalized `Task` | Without it the editor publishes a French page with one English paragraph and never learns why. Reporting is what makes D7's reversal safe rather than merely honest | +| D21 | Dry run is documented as incompatible with marks, not fixed | Its default transformer reverses the string (`runDryRun.ts:18`), which destroys every mark, so a dry run would fall back on every container. The mechanism is already deprecated in favour of supplying a fake `complete` — which round-trips marks fine | +| ~~D22~~ | **Dropped 2026-09-10 by the owner.** No circuit breaker | It only existed to bound the cost of D7's retries, and D7 is gone: nothing is translated twice any more. It would also have switched the run to the defective mode automatically — the same silent downgrade D7 was reversed for | + +--- + +## 3. Three facts that shaped this + +**The text-node walk serves two subsystems, on purpose.** +`collectSerializedLexicalTextNodes` has two callers that ask it different questions: + +| Caller | Question it answers | +| --- | --- | +| `RichTextExpander` (translation pipeline) | which pieces of text to send to the model | +| `leafSourceText` → `projectTranslatableContent` → `fingerprint` (provenance) | what counts as a field's content, so a later run can tell whether the source changed | + +The sharing is deliberate — `contentProjector.ts:5-8` says why: projection and translation reuse +one traversal and one leaf predicate *"so projection and translation can never disagree on which +content is translatable"*. The second caller's output is hashed and **stored** in the provenance +store at translation time; `staleness.ts` compares that stored value against a freshly computed +one to decide whether a translation is stale (the admin indicator, and auto-translate's +source-changed check). + +Container mode needs two things this walk does not give: whitespace-only nodes (it filters them +out, and they hold the gaps between words) and, per text node, the container's direct child above +it (the flat `{ node }` return cannot say a leaf sat inside a link). + +The tempting fix is to widen this walk and move the whitespace filter into `RichTextExpander`. +It was considered and dropped (§9): widening changes what the fingerprint hashes — +`["Buy", " ", "our product"]` joins as `"Buyour product"` today and `"Buy our product"` after — so +every stored fingerprint stops matching. One document with one formatted paragraph is enough to +mark that document stale in every locale, and auto-translate then retranslates the store at the +customer's expense. + +D20 takes the other route: a **separate collection function beside** the existing walk, both living +in the same home. That is what §4.3 of the core-kernel design asks for — the shared layer is the +*language* (per-structure knowledge), not the traversal, and the projection deliberately keeps a +walk of its own so "hash the pristine source before any write happens" is structural rather than a +rule to remember. §7 of that document goes further and keeps a guard test against a later tidy-up +merging the two. + +**Reference mutation survives.** The pipeline's contract — chunks carry live references into +the tree `DataReconciler` built, and the applicator mutates through them — does not change. +Only the level changes: `containerRef.children = [...]` instead of `nodeRef.text = ...`. No +stage downstream of the applicator learns anything new. + +**Copies are the narrow exception.** The rebuilt `children` array normally holds the *same node +objects* in a new order, and the only write into a node is still `node.text`. One shape forces a +copy: a wrapper holding more than one leaf, such as a link with an emphasised word inside. Pushing +that shared wrapper once per leaf would duplicate its entire text rather than reorder it, so each +fragment carries its own copy of the wrapper with just its leaf — prepared during collection, so +the applicator never learns there was a special case. A mark returned twice would be the other +candidate, and D13 rejects that reply instead of supporting it. + +--- + +## 4. Mark contract + +### Emitting + +Walking a container's inline level produces, in document order, one fragment per text leaf and +one per non-text inline node: + +Each fragment carries a mark number and two live references, never a copy (D17): + +| Fragment | Emitted as | `node` (where the translation is written) | `top` (what goes into the rebuilt array) | +| --- | --- | --- | --- | +| text leaf, direct child of the container | `text` | the leaf | the same node | +| the container's direct child holds exactly one leaf | `text` | the leaf | that direct child — any chain above the leaf rides along inside it | +| the container's direct child holds several leaves (a link with an emphasised word) | `text` each | the leaf **inside the copy** | a copy of that child holding only this leaf (D17) | +| non-text inline node (line break, inline block, upload) | `` | — | the node | + +`top` is the container's direct child, not the leaf's immediate parent: with `mark → link → text` +the immediate parent is the link, and pushing that would drop the annotation. Nesting is never +rebuilt by hand — it travels inside `top` by reference. + +### Finding the container + +**A container is a node with at least one direct text child.** Walk from the root down: on a +node that qualifies, stop and take it whole (its nested inline wrappers included); otherwise +descend into its children. + +The rule deliberately names no node types, so it holds for paragraphs, headings, list items, +quotes — and for whatever Payload adds later. + +``` +paragraph ← container: has direct text children +├─ text "Buy " +├─ link → text "our product" the link becomes one fragment inside it +└─ text " today" + +list ← no direct text, descend +├─ listitem → text "first" ← container +└─ listitem → text "second" ← container (each item on its own, as it must be) + +quote → paragraph → text ← the paragraph is the container + +root +├─ paragraph ← container +├─ block (fields, not children) no text children, walked past — unchanged from today +└─ paragraph ← container +``` + +**Known limitation.** A container holding no direct text — a paragraph made of two adjacent +links, say — does not qualify, so each link becomes its own container with a single fragment and +takes the per-node path. The links keep their source order. Fixing that would mean either a list +of known block types (the Lexical knowledge this design avoids) or a depth rule, and depth +cannot work: text sits two levels deep both in a paragraph-with-link and in a list-with-items, +and collapsing list items into one fragment would be flatly wrong. Recorded as a limitation, to +be revisited if real content shows it is common (Q1). + +### Whitespace + +Editors routinely emit a node holding a single space: + +``` +paragraph +├─ text "Buy" +├─ text " " ← this one +└─ text "our product" formatted +``` + +Today's walk drops it (`collectTextNodes.ts:12`) — correct for translation, since a space needs +none — but the serialized string must keep it or the words collide as "Buyour product". + +Per D15 it is **glued onto the preceding fragment** (onto the following one when there is no +preceding), gets no mark, and its node drops out of the result: one node fewer in the tree, +visually identical. A mark of its own would risk coming back empty, and with it the gap. + +Per D16 the core then **restores edge whitespace after the reply**: a fragment whose source +started or ended with a space and whose translation does not gets it back. Non-empty fragments +only — merged fragments legitimately change their edges. + +### Accepting a reply — all or nothing + +**Every number issued must come back exactly once. Order is free, empty content is allowed, +anything else is corrupt.** One set comparison, no sub-cases — and rejecting a repeated mark is +what lets the whole design run without copying a single node (D17). + +| Reply | Verdict | +| --- | --- | +| Same set of numbers, any order | valid | +| A mark carries empty text | valid — the fragment merged into a neighbour; its node drops out of the rebuilt array | +| A mark appears more than once | corrupt — one node cannot sit in two slots without a copy, and the copy is the complexity D17 removes | +| Stray whitespace inside a mark (`< 1 >`) | valid — tolerated rather than losing the container over a space | +| **Any issued number is absent** | corrupt | +| A number we never issued appears | corrupt | +| Unclosed or crossed marks (`<1>text`) | corrupt | +| Nested marks (`<1><2>text`) | corrupt | +| No mark carries any text | corrupt | + +Merging is the case that makes strictness affordable. A translation legitimately turns three +fragments into two, and the model expresses that by returning the third mark **empty** rather +than dropping it. So "every number, exactly once" costs nothing that real translations need — +which is why the earlier bare-vs-wrapped distinction (was a link lost, or did words merge?) is +gone, along with the clone that a repeated mark would have required. + +Splitting a fragment in two is the one thing the model may not do. If it wants to, the container +takes the per-node path and reads as it does today. + +The instruction therefore has to say two things, and the second is easy to forget: **return every +mark, empty if its text moved elsewhere**, and **never introduce a mark into a value that had +none**. + +Corrupt ⇒ D7: the container is queued for a per-node retranslation, batched with every other +corrupt container into one additional request. If that reply is also unusable, the container is +left untranslated and reported — never half-written, because a half-written container with its +markup gone is the failure nobody notices. + +Verification runs in the translation stage, not the applicator: the retranslation needs the +provider, and the applicator has no access to it. The applicator receives fragments that are +already verified. + +### Marks are flat + +A mark denotes a **text leaf together with its whole wrapper chain**, not a markup element — so +nesting cannot arise by construction. A link containing an emphasised word (`read the **docs**`) +holds two leaves and therefore emits two flat, adjacent marks: + +``` +<4>read the <5>docs +``` + +Each gets its own copy of that link as `top` (D17), the second's leaf carrying the emphasis. The +parser reads left to right and needs no stack, and a nested mark in the reply is simply corrupt. + +The cost is D12: one source link becomes two adjacent links with the same href — identical on +screen, one node more in the tree. Structure-mirroring marks +(`<4><5>docs`) would avoid that and cost a stack in the parser plus a whole class of +model errors — declined in §9. + +### Plain values carry no marks + +`text` and `textarea` fields travel exactly as they do today, unmarked. So does a container +holding a single unformatted text node (D6). The rule: **marks appear only where a value holds +more than one fragment.** + +One request therefore mixes marked and unmarked values, which the model can confuse. If marks +appear in the reply to a value that was sent unmarked, they are stripped and a warning is +emitted — the text itself is usually fine and losing it to the model's overreach would be worse. +Splitting marked and unmarked values into separate requests is declined in §9: an extra request +every time, and the whole-document context that keeps terminology consistent is exactly what it +would break. + +--- + +## 5. Where things go + +| Path | Change | +| --- | --- | +| `src/core/kernel/lexical/collectInlineFragments.ts` | **new** — container walk (D20): containers per D1, fragments with `node`/`top`, whitespace kept | +| `src/core/kernel/lexical/inlineMarks.ts` | **new** — serialize fragments to a marked string; parse a marked string back to `{ markId, text }[]`; pure, no Lexical knowledge | +| `src/core/translation-pipeline/types/TextChunk.ts` | `RichContainerChunk` joins `PlainTextChunk` and `RichTextChunk`, plus its guard | +| `src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts` | **new** — one chunk per container; falls back to `RichTextExpander` per D3/D6 | +| `src/core/translation-pipeline/stages/text-expander/TextChunkExpander.ts` | picks the expander by the configured granularity | +| `src/core/translation-pipeline/stages/translation/Translation.stage.ts` | parses and verifies marks, and owns the D7 retranslation pass — it is the stage holding the provider | +| `src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts` | third branch: rebuild `children` from verified fragments; fast path per D5 | +| `src/core/domain/translation-providers/TranslationProvider.interface.ts` | optional `capabilities` (D9) | +| `src/translation-providers/shared/buildSystemPrompt.ts` | mark instruction appended after any override (D11) | +| `src/translation-providers/openai/openAIComplete.ts` | declares `capabilities.inlineMarks` | +| plugin config surface | the transitional flag (D8), JSDoc naming its removal version | +| `docs/DEPRECATIONS.md` | an entry beside `provider-dry-run`, per the register's own format | +| `README.md` | the flag and its lifetime, the provider capability, the dry-run note (D21), the v1 limitation from D12 | + +`collectTextNodes`, `RichTextExpander`, `leafSourceText`, the whole provenance path, +`buildResponseSchema`, `parseAndValidateReply` and `runDryRun` are untouched. No stored fingerprint +changes value, so this design carries no migration. + +--- + +## 6. Build sequence + +1. **Contract tests first, on a stub.** Serializer and parser tests written from §4, red before + any implementation: French adjective, German subordinate clause, link with two formatted + leaves, line break mid-paragraph, a mark-shaped sequence in the source text, a merge expressed + as an empty mark, marks appearing in a reply to an unmarked value, a whitespace-only node + between two words, a reply that trimmed an edge space, a paragraph of adjacent links, and every + row of the reply table. +2. **`collectInlineFragments` + `inlineMarks`** — pure, no pipeline wiring. Turn the tests green. +3. **`RichContainerChunk` + applicator branch** with the D5 fast path, behind the option still + defaulting to `"node"`. +4. **`RichContainerExpander`** and expander selection; D3 and D6 fallbacks. +5. **Provider seams** — capability declaration (D9) and the prompt instruction appended after the + override (D11). +6. **D7 retranslation pass**, then the D22 circuit breaker on top of it. +7. **Fingerprint guard** — a test asserting that a container-mode run leaves stored fingerprints + and staleness verdicts untouched (Q3). Cheap, and it pins D20 against a future tidy-up. +8. **Flag, deprecation entry and docs** (D8, D21), then delete the flag in the next major. + +--- + +## 7. Open questions + +1. **What real content actually contains** — two counts from a live project, not guesses. + (a) Which inline nodes appear inside paragraphs (`linebreak`, `inlineBlock`, mentions, + uploads): the self-closing rule covers them structurally, but corrupt-on-missing is strict, and + a node type models routinely swallow would send containers to the fallback often. + (b) How often a container holds no direct text child (a paragraph of adjacent links), which is + the limitation recorded in §4. Both counts decide whether the simple container rule stands. +2. ~~**How often models drop empty marks**~~ — **answered 2026-09-09, D13 stands.** 99 containers + × French/German/Japanese × four models = 396 translations through the real API: + + | Model | Usable | Corrupt | Cost | + | --- | --- | --- | --- | + | gpt-4o-mini | 98/99 | 1 × `missing-mark` | 0.29 ¢ | + | gpt-4o (today's default) | 98/99 | 1 × `missing-mark` | 4.66 ¢ | + | gpt-5.4-mini | 99/99 | none | 0.86 ¢ | + | gpt-5.5 | 99/99 | none | 11.35 ¢ | + + Fallback rate ≈1% on the older models, zero on the newer ones — the per-container retranslation + of D7 covers it comfortably. Models do use the empty mark rather than omitting it: gpt-4o-mini + returned `<1>製品を購入<2><3>して20%節約しましょう。`, merging mark 2 into its + neighbour. Had D13 demanded text in every mark, that reply would have fallen back. + + Side finding, worth its own task: gpt-5.4-mini is both more accurate and **5× cheaper** than the + `gpt-4o` this plugin still defaults to. gpt-5.5 buys nothing over it and spends 3× the output + tokens (10896 vs 3826), presumably on reasoning a translation does not need. +3. **Group write in the language port** — §8 says the port needs a container-level write beside + the per-unit one. Does that land here (a note in the kernel-extraction doc, implemented when its + phase 2 runs) or does this design ship its own shape and the port adopt it later? Owner call. +4. **Provenance fingerprint direction** — `leafSourceText` + (`src/core/domain/content-projection/translatableLeaf.ts:41-49`) joins source text nodes with + `join("")`. Everything read so far says fingerprints are computed from the source side only, + which would make this change invisible to staleness. To be confirmed by test before step 3, + because if any fingerprint touches the target side, every existing translation flips to stale + on the first container-mode run. + +--- + +## 8. Fit with the core-kernel design + +[2026-08-21-translation-mechanism-kernel-extraction.md](./2026-08-21-translation-mechanism-kernel-extraction.md) +turns each nested data structure into a **language** — one home per structure, answering "what text +is inside this value and how is each piece written back", with `Payload → Lexical → Payload` +nesting declared legal and unbounded. Checked against it, this design lands in three buckets. + +**Lands cleanly.** The container walk, the container rule (D1), mark serialization and parsing are +all Lexical structural knowledge, which is exactly what a language owns. Keeping both granularities +in one home (D20) is that document's §4.3 restated: the shared layer is the language, not the walk. + +**Sharpens a contract that is not built yet.** The language port exposes +`textUnits(leaf) → Array<{ text, write }>` — one independent `write` per unit. Container mode cannot +use that shape: the order of `children` depends on *all* the container's units at once, so writing +one unit in isolation is not a defined operation. The port needs a group write — "here are this +container's units, translated, in reply order; rebuild the children" — alongside the per-unit one. +Worth folding into that design now, while it is still on paper. + +**Deliberately not done.** Merging the projection walk into the pipeline walk. That document's §7 +already carries it as a risk with a guard test, and the reason is ordering, not migration: the +fingerprint must hash the pristine source before any write. This design leaves both walks alone. + +One more borrowed detail: §7 there already plans `fingerprintVersion` for its own phase 5, and +treats a version mismatch as *unknown* rather than *stale*. If a join change ever becomes +unavoidable, that is the mechanism to hang it on — not something to invent for whitespace. + +--- + +## 9. Considered and rejected + +| Option | Why not | +| --- | --- | +| Keep writing into the original nodes, move the formatting instead | Would require reading and writing `format`, and turning a text node into a link node with children. The layer would have to understand Lexical formatting — the one thing this design avoids | +| Structured output: an array of `{ mark, text }` per key | Cleaner than string parsing, but breaks `Record` and with it every hand-written provider | +| Real HTML tags instead of numeric marks | Models "improve" real tags — adding attributes, swapping synonyms. Numeric marks have nothing to improve and are trivial to validate | +| Escape `<` in source text | An escape sequence is itself something the model may "fix" — decode it, duplicate it, drop it. Two directions to implement and test, a new class of failure, and D3 already covers the content it would protect | +| Rare Unicode delimiters instead of angle marks | The model does not recognise them as markup, so it drops them far more often than tags it knows | +| Send neighbouring fields as context, keep per-node granularity | Improves term consistency, not grammar: a fragment still has no correct form outside its sentence, and the context rides along in every request | +| Joining a wrapper's leaves into one fragment (link → one mark, translation written to the first leaf) | No copy needed, but the emphasis inside the link is lost — and losing markup is the defect this design exists to fix | +| Treating a wrapper as a nested container with its own key | No copy needed and markup survives, but the paragraph's own request then shows a placeholder where the link's text was. The model loses exactly the context the whole change is for | +| An optional 4th `translate()` parameter listing which keys carry marks | No consumer: marks are visible in the text itself, so a provider that wants to handle them applies its handling to the whole set. Verifying the marks came back is the core's job, not the provider's | +| A permanent mode option, container mode merely becoming the default in the next major | The case it protects — "the model mangles marks, let me still get some translations" — is already covered by D7 falling back per container, and the double-billing worry by D22. What it costs is two supported modes forever: two test suites, two documented behaviours, and every later feature (glossary, brand voice, fingerprints) verified twice. A config option is a support promise, not a debug switch | +| Merge adjacent fragments sharing one wrapper (D12) | Correct output without it; deferred so v1 stays small | +| Structure-mirroring nested marks | Needs a stack in the parser, and nesting is the thing models break most often. Flat marks have no nesting to break | +| Separate requests for marked and unmarked values | An extra request on every document, and it splits the whole-document context that makes terminology consistent | +| A `parents` array on each fragment | Rebuilding nesting by hand, when pushing `top` carries it by reference. Two references (`node`, `top`) say everything the applicator needs | +| Cloning a node so a repeated mark can occupy two slots | Buys one model behaviour nobody needs and pays with a clone path, a leaf-lookup inside the clone, and a second way for the tree to be built. D13 rejects the repeat instead | +| Widening `collectSerializedLexicalTextNodes` and moving the whitespace filter into its caller | Tidier on paper, but it changes the fingerprint join, so every stored fingerprint stops matching: false staleness across the store and an auto-translate bill the customer never asked for. The drift it avoids is handled by keeping both granularities in one home (D20) | +| Merging the projection walk into the pipeline walk, now or in a later major | Not a migration question — an ordering one. The fingerprint must hash the pristine source *before* any write; a single walk turns that guarantee into a rule someone has to remember, and forgetting it makes every fresh translation read as stale. §7 of the core-kernel design keeps a guard test against exactly this | +| Tolerate a missing mark by inspecting what it wrapped | The branch it buys is only needed because merging had no legal encoding; with empty marks it has one | diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity.task.md new file mode 100644 index 000000000..21d56ad42 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-08-richtext-container-granularity.task.md @@ -0,0 +1,132 @@ +# Task — container granularity, step 1: the two pure modules (#134) + +- **Design:** [2026-09-08-richtext-container-granularity-design.md](./2026-09-08-richtext-container-granularity-design.md) — decisions D1–D22 are settled input, not up for re-litigation here +- **Issue:** [#134](https://github.com/focusreactive/payload-plugins/issues/134) +- **Date:** 2026-09-08 · **Risk:** low · **Scope:** build-sequence steps 1–2 only + +--- + +## What this step builds + +Two pure modules under `src/core/kernel/lexical/`, plus their contract tests. Nothing is +wired into the pipeline, and no existing file changes. + +| Module | Owns | +| --- | --- | +| `collectInlineFragments.ts` | the Lexical side: find containers (D1), emit fragments with `node`/`top` (D17), glue whitespace nodes (D15), flag mark-shaped source text (D3) | +| `inlineMarks.ts` | the string side: serialize fragments to a marked string (D2, D4), parse a reply back, verify the mark set (D13), restore edge whitespace (D16) | + +**Out of scope:** `RichContainerChunk` and the applicator branch, `RichContainerExpander`, +the transitional flag, provider capability, the prompt instruction, the retranslation pass +and the circuit breaker. Per D20, `collectTextNodes.ts`, `leafSourceText` and the whole +provenance path are not touched at all. + +## Implementation decisions + +| # | Decision | Rejected alternative | Constraint it cites | +| --- | --- | --- | --- | +| R1 | Corruption is a **return value**, not an exception: parsing yields a discriminated result carrying either fragments or a reason | Typed errors, as `parseAndValidateReply` throws | Corruption is one of two normal outcomes here — D7 makes per-node retranslation a routine path, not an anomaly. An exception on a routine path forces every caller into try/catch and is easy to swallow. Precedent for a value: `TranslationProvider.translate` returns `null` on failure | +| R2 | Two modules, split along "knows Lexical" vs "knows only strings" | One module covering both | `inlineMarks` has no reason to know what a node is, and mark-format tests would otherwise have to build trees. Matches the directory's habit: `guards`, `collectTextNodes`, `traverseLexicalTree`, `isEmptyRichText` are each one job | +| R3 | Edge-whitespace restoration (D16) lives in the parse step | A separate function, or doing it in the applicator | Parsing already holds both the source fragment texts and the translated ones; anywhere else has to be handed the same data twice. And the applicator must receive finished fragments — that is the core/provider split this design rests on | +| R4 | Neither module is added to `index.ts` yet | Export now for completeness | No caller exists until step 3 (`RichContainerExpander`). Tests import by direct path, as `collectTextNodes.test.ts` does | +| R5 | Own recursion for the container walk, not `traverseLexicalTree` | Reuse the existing traversal | It has no "found what I need, do not descend further" signal — only a full stop. A container must not have its own nested containers visited | + +**Placement:** both files in `src/core/kernel/lexical/`, tests beside them as `*.test.ts`. + +**New surface:** two functions and their result types. Callers: the tests now, and +`RichContainerExpander` at step 3 (named in the design's §5). No caller exists in the +codebase yet — justified because this is step 1 of an approved build sequence, not a +speculative seam. + +**Written contract (what the signatures cannot say):** fragment order is document order · +`top` may be a prepared copy rather than a node from the tree · whitespace-only nodes are +already glued into a neighbouring fragment and never appear as their own fragment · the +parser tolerates stray whitespace inside a mark · a corrupt reply returns a reason and +never partial fragments. This is what puts Phase 3 on the `/sp-red-test` path. + +**Escalate to /sp-architect?** No. The design is already decided in the companion document; +this step is two leaf modules inside one existing directory. + +## Acceptance criteria + +| # | Criterion | How it is checked | Passes when | +|---|---|---|---| +| 1 | Serializing a container wraps every fragment, including unformatted ones (D4), and emits `` for a node with no text (D2) | `bunx vitest run src/core/kernel/lexical/inlineMarks.test.ts -t "serialize"` | exit 0, cases run | +| 2 | A reply whose marks come back reordered yields fragments in the reply's order | same file, `-t "reorder"` | exit 0 | +| 3 | Every verdict row of D13 holds: reorder / empty content / stray space inside a mark are valid; missing, unknown, repeated, nested, crossed, and no-text-at-all are corrupt with a reason | same file, `-t "verdict"` | exit 0 | +| 4 | An edge space the model trimmed is restored (D16) | same file, `-t "edge whitespace"` | exit 0 | +| 5 | Container detection follows D1: a paragraph with direct text is one; a list is not but each item is; a paragraph of only wrappers is not | `bunx vitest run src/core/kernel/lexical/collectInlineFragments.test.ts -t "container"` | exit 0 | +| 6 | A wrapper holding two leaves yields one copy per leaf, and the source tree is left unmutated (D17) | same file, `-t "wrapper"` | exit 0 | +| 7 | A whitespace-only node is glued onto the preceding fragment and never becomes its own mark (D15) | same file, `-t "whitespace"` | exit 0 | +| 8 | Source text containing a mark-shaped sequence is reported as unusable for container mode (D3) | same file, `-t "mark-shaped"` | exit 0 | +| 9 | No existing test breaks | `bun run test` | exit 0, ≥ 1359 tests pass, 0 failures | +| 10 | No new type errors | `bun run check-types` | exit 0 | +| 11 | No new lint findings over the baseline | `bun run lint` | ≤ 58 warnings, 0 errors | +| 12 | D20 holds — the per-node walk, the leaf text notion and the provenance path are untouched | `git diff --stat -- src/core/kernel/lexical/collectTextNodes.ts src/core/domain/content-projection src/core/domain/provenance src/server/modules/provenance` | empty diff | + +### Pre-flight (run against the untouched tree, 2026-09-08) + +| # | Polarity | Result now | +|---|---|---| +| 1–8 | must FAIL now | FAIL — the test files do not exist, vitest matches no file | +| 9 | must PASS now | PASS — 109 files, 1359 tests, all green | +| 10 | must PASS now | PASS — exit 0 | +| 11 | must PASS now | Baseline recorded: **58 warnings, 0 errors** across 452 files. Note: `bun run lint` exits 1 on warnings, so "lint is green" would have been a criterion that already fails — hence the delta form | +| 12 | must PASS now | PASS — empty diff | + +## Risk notes + +- The mark-set check is the piece everything else leans on. If it is wrong in the lenient + direction, corrupt replies reach the tree; in the strict direction, every container + falls back and the mode is pointless. It gets the fullest test coverage of the two modules. +- Copying a wrapper (D17) must not mutate the source node. A test asserts the original + tree is unchanged after collection, because this is the failure that would silently + corrupt a document once step 3 wires the applicator. +- The design's open question 2 (how often models drop empty marks) is answered by a live + run *after* this step. If the answer is bad, D13 changes and the parser changes with it — + which is why nothing above this layer is built yet. + +## Human choices + +| Decision | Chosen | Why | +| --- | --- | --- | +| D6's "unformatted" criterion | **Count leaves, do not read formatting** — a container with a single text leaf is skipped whatever its formatting | "Unformatted" cannot be decided without reading `format`, which this layer refuses to read. One leaf is uniform by construction, so the count answers the same question | +| Mixed nodes (own inline text plus a nested container) | Owner chose "skip and walk the children separately"; **implementation showed the case cannot be detected** — a link inside a paragraph has a direct text child too, so the check swallowed ordinary paragraphs. Dropped: the top-most node with a direct text child is the container, everything below is content | Telling a nested block from an inline wrapper needs a list of block types — the Lexical knowledge this design exists without. Real Lexical trees do not mix the two | +| Edge-whitespace restoration (D16) | Narrowed during implementation: applied **only when the reply has the same shape as the request** (same order, no empty mark) | Two contract tests failed on the original wording: restoring an edge after a reorder produced `une voiture`, and after a merge a space trailing the paragraph. Order and merges change edges legitimately | + +## Review log + +### 2026-09-09 · sp-task step 1 (the two pure modules) + +- **Contract tests, blind author.** 79 checks written from the contract against throwing stubs, by + an agent with no implementation to read — none existed yet. Red run: 79 failed, 0 passed, 0 + module-resolution errors, every failure the stub's own error. Artifact: scratchpad `red-run.txt`. +- **Contract gaps the author surfaced: 23.** Twenty closed in the contract before implementing — + serializer on empty input, reason precedence, `unclosed-mark` as its own reason, text outside a + mark appended rather than dropped, `no-text` judged on non-blank content, edge restoration using + the source's own characters, text-free fragment returned with text, tolerated liberties, mark + numbers need not be contiguous, whitespace/empty leaves, root without children, copy depth. + Three went to the owner (see `## Human choices`). +- **Anti-tautology pass caught two weak checks.** Both "the tree is not mutated" checks would have + passed on an empty implementation, and the unclosed-mark check asserted only "not ok". All three + strengthened before the implementation was written. +- **Green run:** 34/34 and 45/45. Two checks failed on first implementation and the *tests* were + right: edge restoration after a reorder produced `une voiture`, and after a merge a space + trailing the paragraph. D16 was narrowed to same-shape replies only. +- **Mutations: 10, each red on exactly its own checks** — reply order, edge restoration, + missing/repeated/nested verdicts, whitespace glue, wrapper copy, mark-shaped source, single leaf, + and the backwards glue search. All reverted, suite green after each. +- **Fresh-eyes review (sp-review-iteration): one major finding, confirmed and fixed.** + `glueWhitespace` searched only the last produced fragment, so whitespace after a line break was + glued forwards instead of backwards and, at the end of a container, silently dropped. Two red + tests first, then the fix, then a mutation proving they guard it. `inlineMarks` held up: + no `lastIndex` leak, reason precedence matches the documented order. +- **Live model run (design's open question 2, now closed).** 99 containers × + French/German/Japanese × 4 models = 396 translations. Fallback rate ≈1% on gpt-4o and + gpt-4o-mini (one `missing-mark` each), 0% on gpt-5.4-mini and gpt-5.5. Models do return the + empty mark rather than omitting it. Total spend 17.2 ¢. +- **Gates:** `sp-diff-checks` clean (5 checks); `sp-lint-delta` no findings introduced; lint 58 + warnings / 0 errors — the recorded baseline; `check-types` clean; full suite 1442 passed. +- **All 12 acceptance criteria met** by their declared checks, none substituted. +- **Not in this step, by design:** the applicator branch, the expander, the transitional flag, + provider capability, the prompt instruction, the retranslation pass, the circuit breaker. diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-09-container-mode-wiring.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-09-container-mode-wiring.task.md new file mode 100644 index 000000000..d64aa312a --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-09-container-mode-wiring.task.md @@ -0,0 +1,158 @@ +# Task — wiring container mode into the pipeline (#134, step 2) + +- **Design:** [2026-09-08-richtext-container-granularity-design.md](./2026-09-08-richtext-container-granularity-design.md) — D1–D22 settled +- **Step 1 (done):** [2026-09-08-richtext-container-granularity.task.md](./2026-09-08-richtext-container-granularity.task.md) — the two pure modules, review log, live-run results +- **Date:** 2026-09-09 · **Risk:** HIGH · **Scope:** build-sequence steps 3, 4, part of 5, and 8 + +--- + +## Why the risk is high + +`TranslationMutator` is the write path of **every** translation this plugin performs. A defect +here does not fail loudly — it writes a wrong tree into a customer's document. Consequences +downstream: three review angles instead of one (Phase 5b), an evidenced regression sweep +(Phase 4), and a criterion proving byte-identical output while the flag is off. + +## What this step builds + +| Piece | Where | +| --- | --- | +| `RichContainerChunk` + guard | `src/core/translation-pipeline/types/TextChunk.ts` | +| Parsed fragments in the shared context | `src/core/translation-pipeline/types/PipelineContext.ts` | +| Parse + verify the reply | `.../stages/translation/Translation.stage.ts` | +| Rebuild `children`, with the D5 fast path | `.../stages/translation-applicator/TranslationMutator.ts` | +| `RichContainerExpander`, delegating to `RichTextExpander` on D3/D6 | `.../stages/text-expander/` | +| Expander list chosen by the flag | `translateContent.ts` → `TranslationPipeline` | +| `experimental.inlineMarks` flag | `src/plugin.ts`, threaded to the handlers | +| Mark instruction appended after any override | `src/translation-providers/shared/buildSystemPrompt.ts` | +| `capabilities.inlineMarks` | `src/core/domain/translation-providers/TranslationProvider.interface.ts`, declared by the OpenAI provider | +| Deprecation entry | `docs/DEPRECATIONS.md` | + +**Out of scope:** D7 (per-container retranslation) and D22 (circuit breaker) — next step. Until +then a corrupt reply leaves that container untranslated, which is acceptable behind a flag that +is off by default. Changing the default model is a separate task. + +## Implementation decisions + +| # | Decision | Rejected alternative | Constraint it cites | +| --- | --- | --- | --- | +| W1 | The flag reaches the core as a boolean on `translateContent`; the core assembles the expander list | Handlers pass `textExpanders` themselves | An adapter must not know the expander classes. `TranslationPipeline.textExpanders` already exists and stays for tests | +| W2 | `RichContainerExpander` takes `RichTextExpander` as a fallback and delegates inside `expand` | `canExpand` returns false so the next expander in the list runs | `canExpand(chunk, value)` cannot answer without walking the tree, so the walk would run twice — once to decide, once to expand | +| W3 | The reply is parsed and verified in the **translation stage**, which puts fragments into the shared context; the applicator reads them | Parse inside the applicator | Owner's call, 2026-09-09. Step 3 (D7) retranslates a corrupt container, which needs the provider — the applicator has no access to it, so parsing there would have to move a step later | +| W4 | Public flag is `experimental?: { inlineMarks?: boolean }` | A flat `richTextInlineMarks?: boolean` beside `provenance` / `targetSelection` | Owner's call, 2026-09-09: a nest says "do not build on this" more plainly than a JSDoc tag, even though the project has no such nest yet | +| W5 | The mark instruction is appended to whatever `systemPrompt` returns, and its wording is the one the live run validated | Compose it into `defaultPrompt` | `buildSystemPrompt.ts:39-41` returns the builder's string whole, so a builder ignoring `defaultPrompt` would silently drop the rule the format depends on. The wording was proven on 396 translations | + +**New surface:** `RichContainerChunk` (callers: the expander and the applicator, both in this step); +`RichContainerExpander` (caller: the expander list); `capabilities` on the provider port (callers: +the OpenAI provider and the core's mode decision). No speculative seams. + +**Written contract:** the applicator owes callers that a container's `children` end up in the +reply's order, that the fast path leaves the original node objects in place, and that a corrupt +reply leaves the tree untouched. That is what puts the new units on the red-test path. + +**Escalate to /sp-architect?** No — the design is decided in the companion document and W3/W4 were +settled by the owner at this gate. + +## Acceptance criteria + +| # | Criterion | How it is checked | Passes when | +|---|---|---|---| +| 1 | Flag off ⇒ output byte-identical to the captured baseline (per-node translation, structure intact) | new test asserting the recorded baseline: `['[Buy ]','[our product]','[ today]']` and `['[Read the ]', link(['[documentation]']), '[ first]']` | exit 0 | +| 2 | Flag on ⇒ a formatted paragraph is sent as one marked string | test on `textMap` | exit 0 | +| 3 | Reply with reordered marks rebuilds `children` in the reply's order | test on the resulting tree | exit 0 | +| 4 | Reply in the original order takes the fast path — the original node objects are still in the tree | test asserting object identity | exit 0 | +| 5 | Mark-shaped source text (D3) falls back to per-node with the flag on | test | exit 0 | +| 6 | A single-fragment container (D6) falls back to per-node with the flag on | test | exit 0 | +| 7 | **Negative path:** a corrupt reply leaves that container untranslated and the tree unmodified | test | exit 0 | +| 8 | The mark instruction survives a `systemPrompt` that ignores `defaultPrompt` (D11) | test on `buildSystemPrompt` | exit 0 | +| 9 | **Negative path:** a provider without `capabilities.inlineMarks` stays per-node even with the flag on (D9) | test | exit 0 | +| 10 | D20 holds — per-node walk, leaf text notion and provenance path untouched | `git diff --stat` over those paths | empty | +| 11 | No existing test breaks | `bun run test` | ≥ 1442 pass, 0 fail | +| 12 | Types and lint clean | `bun run check-types`; `bun run lint` | exit 0; ≤ 58 warnings, 0 errors | +| 13 | The flag is registered as deprecated | `grep` in `docs/DEPRECATIONS.md` | entry present | + +### Pre-flight (untouched tree, 2026-09-09) + +| # | Polarity | Result now | +|---|---|---| +| 1 | must PASS after the change; **baseline captured now** | Baseline recorded from the current code via scratchpad `baseline.ts`, output in `baseline-output.json` | +| 2–9 | must FAIL now | FAIL — no container mode, no flag, no capability, no mark instruction | +| 10 | must PASS now | PASS — empty diff | +| 11 | must PASS now | PASS — 1442 tests green | +| 12 | must PASS now | PASS — types clean; lint 58 warnings / 0 errors (recorded baseline) | +| 13 | must FAIL now | FAIL — no entry | + +## Risk notes + +- The fast path (D5) exists to keep today's behaviour for most content. If its "same order" + test is wrong in the lenient direction, trees get rebuilt when they need not be — cosmetic. In + the strict direction, nothing is ever rebuilt and the feature is inert. Criterion 4 asserts + object identity precisely to pin this. +- A corrupt reply must never half-write a container. Criterion 7 is the one that would catch the + data-loss shape of this change. +- Three files on the write path are shared by every translation. The regression sweep in Phase 4 + will name what was grepped and how many call sites were read. + +## Human choices + +| Question | Chosen | Why | +| --- | --- | --- | +| Where the reply is parsed | **Translation stage, with parsed fragments in the shared context** | The next step retranslates corrupt containers and needs the provider; parsing in the applicator would have to move | +| Flag shape | **`experimental?: { inlineMarks?: boolean }`** | A nest states "transitional" more plainly than a JSDoc tag | + +## Review log + +### 2026-09-09 · sp-task step 2 (wiring) + +**Four data-corruption defects were found by review, not by the tests written alongside the code.** +Each got a red test first, then the fix, then the suite. + +1. **The flag leaked** (regression angle, critical). `hasInlineMarks` was inferred by regex-testing + every value, so a customer field containing `<1>` — a footnote marker, a template placeholder, + an article about markup — would append the mark instruction with the flag **off**. My own + rejection of an explicit parameter ("marks are visible in the text") was the cause: customer + text is indistinguishable from a mark this pipeline emitted. Fixed by adding + `TranslationRequestOptions` as an optional 4th parameter, stated by the translation stage. The + three-argument call is preserved when no marks were sent, so an existing test asserting the + exact call still passes. +2. **The fast path kept emptied nodes** (correctness + tests angles, both). `sameOrder` compared + only mark order, while `parseInlineMarks`'s `sameShape` also requires non-empty text. A merge + that kept its order therefore left a stray empty node in the tree — the same event dropped the + node when any other mark had moved. +3. **A wrapper copy dropped nodes** (correctness angle). `copyChainToLeaf` keeps only the path to + one leaf, so a line break sitting between two formatted words inside one link vanished from + every copy, with no fragment and no warning. Such containers are now skipped + (`unsupported-wrapper`). +4. **A glued space was written twice** (found by the *comment* audit, in its out-of-scope notes). + A whitespace-only node is glued into a neighbour's text and only the rebuild branch drops it; + the fast path left it in place, rendering `une `. Containers with glued whitespace now + always rebuild. + +**Test weaknesses fixed:** criterion 1 asserted only the first paragraph's texts, so structural +damage to a nested link would have passed — it now pins the full shape of a link-bearing +paragraph; the whole-field fallback (every container skipped) had no coverage; a mixed +plain+richText schema had none; the legacy-provider test pinned today's value instead of proving +delegation. + +**Regression sweep (high risk, evidenced):** `TextChunk` discrimination — 2 sites, both read and +updated; `TranslationProvider` implementations — 3, all read, **and the deprecated wrapper was +found forwarding `translate` but not `capabilities`**, fixed and pinned by a test plus a mutation; +`TranslationMutator.apply` — 1 caller; `buildSystemPrompt` — 1 caller; `translateContent` — 2 +callers; no import cycles; core boundary intact (71 tests). + +**Comment audit:** density was 6.7 per ten added code lines, verdict too-dense, dominant defect +repetition — the same three sentences restated in 3–4 places. Applied: 47 contract-quote lines +deleted from the two kernel test files, six member docblocks restating their own member names, +the repeated rationale in `plugin.ts` replaced by a link to the deprecation register, and two +constants moved out from between a docblock and the function it documented. Kept with named +reasons: the provider capability contract, the measured prompt wording, the `top`-may-be-a-copy +rule, the backwards-glue note, and the frozen baseline marker. + +**Gates:** `sp-diff-checks` clean (5 checks); lint 58 warnings / 0 errors — the recorded baseline, +after fixing one lint **error** and two warnings this change had introduced; `check-types` clean; +full suite 1461 passed. + +**All 13 acceptance criteria met** by their declared checks. + +**Still out of scope, by design:** D7 (per-container retranslation) and D22 (circuit breaker). A +corrupt reply currently leaves that container in its source language. diff --git a/packages/payload-plugin-translator/src/composition/levels/fieldLevel.ts b/packages/payload-plugin-translator/src/composition/levels/fieldLevel.ts index 03a462177..a15907b5f 100644 --- a/packages/payload-plugin-translator/src/composition/levels/fieldLevel.ts +++ b/packages/payload-plugin-translator/src/composition/levels/fieldLevel.ts @@ -32,6 +32,7 @@ export function fieldLevel(): TranslationLevel { translationProvider: ctx.translationProvider, access: ctx.access, basePath: ctx.basePath, + inlineMarks: ctx.inlineMarks, }), ]); }, diff --git a/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts b/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts index 99b702334..627f4609d 100644 --- a/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts +++ b/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts @@ -24,6 +24,24 @@ export type TranslationInput = Record; */ export type TranslationOutput = Record; +/** + * What the core can tell a provider about the request beyond the text itself. + * + * Optional, so every implementation written before it keeps compiling and working. + * + * @since 0.12.0 + */ +export type TranslationRequestOptions = { + /** + * The values carry numbered inline marks the provider must preserve. + * + * Stated rather than sniffed: customer text can legitimately contain `<1>` — a footnote + * reference, a template placeholder, an article about markup — and inferring from content + * would change the prompt for documents that opted into nothing. + */ + inlineMarks?: boolean; +}; + /** * Interface for translation service providers. * @@ -46,6 +64,23 @@ export type TranslationOutput = Record; * ``` */ export interface TranslationProvider { + /** + * What this provider can be asked to do beyond plain translation. Absent means "nothing extra" — + * every provider written before a capability existed keeps working unchanged. + * + * @since 0.12.0 + */ + capabilities?: { + /** + * The provider preserves numbered inline marks (`<1>text`) in the values it returns, + * keeping every number exactly once and moving them where the target language needs them. + * + * Declare it only for a language model that was instructed accordingly. A machine-translation + * API would translate or strip the marks, so without this declaration the core keeps + * translating rich text node by node whatever the plugin config says. + */ + inlineMarks?: boolean; + }; /** * Translates indexed text content from source to target language. * @@ -57,6 +92,7 @@ export interface TranslationProvider { translate( input: TranslationInput, sourceLng: string, - targetLng: string + targetLng: string, + options?: TranslationRequestOptions ): Promise; } diff --git a/packages/payload-plugin-translator/src/core/domain/translation-providers/index.ts b/packages/payload-plugin-translator/src/core/domain/translation-providers/index.ts index 9c28afcfb..260e6638d 100644 --- a/packages/payload-plugin-translator/src/core/domain/translation-providers/index.ts +++ b/packages/payload-plugin-translator/src/core/domain/translation-providers/index.ts @@ -2,6 +2,7 @@ // src/providers (outside core) so the core barrel stays dependency-free (no `openai`). export type { TranslationProvider, + TranslationRequestOptions, TranslationInput, TranslationOutput, TranslationIndex, diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts new file mode 100644 index 000000000..a35a29b65 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/containerMode.test.ts @@ -0,0 +1,385 @@ +import type { Field } from "payload"; +import { describe, expect, it, vi } from "vitest"; +import type { TranslationProvider } from "../domain/translation-providers"; +import { translateContent } from "./translateContent"; + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +const richText = (paragraphs: unknown[]) => ({ + root: { type: "root", children: paragraphs, format: "", indent: 0, version: 1, direction: "ltr" }, +}); + +const link = (url: string, children: unknown[]) => ({ + type: "link", + fields: { url, linkType: "custom" }, + children, + version: 3, +}); + +const schema: Field[] = [{ name: "body", type: "richText", localized: true }]; + +const mixedSchema: Field[] = [ + { name: "body", type: "richText", localized: true }, + { name: "title", type: "text", localized: true }, +]; + +/** Wraps each value so the result is deterministic, and records what was sent. */ +const bracketProvider = ( + extra?: Partial & { capabilities?: { inlineMarks?: boolean } } +) => { + const sent: Record[] = []; + const provider = { + translate: vi.fn(async (input: Record) => { + sent.push({ ...input }); + const out: Record = {}; + for (const [key, value] of Object.entries(input)) out[Number(key)] = `[${value}]`; + return out; + }), + ...extra, + } as TranslationProvider; + return { provider, sent }; +}; + +type Wrapper = { type: string; fields?: { url?: string }; children?: { text?: string }[] }; +type Paragraph = { children: ({ text?: string } & Partial)[] }; + +const paragraphsOf = (data: Record | null): Paragraph[] => { + if (!data) throw new Error("translateContent returned nothing"); + return (data.body as { root: { children: Paragraph[] } }).root.children; +}; + +describe("container mode", () => { + describe("flag off", () => { + // Baseline captured from the untouched tree on 2026-09-09 (see the task contract). + it("translates per node, byte-identical to the recorded baseline", async () => { + const { provider } = bracketProvider(); + const source = { + body: richText([ + paragraph([text("Buy "), text("our product", 1), text(" today")]), + paragraph([text("Read the "), link("/docs", [text("documentation")]), text(" first")]), + ]), + }; + + const result = await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + }); + + const [first, second] = paragraphsOf(result ?? null); + expect(first?.children.map((node) => node.text)).toEqual([ + "[Buy ]", + "[our product]", + "[ today]", + ]); + // The nested link is the half that catches structural damage: text-only assertions pass + // even when a rebuild flattens or duplicates a wrapper. + expect(second?.children).toEqual([ + expect.objectContaining({ type: "text", text: "[Read the ]" }), + expect.objectContaining({ + type: "link", + fields: { url: "/docs", linkType: "custom" }, + children: [expect.objectContaining({ type: "text", text: "[documentation]" })], + }), + expect.objectContaining({ type: "text", text: "[ first]" }), + ]); + }); + }); + + describe("flag on", () => { + it("sends a formatted paragraph as one marked string", async () => { + const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); + const source = { + body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), + }; + + await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(Object.values(sent[0] ?? {})).toEqual(["<1>a <2>red<3> car"]); + }); + + it("rebuilds children in the reply's order when marks move", async () => { + const provider = { + capabilities: { inlineMarks: true }, + translate: vi.fn(async () => ({ 0: "<1>une <3>voiture <2>rouge" })), + } as TranslationProvider; + const source = { + body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), + }; + + const result = await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(paragraphsOf(result ?? null)[0]?.children.map((node) => node.text)).toEqual([ + "une ", + "voiture ", + "rouge", + ]); + }); + + it("translates the caller's paragraph without touching its nodes", async () => { + const nodes = [text("a "), text("red", 1), text(" car")]; + const provider = { + capabilities: { inlineMarks: true }, + translate: vi.fn(async () => ({ 0: "<1>une <2>rouge<3> voiture" })), + } as TranslationProvider; + + const result = await translateContent({ + schema, + sourceData: { body: richText([paragraph(nodes)]) }, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + // Both halves matter: the text alone passes on code that mutates the caller's tree, and the + // untouched source alone passes on code that translated nothing. + expect(paragraphsOf(result ?? null)[0]?.children.map((node) => node.text)).toEqual([ + "une ", + "rouge", + " voiture", + ]); + expect(nodes.map((node) => node.text)).toEqual(["a ", "red", " car"]); + expect(paragraphsOf(result ?? null)[0]?.children[1]).not.toBe(nodes[1]); + }); + + // The point of the whole mode: a wrapper has to travel with the word it formats, so the rebuilt + // array must carry the wrapper node itself, never the bare leaf inside it. + it("moves a link with its word when the reply reorders them", async () => { + const provider = { + capabilities: { inlineMarks: true }, + translate: vi.fn(async () => ({ 0: "<2>Dokumentation<1> lesen" })), + } as TranslationProvider; + + const result = await translateContent({ + schema, + sourceData: { + body: richText([paragraph([text("Read the "), link("/docs", [text("docs")])])]), + }, + sourceLng: "en", + targetLng: "de", + translationProvider: provider, + inlineMarks: true, + }); + + const children = paragraphsOf(result ?? null)[0]?.children; + + expect(children?.[0]?.type).toBe("link"); + expect(children?.[0]?.fields?.url).toBe("/docs"); + expect(children?.[0]?.children?.[0]?.text).toBe("Dokumentation"); + expect(children?.[1]?.text).toBe(" lesen"); + }); + + it("drops the node of a mark that came back empty", async () => { + const provider = { + capabilities: { inlineMarks: true }, + translate: vi.fn(async () => ({ 0: "<1>une voiture rouge<2><3>" })), + } as TranslationProvider; + + const result = await translateContent({ + schema, + sourceData: { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]) }, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(paragraphsOf(result ?? null)[0]?.children.map((node) => node.text)).toEqual([ + "une voiture rouge", + ]); + }); + + it("falls back to per-node when the source holds a mark-shaped sequence", async () => { + const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); + const source = { + body: richText([ + paragraph([text("use "), text("<1>", 1), text(" as a placeholder")]), + paragraph([text("a "), text("red", 1), text(" car")]), + ]), + }; + + await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(Object.values(sent[0] ?? {})).toEqual([ + "use ", + "<1>", + " as a placeholder", + "<1>a <2>red<3> car", + ]); + }); + + it("falls back to per-node for a single-fragment container", async () => { + const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); + const source = { + body: richText([ + paragraph([text("Sign up now")]), + paragraph([text("a "), text("red", 1), text(" car")]), + ]), + }; + + await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(Object.values(sent[0] ?? {})).toEqual([ + "Sign up now", + "<1>a <2>red<3> car", + ]); + }); + + // The glued whitespace node has no fragment, so it is absent from the rebuilt array. Were it + // left in place beside the neighbour that swallowed its space, the space would render twice. + it("does not double a glued space", async () => { + const provider = { + capabilities: { inlineMarks: true }, + translate: vi.fn(async () => ({ 0: "<1>Achetez <2>notre produit" })), + } as TranslationProvider; + + const result = await translateContent({ + schema, + sourceData: { + body: richText([paragraph([text("Buy"), text(" "), text("our product", 1)])]), + }, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(paragraphsOf(result ?? null)[0]?.children.map((node) => node.text)).toEqual([ + "Achetez ", + "notre produit", + ]); + }); + + it("takes the whole field per-node when every container is skipped", async () => { + const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); + const source = { + body: richText([paragraph([text("Sign up now")]), paragraph([text("No card needed")])]), + }; + + await translateContent({ + schema, + sourceData: source, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(Object.values(sent[0] ?? {})).toEqual(["Sign up now", "No card needed"]); + }); + + it("leaves a plain text field to the plain expander", async () => { + const { provider, sent } = bracketProvider({ capabilities: { inlineMarks: true } }); + + await translateContent({ + schema: mixedSchema, + sourceData: { + body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), + title: "Weather data", + }, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(Object.values(sent[0] ?? {})).toEqual([ + "<1>a <2>red<3> car", + "Weather data", + ]); + }); + + it("leaves the container untranslated when the reply is corrupt", async () => { + const provider = { + capabilities: { inlineMarks: true }, + // Mark 3 never comes back: corrupt per D13. + translate: vi.fn(async () => ({ 0: "<1>une <2>rouge" })), + } as TranslationProvider; + + const result = await translateContent({ + schema, + sourceData: { body: richText([paragraph([text("a "), text("red", 1), text(" car")])]) }, + sourceLng: "en", + targetLng: "fr", + translationProvider: provider, + inlineMarks: true, + }); + + expect(paragraphsOf(result ?? null)[0]?.children.map((node) => node.text)).toEqual([ + "a ", + "red", + " car", + ]); + }); + + it("stays per-node when the provider does not declare inlineMarks", async () => { + const body = () => ({ + body: richText([paragraph([text("a "), text("red", 1), text(" car")])]), + }); + const silent = bracketProvider(); + const declaring = bracketProvider({ capabilities: { inlineMarks: true } }); + + for (const each of [silent, declaring]) { + await translateContent({ + schema, + sourceData: body(), + sourceLng: "en", + targetLng: "fr", + translationProvider: each.provider, + inlineMarks: true, + }); + } + + expect(Object.values(silent.sent[0] ?? {})).toEqual(["a ", "red", " car"]); + expect(Object.values(declaring.sent[0] ?? {})).toEqual(["<1>a <2>red<3> car"]); + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/index.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/index.ts index d54d9e4bf..d56b3cd85 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/index.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/index.ts @@ -4,6 +4,7 @@ export { TextChunkExpander, PlainTextExpander, RichTextExpander, + RichContainerExpander, TextChunkExpanderStage, } from "./text-expander"; export type { TextExpansionResult, TextExpander, ExpansionResult } from "./text-expander"; diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts new file mode 100644 index 000000000..4f65ed83c --- /dev/null +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts @@ -0,0 +1,88 @@ +import { collectInlineFragments } from "../../../kernel/lexical/collectInlineFragments"; +import { + collectSerializedLexicalTextNodes, + isSerializedLexicalRoot, +} from "../../../kernel/lexical"; +import { serializeInlineMarks } from "../../../kernel/lexical/inlineMarks"; +import type { FieldChunk, RichTextChunk, TextChunk } from "../../types"; +import { RichTextExpander } from "./RichTextExpander"; +import type { ExpansionResult, TextExpander } from "./TextExpander.interface"; + +/** + * Expands richText into one chunk per container, so a whole sentence reaches the model and its + * pieces can come back reordered. + * + * Containers the marked format cannot serve — a mark-shaped sequence in the source, or a single + * fragment with nothing to reorder — fall back to the per-node expander. The fallback is held + * here rather than expressed as `canExpand: false` because deciding requires walking the tree, + * and a second expander would walk it again. + */ +export class RichContainerExpander implements TextExpander { + private readonly perNode: TextExpander; + + constructor(perNode: TextExpander = new RichTextExpander()) { + this.perNode = perNode; + } + + canExpand(chunk: FieldChunk, value: unknown): boolean { + return chunk.schema.type === "richText" && isSerializedLexicalRoot(value); + } + + expand(chunk: FieldChunk, value: unknown, startIndex: number): ExpansionResult { + const containers = collectInlineFragments( + (value as { root: Parameters[0] }).root + ); + + if (containers.every((container) => container.skip)) { + return this.perNode.expand(chunk, value, startIndex); + } + + // Skipped containers keep the per-node behaviour exactly, so their chunks come from the + // per-node expander rather than from the fragments — the two disagree on whitespace nodes, + // and a fallback that translated something different from today would not be a fallback. + const perNodeChunks = this.perNode + .expand(chunk, value, 0) + .chunks.filter((each): each is RichTextChunk => each.type === "richText"); + + const chunks: TextChunk[] = []; + const textMap: Record = {}; + let index = startIndex; + + const emit = (text: string, chunkOf: (at: number) => TextChunk) => { + chunks.push(chunkOf(index)); + textMap[index] = text; + index += 1; + }; + + let ordinal = 0; + + for (const container of containers) { + if (container.skip) { + ordinal += 1; + const nodes = new Set( + collectSerializedLexicalTextNodes(container.node).map((ref) => ref.node) + ); + for (const perNodeChunk of perNodeChunks) { + if (!nodes.has(perNodeChunk.nodeRef)) continue; + emit(perNodeChunk.text, (at) => ({ ...perNodeChunk, index: at })); + } + continue; + } + + const text = serializeInlineMarks(container.fragments); + const place = { path: chunk.path, container: ordinal }; + ordinal += 1; + + emit(text, (at) => ({ + type: "richContainer", + index: at, + text, + containerRef: container.node, + fragments: container.fragments, + place, + })); + } + + return { chunks, textMap, nextIndex: index }; + } +} diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/index.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/index.ts index 5758f0b37..5452ab170 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/index.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/index.ts @@ -3,4 +3,5 @@ export type { TextExpansionResult } from "./TextChunkExpander"; export type { TextExpander, ExpansionResult } from "./TextExpander.interface"; export { PlainTextExpander } from "./PlainTextExpander"; export { RichTextExpander } from "./RichTextExpander"; +export { RichContainerExpander } from "./RichContainerExpander"; export { TextChunkExpanderStage } from "./TextChunkExpander.stage"; diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts index b58c6f5d3..12c5e84a7 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts @@ -11,7 +11,7 @@ export class TranslationMutatorStage implements PipelineStage { } const mutator = new TranslationMutator(); - mutator.apply(ctx.textChunks, ctx.translations); + mutator.apply(ctx.textChunks, ctx.translations, ctx.containerFragments); return ctx; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts index a474e8ed0..5262fee75 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts @@ -1,5 +1,35 @@ -import type { TextChunk } from "../../types"; -import { isPlainTextChunk, isRichTextChunk } from "../../types"; +import type { ParsedMark } from "../../../kernel/lexical/inlineMarks"; +import { hasChildren } from "../../../kernel/lexical"; +import type { SerializedLexicalNode } from "../../../kernel/lexical"; +import type { RichContainerChunk, TextChunk } from "../../types"; +import { isPlainTextChunk, isRichContainerChunk, isRichTextChunk } from "../../types"; + +/** + * Writes a container's reply back by rebuilding `children` in the reply's order — which is what + * carries a mark's formatting to the word it now belongs to. + * + * A mark that came back empty means its fragment merged into a neighbour, so its node leaves the + * tree instead of staying behind as an empty one. A node that never had a fragment — the whitespace + * glued into a neighbour's text — is likewise absent from the rebuilt array, which is what stops + * its space being rendered twice. + */ +function applyContainer(chunk: RichContainerChunk, parsed: ParsedMark[]): void { + if (!hasChildren(chunk.containerRef)) return; + + const byMarkId = new Map(chunk.fragments.map((fragment) => [fragment.markId, fragment])); + const children: SerializedLexicalNode[] = []; + for (const mark of parsed) { + const fragment = byMarkId.get(mark.markId); + if (!fragment) continue; + if (fragment.node) { + if (!mark.text) continue; + fragment.node.text = mark.text; + } + children.push(fragment.top); + } + + chunk.containerRef.children = children; +} /** * Applies translations by mutating data through TextChunk references. @@ -15,8 +45,18 @@ export class TranslationMutator { * @param translations - Map of index -> translated text * @returns Mutation result with count of translated chunks */ - apply(textChunks: TextChunk[], translations: Record): void { + apply( + textChunks: TextChunk[], + translations: Record, + containerFragments?: Record + ): void { for (const chunk of textChunks) { + if (isRichContainerChunk(chunk)) { + const parsed = containerFragments?.[chunk.index]; + if (parsed) applyContainer(chunk, parsed); + continue; + } + const translation = translations[chunk.index]; if (translation === undefined) continue; diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts index b925842ef..6d2b4fc14 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts @@ -1,6 +1,30 @@ +import { parseInlineMarks } from "../../../kernel/lexical/inlineMarks"; +import type { ParsedMark } from "../../../kernel/lexical/inlineMarks"; import type { PipelineContext, PipelineStage } from "../../types"; +import { isRichContainerChunk } from "../../types"; import type { TranslationProvider } from "../../../domain/translation-providers"; +/** A container missing from the result had a corrupt reply and keeps its source text. */ +function parseContainerReplies( + ctx: PipelineContext, + translations: Record +): Record | undefined { + const containers = (ctx.textChunks ?? []).filter(isRichContainerChunk); + if (containers.length === 0) return undefined; + + const parsed: Record = {}; + + for (const chunk of containers) { + const reply = translations[chunk.index]; + if (reply === undefined) continue; + + const result = parseInlineMarks(reply, chunk.fragments); + if (result.ok) parsed[chunk.index] = result.fragments; + } + + return parsed; +} + /** * Calls translation provider to translate text. */ @@ -12,7 +36,15 @@ export class TranslationStage implements PipelineStage { return ctx; } - const translations = await this.provider.translate(ctx.textMap, ctx.sourceLng, ctx.targetLng); + const inlineMarks = (ctx.textChunks ?? []).some(isRichContainerChunk); + + // Called with three arguments when no marks were sent, so a provider (or a test) that + // inspects the call sees exactly what it saw before this feature existed. + const translations = inlineMarks + ? await this.provider.translate(ctx.textMap, ctx.sourceLng, ctx.targetLng, { + inlineMarks: true, + }) + : await this.provider.translate(ctx.textMap, ctx.sourceLng, ctx.targetLng); if (!translations) { throw new Error("Translation provider returned null"); @@ -21,6 +53,7 @@ export class TranslationStage implements PipelineStage { return { ...ctx, translations, + containerFragments: parseContainerReplies(ctx, translations), }; } } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts index 9307a7175..fa87f3bb1 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.test.ts @@ -168,6 +168,28 @@ describe("translateContent", () => { expect(result).not.toEqual(before); }); + it("is unchanged after a rich-text translation in container mode", async () => { + const sourceData = { body: richTextValue(["Hello ", "world"]) }; + const before = structuredClone(sourceData); + + const marksProvider = { + capabilities: { inlineMarks: true }, + translate: async () => ({ 0: "<2>Welt<1>Hallo " }), + } as TranslationProvider; + + const result = await translateContent({ + schema: richSchema, + sourceData, + sourceLng: "en", + targetLng: "de", + translationProvider: marksProvider, + inlineMarks: true, + }); + + expect(sourceData).toEqual(before); + expect(result).not.toEqual(before); + }); + it("leaves the source fingerprint identical either side of a translation", async () => { const sourceData = { body: richTextValue(["Hello ", "world"]) }; const before = computeSourceFingerprint(sourceData, richSchema); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts index f0d367728..26a2044d0 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts @@ -1,6 +1,7 @@ import type { FieldLike } from "../kernel/field-traversal"; import type { TranslationProvider } from "../domain/translation-providers"; import { TranslationPipeline } from "./TranslationPipeline"; +import { PlainTextExpander, RichContainerExpander } from "./stages"; import { createTranslationStrategy } from "./strategies"; import type { TranslationStrategyName } from "./strategies"; @@ -21,6 +22,16 @@ export type TranslateContentArgs = { translationProvider: TranslationProvider; /** @default 'overwrite' */ strategy?: TranslationStrategyName; + /** + * Translate each rich-text container (paragraph, heading, list item) as one marked string + * instead of node by node, so the model may reorder its pieces. + * + * Ignored unless the provider declares `capabilities.inlineMarks`: a provider that is not a + * language model would mangle the marks. + * + * @default false + */ + inlineMarks?: boolean; }; /** @@ -44,10 +55,14 @@ export async function translateContent({ targetLng, translationProvider, strategy = "overwrite", + inlineMarks = false, }: TranslateContentArgs): Promise | null> { + const marksUsable = inlineMarks && translationProvider.capabilities?.inlineMarks === true; + const pipeline = new TranslationPipeline({ translationProvider, translationStrategy: createTranslationStrategy(strategy), + textExpanders: marksUsable ? [new RichContainerExpander(), new PlainTextExpander()] : undefined, }); const result = await pipeline.execute({ diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts index 7f689ff97..bb8df0ec9 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts @@ -1,4 +1,5 @@ import type { FieldLike } from "../../kernel/field-traversal"; +import type { ParsedMark } from "../../kernel/lexical/inlineMarks"; import type { FieldChunk } from "./FieldChunk"; import type { TextChunk } from "./TextChunk"; @@ -19,6 +20,11 @@ export type PipelineContext = { textChunks?: TextChunk[]; textMap?: Record; translations?: Record; + /** + * Parsed marked replies, keyed by chunk index. A container missing here had a corrupt reply and + * must be left in its source language. + */ + containerFragments?: Record; }; /** diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts index 78056c3f5..cbbb10270 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts @@ -1,4 +1,5 @@ -import type { SerializedTextNode } from "../../kernel/lexical"; +import type { InlineFragment } from "../../kernel/lexical/collectInlineFragments"; +import type { SerializedLexicalNode, SerializedTextNode } from "../../kernel/lexical"; /** * Text chunk for plain text/textarea fields. @@ -30,11 +31,34 @@ export type RichTextChunk = { nodeRef: SerializedTextNode; }; +/** + * Text chunk for a whole rich-text container (paragraph, heading, list item). + * + * One chunk per container rather than per text node, so the model receives a connected sentence + * and may reorder its pieces. + */ +export type RichContainerChunk = { + type: "richContainer"; + index: number; + /** The marked string sent for translation, not source prose — never hash or display it */ + text: string; + /** The container whose `children` are rebuilt when the reply reorders marks */ + containerRef: SerializedLexicalNode; + /** Fragments in document order, as collected */ + fragments: InlineFragment[]; + /** Where this container lives, for reporting it when its reply cannot be used. */ + place: { path: string[]; container: number }; +}; + /** * Union type for all text chunks. * Schema-independent - contains only data references for mutation. */ -export type TextChunk = PlainTextChunk | RichTextChunk; +export type TextChunk = PlainTextChunk | RichTextChunk | RichContainerChunk; + +export function isRichContainerChunk(chunk: TextChunk): chunk is RichContainerChunk { + return chunk.type === "richContainer"; +} /** * Type guard for PlainTextChunk. diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/index.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/index.ts index 67e3f2310..e5e241e36 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/index.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/index.ts @@ -1,5 +1,5 @@ export type { FieldChunk } from "./FieldChunk"; -export type { PlainTextChunk, RichTextChunk, TextChunk } from "./TextChunk"; -export { isPlainTextChunk, isRichTextChunk } from "./TextChunk"; +export type { PlainTextChunk, RichTextChunk, RichContainerChunk, TextChunk } from "./TextChunk"; +export { isPlainTextChunk, isRichTextChunk, isRichContainerChunk } from "./TextChunk"; export type { PipelineConfig, PipelineResult } from "./Pipeline"; export type { PipelineContext, PipelineStage } from "./PipelineContext"; diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts index 6835406ac..2870ec62a 100644 --- a/packages/payload-plugin-translator/src/plugin.ts +++ b/packages/payload-plugin-translator/src/plugin.ts @@ -92,6 +92,28 @@ export type TranslatorPluginConfig = { * @since 0.10.0 */ targetSelection?: TargetSelectionMode; + /** + * Where switches for behaviour on its way to becoming the default live. The option itself is + * permanent; each **entry** is deprecated the day it ships, because it exists only so you can + * adopt a change early and the next major removes the switch, not the behaviour. + * + * @since 0.12.0 + */ + experimental?: { + /** + * Translate each rich-text container (paragraph, heading, list item) as one string with + * numbered inline marks, instead of node by node. + * + * Requires a provider declaring `capabilities.inlineMarks`; without one the plugin keeps + * translating node by node. A container whose reply comes back with damaged marks is left in + * its source language rather than half-written. + * + * @default false + * @deprecated Transitional. Removed in the next major, when this becomes the only mode. + * @see docs/DEPRECATIONS.md#experimental-inline-marks — why this exists and when it goes + */ + inlineMarks?: boolean; + }; }; /** @deprecated Use `TranslatorPluginConfig` instead */ @@ -116,6 +138,7 @@ export class TranslateCollectionPlugin { provenance, lifecycle, targetSelection = "single", + experimental, basePath: rawBasePath = "/translate", } = this.pluginConfig; @@ -133,7 +156,10 @@ export class TranslateCollectionPlugin { // Each concern owns its own config-time wiring and exposes it uniformly; init() just composes. const provenanceModule = configureProvenance(provenance, schemaMap); + const inlineMarks = experimental?.inlineMarks === true; + const { taskRunnerFactory, configModifier: runnerConfigModifier } = wireTranslateRunner({ + inlineMarks, translationProvider, schemaMap, provenanceServiceFactory: provenanceModule.serviceFactory, @@ -155,6 +181,7 @@ export class TranslateCollectionPlugin { translationProvider, provenanceServiceFactory: provenanceModule.serviceFactory, targetSelection, + inlineMarks, }); for (const level of activeLevels) level.extend(builder); 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 c082dddbd..47c712da4 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 @@ -28,15 +28,18 @@ export class TranslateDocumentHandler implements Handler< private readonly translationProvider: TranslationProvider; private readonly schemaMap: CollectionSchemaMap; private readonly provenanceServiceFactory?: ProvenanceServiceFactory; + private readonly inlineMarks: boolean; constructor( translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap, - provenanceServiceFactory?: ProvenanceServiceFactory + provenanceServiceFactory?: ProvenanceServiceFactory, + inlineMarks = false ) { this.translationProvider = translationProvider; this.schemaMap = schemaMap; this.provenanceServiceFactory = provenanceServiceFactory; + this.inlineMarks = inlineMarks; } async handle(payload: Payload, input: TranslateDocumentInput): Promise { @@ -77,6 +80,7 @@ export class TranslateDocumentHandler implements Handler< targetLng, translationProvider: this.translationProvider, strategy, + inlineMarks: this.inlineMarks, }); if (translatedData) { diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts index dedf0b219..8ad89cfd4 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts @@ -25,6 +25,7 @@ type WireTranslateRunnerParams = { runner: TaskRunnerProvider; lifecycle: TranslationLifecycleCallbacks; collections: CollectionSlug[]; + inlineMarks?: boolean; }; /** @@ -42,6 +43,7 @@ export function wireTranslateRunner({ runner, lifecycle, collections, + inlineMarks = false, }: WireTranslateRunnerParams): { taskRunnerFactory: TaskRunnerFactory; configModifier: ConfigModifier; @@ -49,7 +51,8 @@ export function wireTranslateRunner({ const translateHandler = new TranslateDocumentHandler( translationProvider, schemaMap, - provenanceServiceFactory + provenanceServiceFactory, + inlineMarks ); const runnerContext: TaskRunnerContext = { diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts index fe674df43..69a23c841 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts @@ -119,6 +119,7 @@ export class TranslateFieldHandler { sourceLng: source_lng, targetLng: target_lng, translationProvider: this.config.translationProvider, + inlineMarks: this.config.inlineMarks, }); if (!translated) { diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts index 057f3ba05..bd3ffe23a 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts @@ -36,4 +36,5 @@ export type FieldTranslationInput = z.infer; export type FieldTranslationConfig = { schemaMap: CollectionSchemaMap; translationProvider: TranslationProvider; + inlineMarks?: boolean; }; diff --git a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts index a617ba5d6..bf0bbf455 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts @@ -75,6 +75,7 @@ export class PluginConfigBuilder implements LevelContext { readonly translationProvider: TranslationProvider; readonly provenanceServiceFactory?: ProvenanceServiceFactory; readonly targetSelection: TargetSelectionMode; + readonly inlineMarks?: boolean; private readonly endpoints: Endpoint[] = []; private readonly collectionComponents: CollectionComponent[] = []; @@ -90,6 +91,7 @@ export class PluginConfigBuilder implements LevelContext { this.translationProvider = deps.translationProvider; this.provenanceServiceFactory = deps.provenanceServiceFactory; this.targetSelection = deps.targetSelection; + this.inlineMarks = deps.inlineMarks; } addEndpoints(endpoints: Endpoint[]): void { diff --git a/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts b/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts index b0c50b788..00b8bce1a 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts @@ -30,6 +30,7 @@ export type TranslationContext = { /** Resolved target-language selection mode (`'single'` default) — drives which target control the * admin forms render. */ readonly targetSelection: TargetSelectionMode; + readonly inlineMarks?: boolean; }; /** diff --git a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslation.provider.ts b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslation.provider.ts index 6c182a842..7d0b392e7 100644 --- a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslation.provider.ts +++ b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslation.provider.ts @@ -131,6 +131,7 @@ export function createOpenAIProvider(config: OpenAIProviderConfig): TranslationP return createTranslationProvider({ systemPrompt, dryRun, + capabilities: { inlineMarks: true }, complete: async (request) => { const client = await resolveClient(); return openAIComplete({ client, model, sampling, structuredOutput })(request); diff --git a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.test.ts b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.test.ts index 03a5860f2..37ce31364 100644 --- a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.test.ts +++ b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.test.ts @@ -58,4 +58,10 @@ describe("OpenAITranslationProvider (deprecated class)", () => { expect(result).toEqual({ 0: "olleH" }); expect(loadClient).not.toHaveBeenCalled(); }); + + it("reports the same capabilities as the factory it wraps", () => { + const provider = new OpenAITranslationProvider({ apiKey: "sk-test" }); + + expect(provider.capabilities?.inlineMarks).toBe(true); + }); }); diff --git a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.ts b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.ts index 693b5a89b..8037f46d3 100644 --- a/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.ts +++ b/packages/payload-plugin-translator/src/translation-providers/openai/OpenAITranslationLegacy.provider.ts @@ -1,4 +1,5 @@ import type { + TranslationRequestOptions, TranslationInput, TranslationOutput, TranslationProvider, @@ -17,11 +18,16 @@ export class OpenAITranslationProvider implements TranslationProvider { this.inner = createOpenAIProvider(config); } + get capabilities(): TranslationProvider["capabilities"] { + return this.inner.capabilities; + } + translate( input: TranslationInput, sourceLng: string, - targetLng: string + targetLng: string, + options?: TranslationRequestOptions ): Promise { - return this.inner.translate(input, sourceLng, targetLng); + return this.inner.translate(input, sourceLng, targetLng, options); } } diff --git a/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts b/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts index 06d48a12c..b47be1e04 100644 --- a/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts +++ b/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts @@ -2,6 +2,7 @@ import type { TranslationInput, TranslationOutput, TranslationProvider, + TranslationRequestOptions, } from "../../core/domain/translation-providers"; import { buildResponseSchema } from "./buildResponseSchema"; import type { JsonSchemaObject } from "./buildResponseSchema"; @@ -44,6 +45,14 @@ export type CompletionFn = (request: CompletionRequest) => Promise; * @since 0.11.0 */ export type TranslationProviderConfig = { + /** + * What the transport can be asked to do beyond plain translation — passed through to the + * provider unchanged. A transport that is not a language model must leave `inlineMarks` unset: + * the core then keeps translating rich text node by node. + * + * @since 0.12.0 + */ + capabilities?: TranslationProvider["capabilities"]; complete: CompletionFn; systemPrompt?: SystemPromptBuilder; /** @@ -120,7 +129,7 @@ function guardTransformer(dryRun: boolean | DryRunConfig): boolean | DryRunConfi * @since 0.11.0 */ export function createTranslationProvider(config: TranslationProviderConfig): TranslationProvider { - const { complete, systemPrompt, dryRun } = config; + const { complete, systemPrompt, dryRun, capabilities } = config; if (dryRun) { const guarded = guardTransformer(dryRun); @@ -128,17 +137,24 @@ export function createTranslationProvider(config: TranslationProviderConfig): Tr } return { + capabilities, async translate( input: TranslationInput, sourceLng: string, - targetLng: string + targetLng: string, + options?: TranslationRequestOptions ): Promise { // A strict response schema with no properties is rejected by the service, so an empty // document must not reach the transport at all. if (Object.keys(input).length === 0) return {}; const systemPromptText = await asConfigurationFailure("systemPrompt builder", () => - buildSystemPrompt({ sourceLng, targetLng, override: systemPrompt }) + buildSystemPrompt({ + sourceLng, + targetLng, + override: systemPrompt, + hasInlineMarks: options?.inlineMarks === true, + }) ); const request = await asConfigurationFailure("serialization of the input", () => ({ diff --git a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.test.ts b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.test.ts index 7b3622846..25be0051c 100644 --- a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.test.ts +++ b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.test.ts @@ -53,4 +53,30 @@ describe("buildSystemPrompt", () => { buildSystemPrompt({ sourceLng: "en", targetLng: "fr", override: () => "only this" }) ).toBe("only this"); }); + + describe("inline marks", () => { + it("appends the mark instruction when the values carry marks", () => { + const prompt = buildSystemPrompt({ sourceLng: "en", targetLng: "de", hasInlineMarks: true }); + + expect(prompt).toContain("Return every mark exactly once"); + }); + + it("says nothing about marks when no value carries one", () => { + const prompt = buildSystemPrompt({ sourceLng: "en", targetLng: "de" }); + + expect(prompt).not.toContain("mark"); + }); + + it("appends the mark instruction after an override that ignores defaultPrompt", () => { + const prompt = buildSystemPrompt({ + sourceLng: "en", + targetLng: "de", + hasInlineMarks: true, + override: () => "Translate to German. Be formal.", + }); + + expect(prompt.startsWith("Translate to German. Be formal.")).toBe(true); + expect(prompt).toContain("Return every mark exactly once"); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts index 3270ca192..986efb09b 100644 --- a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts +++ b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts @@ -1,3 +1,13 @@ +/** + * Wording validated against 396 live translations (French, German, Japanese × four models) before + * it shipped: the fallback rate was ~1% on gpt-4o and zero on newer models. Reword it only with + * the same measurement in hand. + */ +const INLINE_MARKS_INSTRUCTION = `Some values contain numbered inline marks, written as <1>text or <5/>. They carry formatting, not content. +Return every mark exactly once, keeping its number, and put each mark where the translated sentence needs it — the order of marks may change. +If a piece of text merges into a neighbouring mark, return the emptied mark as <2>; never omit a mark. +Never introduce a mark into a value that has none, and never nest marks.`; + /** * Context handed to a {@link SystemPromptBuilder}. * @@ -27,8 +37,14 @@ export function buildSystemPrompt(args: { sourceLng: string; targetLng: string; override?: SystemPromptBuilder; + /** + * Whether the values being sent carry inline marks. Appended **after** whatever `override` + * returns: a builder is free to ignore `defaultPrompt`, and the format must not depend on it + * remembering to include this rule. + */ + hasInlineMarks?: boolean; }): string { - const { sourceLng, targetLng, override } = args; + const { sourceLng, targetLng, override, hasInlineMarks } = args; const defaultPrompt = `Translate the values from the JSON that the user will send you${ sourceLng ? ` from ${sourceLng}` : "" @@ -36,9 +52,9 @@ export function buildSystemPrompt(args: { The response should be a valid JSON object with the same structure and keys as the input, but with translated values. Maintain any special formatting, placeholders, or variables within the values if they exist.`; - if (override) { - return override({ sourceLang: sourceLng, targetLang: targetLng, defaultPrompt }); - } + const prompt = override + ? override({ sourceLang: sourceLng, targetLang: targetLng, defaultPrompt }) + : defaultPrompt; - return defaultPrompt; + return hasInlineMarks ? `${prompt}\n\n${INLINE_MARKS_INSTRUCTION}` : prompt; } From 853f2581a0f62d2e5a7629d5a661b4c763124da0 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 17:34:47 +0200 Subject: [PATCH 02/11] chore(translator): drop the container address this change does not use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RichContainerExpander` computed a per-container ordinal and stamped each chunk with `place: { path, container }`, and `RichContainerChunk` declared the field. Nothing in this change reads either. The address exists for the untranslated report, which is the next change and is where it will arrive together with the code that consumes it. Carrying it early means a field computed on every container, declared in a public-facing chunk type, and dead — which is the speculative code this repository's rules forbid, and which this PR's own message argues against while shipping an instance of it. Found while explaining the expander rather than by a check: the report was split out of this PR by searching for `untranslated`, and `place` does not contain that word. Verification: 1488 unit tests, unchanged in count and all green; check-types clean; lint 58 warnings and 0 errors, identical to main; declaration build passes. --- .../stages/text-expander/RichContainerExpander.ts | 6 ------ .../src/core/translation-pipeline/types/TextChunk.ts | 2 -- 2 files changed, 8 deletions(-) diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts index 4f65ed83c..47b31d564 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts @@ -54,11 +54,8 @@ export class RichContainerExpander implements TextExpander { index += 1; }; - let ordinal = 0; - for (const container of containers) { if (container.skip) { - ordinal += 1; const nodes = new Set( collectSerializedLexicalTextNodes(container.node).map((ref) => ref.node) ); @@ -70,8 +67,6 @@ export class RichContainerExpander implements TextExpander { } const text = serializeInlineMarks(container.fragments); - const place = { path: chunk.path, container: ordinal }; - ordinal += 1; emit(text, (at) => ({ type: "richContainer", @@ -79,7 +74,6 @@ export class RichContainerExpander implements TextExpander { text, containerRef: container.node, fragments: container.fragments, - place, })); } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts index cbbb10270..c7fa39424 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts @@ -46,8 +46,6 @@ export type RichContainerChunk = { containerRef: SerializedLexicalNode; /** Fragments in document order, as collected */ fragments: InlineFragment[]; - /** Where this container lives, for reporting it when its reply cannot be used. */ - place: { path: string[]; container: number }; }; /** From 051fe7a9ffd5fb4067bef02d3436a133cb47961f Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 19:14:28 +0200 Subject: [PATCH 03/11] docs(translator): name the release these additions actually ship in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four `@since 0.12.0` annotations said the version the package is already on. This change is a `feat` on top of 0.12.0, so everything it adds arrives in 0.13.0 — `TranslationRequestOptions`, `TranslationProvider.capabilities`, the transport's pass-through of the same, and the `experimental` plugin option. They were written while 0.12.0 was still the next release; it was taken by the default-model change, and the annotations did not move with it. The package's own rule is to derive the version from the latest tag plus the highest bump in the change, which is what makes them checkable at all. The deprecation register's entry also cited `#134`, the issue, where every other entry cites the PR. --- packages/payload-plugin-translator/docs/DEPRECATIONS.md | 2 +- .../translation-providers/TranslationProvider.interface.ts | 4 ++-- packages/payload-plugin-translator/src/plugin.ts | 2 +- .../shared/CompletionProvider.provider.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index eb5ae8cd7..242d16a50 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -223,7 +223,7 @@ the single source of truth — code annotations link here by anchor instead of d - **What:** `translatorPlugin({ experimental: { inlineMarks } })`. - **Status:** live (`@deprecated` in code from the day it shipped) -- **Deprecated:** 2026-09-09 / #134 +- **Deprecated:** 2026-09-11 / PR #139 (issue #134) - **Replacement:** none — the behaviour becomes the only mode, so the switch simply goes away. - **Scope:** this entry only. The `experimental` option itself is permanent — it is where the next transitional switch will live, so removing `inlineMarks` does not remove the object. diff --git a/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts b/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts index 627f4609d..c20966a77 100644 --- a/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts +++ b/packages/payload-plugin-translator/src/core/domain/translation-providers/TranslationProvider.interface.ts @@ -29,7 +29,7 @@ export type TranslationOutput = Record; * * Optional, so every implementation written before it keeps compiling and working. * - * @since 0.12.0 + * @since 0.13.0 */ export type TranslationRequestOptions = { /** @@ -68,7 +68,7 @@ export interface TranslationProvider { * What this provider can be asked to do beyond plain translation. Absent means "nothing extra" — * every provider written before a capability existed keeps working unchanged. * - * @since 0.12.0 + * @since 0.13.0 */ capabilities?: { /** diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts index 2870ec62a..c1989decb 100644 --- a/packages/payload-plugin-translator/src/plugin.ts +++ b/packages/payload-plugin-translator/src/plugin.ts @@ -97,7 +97,7 @@ export type TranslatorPluginConfig = { * permanent; each **entry** is deprecated the day it ships, because it exists only so you can * adopt a change early and the next major removes the switch, not the behaviour. * - * @since 0.12.0 + * @since 0.13.0 */ experimental?: { /** diff --git a/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts b/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts index b47be1e04..2188abced 100644 --- a/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts +++ b/packages/payload-plugin-translator/src/translation-providers/shared/CompletionProvider.provider.ts @@ -50,7 +50,7 @@ export type TranslationProviderConfig = { * provider unchanged. A transport that is not a language model must leave `inlineMarks` unset: * the core then keeps translating rich text node by node. * - * @since 0.12.0 + * @since 0.13.0 */ capabilities?: TranslationProvider["capabilities"]; complete: CompletionFn; From 7631375a45b4f62a95b3363866745c4319aff9c0 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 19:37:46 +0200 Subject: [PATCH 04/11] refactor(translator): keep a container's parsed reply on its own chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TranslationStage` put parsed reply marks in `PipelineContext.containerFragments`, a `Record`, and `TranslationMutator.apply` took that map as a third parameter and joined it back to each chunk by `chunk.index`. The reply now lives on `RichContainerChunk.reply`; the context field and the third parameter are gone. `chunk.index` is the chunk's own position in `textChunks` — `TextChunkExpander` runs one dense counter across all fields and every expander increments by one per chunk emitted. So the map was a sparse column over an array already indexed by the same number, and its only job was putting the two halves of one write back together. The chunk already holds every other half: `containerRef`, and each fragment's `node` and `top`. It also settles what comes next. The untranslated report derives a second per-chunk value from the same parse — why a reply could not be used — which under the old shape would have become a fifth index-keyed column in the context. On the chunk it is one more field and the report is a walk over `textChunks`. **A habit is broken here deliberately.** No pipeline stage wrote into a chunk before this: chunks were authored by the expander and read by the applicator, so a stage's return value described everything it did. `TranslationStage` now fills in a field on chunks it did not create, and its return understates it. The cost of keeping the rule was the fourth column now and the fifth next. Two smaller things fall out. `parseContainerReplies` no longer returns anything, so it no longer has two ways to say "nothing here" — `undefined` for no containers and an empty map for none parsed — which no caller could tell apart. And the name `containerFragments` no longer sits next to `chunk.fragments` meaning something else: fragments carry live node references collected before sending, a reply is pure data that came back. Behaviour is unchanged, and the 13 end-to-end container tests were not edited to fit. The container write path had no unit test at all before this, so three were added against the applicator directly: reorder, drop a node whose mark came back empty, and leave the container alone when no reply was parsed. Verification: 1491 unit tests (1488 + 3), 0 failed; check-types clean; lint 58 warnings and 0 errors, identical to main; declaration build passes; diff-hygiene and analyzer-delta gates clean; a fresh review pass found nothing. Two mutations, each red on exactly its own cases. --- ...26-09-11-parsed-reply-on-the-chunk.task.md | 122 ++++++++++++++++++ .../TranslationMutator.stage.ts | 2 +- .../TranslationMutator.test.ts | 70 +++++++++- .../TranslationMutator.ts | 9 +- .../stages/translation/Translation.stage.ts | 27 +--- .../types/PipelineContext.ts | 6 - .../translation-pipeline/types/TextChunk.ts | 7 + 7 files changed, 208 insertions(+), 35 deletions(-) create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-11-parsed-reply-on-the-chunk.task.md diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-11-parsed-reply-on-the-chunk.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-11-parsed-reply-on-the-chunk.task.md new file mode 100644 index 000000000..df04ad72e --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-11-parsed-reply-on-the-chunk.task.md @@ -0,0 +1,122 @@ +# Move the parsed reply onto the chunk + +**Date:** 2026-09-11 · **PR:** #139 (fourth commit) · **Risk:** low +**Behaviour must not change** — 1488 tests, same count, all green. + +## Requirements / Task restatement + +`TranslationStage` parses each container's reply into marks and puts the result in +`PipelineContext.containerFragments`, a `Record`. `TranslationMutator.apply` +takes that map as a third parameter and joins it to each chunk by `chunk.index`. + +Put the parsed reply on `RichContainerChunk` instead. The context field and the third parameter go. + +## Decisions + +**D1 — the parsed reply lives on the chunk.** Rejected: the status quo. The constraint is in +`types/TextChunk.ts` itself: a chunk already carries every write handle the mutator needs — +`dataRef`/`key` (`:15-17`), `nodeRef` (`:31`), `containerRef` (`:46`), and `fragments[].node`/`.top`. +"Where to write" is already the chunk's job; the parsed reply is "what to write" for the same +write, and keeping it elsewhere forces a lookup by index to put the two halves back together. + +Second constraint, decisive: `chunk.index` equals the chunk's position in `textChunks` — +`TextChunkExpander.expand` runs one dense counter across all fields (`:27,34,37`), and every +expander increments by one per emitted chunk. So `containerFragments` is a sparse column over an +array that is already indexed by the same number: a structure whose only job is re-association. + +Third: the next change (the untranslated report) derives a **second** per-chunk value from the same +parse — the failure reason. Under the status quo that becomes a fifth index-keyed column in the +context; on the chunk it is one more field and the report is a walk over `textChunks`. + +**D2 — the field is named `reply`, not `fragments` or anything containing it.** +`chunk.fragments` already exists and means something different: fragments collected from the tree +*before* sending, carrying live node references. The parsed reply is pure data that came *back*. +One word for both is what made the distinction unreadable in review. + +**D3 — a stage now writes into a chunk, and that is a deliberate break.** +No pipeline stage writes into a chunk today (verified by grep over `stages/`): chunks are authored +by stage 3 and read by stage 5, and a stage's return value describes everything it did. After this +change `TranslationStage` fills in a field on chunks stage 3 created, so its return understates it. +Accepted by the owner: the cost of the rule is a fourth index-keyed column now and a fifth next. +Named here so a later reviewer does not read it as an oversight. + +**D4 — `parseContainerReplies` stops returning anything.** It writes onto the chunks and returns +`void`, which removes the state it had no use for: `undefined` when there were no containers versus +an empty map when none parsed — two spellings of "nothing here" that no consumer could tell apart. + +**Placement:** `types/TextChunk.ts` (the field), `Translation.stage.ts` (the write), +`TranslationMutator.ts` + `.stage.ts` (the read), `types/PipelineContext.ts` (the removal). + +**New surface:** none. An optional field on a type that `src/index.ts` does not export. + +**Written contract?** One clause the signature cannot state: **an absent `reply` means the +container keeps its source text** — it is the same outcome for "no reply came back" and "the reply +could not be parsed", and the mutator must not distinguish them. Recorded in the field's docblock. +Not enough to fire a blind-authoring run: this is a refactor of behaviour already covered, not a +new contract others will implement. + +**Escalate?** No. One module, no new seam, no data-model change. + +## Acceptance Criteria + +| # | Criterion | How it is checked | Passes when | +|---|---|---|---| +| 1 | `containerFragments` exists nowhere | `grep -rn containerFragments src/` | no matches | +| 2 | `apply` takes two parameters | `bun run check-types` after a temporary third argument at the call site | the extra argument is a type error | +| 3 | The container write path has a direct unit test | `bunx vitest run .../TranslationMutator.test.ts -t "container"` | exit 0, the cases run | +| 4 | Behaviour unchanged | `bunx vitest run` | **1488 + the new cases**, 0 failed | +| 5 | The 13 container tests were not edited to fit | `git diff -- .../containerMode.test.ts` | empty | +| 6 | No new type errors | `bun run check-types` | clean | +| 7 | No new lint findings | `bun run lint` | 58 warnings, 0 errors | +| 8 | The declaration build still passes | `bunx turbo run build --filter=...` | 1 successful | +| 9 | Unhappy path: a container whose reply did not parse keeps its source text | the new unit test, a chunk with no `reply` | the node's text is untouched | + +## Pre-flight (against the untouched tree, 2026-09-11) + +| # | Result | Class | +|---|---|---| +| 1 | 4 files mention `containerFragments` | change — fails now, correct | +| 2 | the third parameter is declared | change — fails now, correct | +| 3 | `grep -c richContainer` in the mutator's test → **0** | change — fails now, correct | +| 4 | 113 files, 1488 passed | invariant — passes now | +| 6 | clean | invariant — passes now | +| 7 | 58 warnings, 0 errors | invariant — passes now | + +## Risk notes + +- The only real risk is a silent behaviour change, and the net is 23 tests over the two files that + matter (13 end-to-end container cases, 10 on the mutator). Criterion 5 is what stops the net + being adjusted to fit the change. +- The container write path had no unit test before this change. That is why criterion 3 exists: + without it the refactor would rest entirely on end-to-end coverage. + +## Human choices + +- **2026-09-11 — the owner proposed this design and instructed the run**: *"Если тестами покрыто - + тогда запускай через /sp-task, потом подлей изменения в ветку"*. The Phase 2 gate is therefore + not re-asked: re-confirming the owner's own proposal would be a pause with no decision in it. +- **2026-09-11 — breaking the "no stage writes a chunk" habit is accepted** (D3), on the grounds + that the rule costs a fourth index-keyed column now and a fifth in the next change. + +## Review log + +| Date | Pass | Outcome | +|---|---|---| +| 2026-09-11 | implementation self-verification | criteria 1-9 met by their declared checks; `sp-diff-checks` clean (5 checks); `sp-lint-delta` reports no introduced findings | +| 2026-09-11 | fresh eyes on the diff | **no findings**. Confirmed no behaviour difference (an empty `ParsedMark[]` is truthy under both the old lookup and the new field read, and `undefined` propagates identically); no aliasing (chunks are rebuilt on every `execute()`); nothing left behind by the removal | + +### Mutations + +| Mutation | Went red | +|---|---| +| the translation stage stops writing `chunk.reply` | 6 cases, incl. three end-to-end container tests | +| apply an empty reply when none was parsed | the new `leaves the container untouched when no reply was parsed`, plus the end-to-end corrupt-reply case | + +**One correction worth recording.** The first attempt at the second mutation stayed green, and I +briefly read that as a coverage gap. It was a bad mutation: it wrote each fragment's own text back +over itself, so nothing changed and there was nothing for a test to catch. Replaced with one that +applies an empty reply, which discriminates. + +**One honest limit.** Of the three new unit tests, a no-op implementation would fail the first two; +the third — absence of a reply leaves the container alone — is satisfied by doing nothing, which is +also what it asserts. Its guard value comes from the mutation above, not from the test in isolation. diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts index 12c5e84a7..b58c6f5d3 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.stage.ts @@ -11,7 +11,7 @@ export class TranslationMutatorStage implements PipelineStage { } const mutator = new TranslationMutator(); - mutator.apply(ctx.textChunks, ctx.translations, ctx.containerFragments); + mutator.apply(ctx.textChunks, ctx.translations); return ctx; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts index 2710694a5..092c5671c 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import type { PlainTextChunk, RichTextChunk, TextChunk } from "../../types"; +import type { PlainTextChunk, RichContainerChunk, RichTextChunk, TextChunk } from "../../types"; +import type { InlineFragment } from "../../../kernel/lexical/collectInlineFragments"; +import type { SerializedLexicalNode } from "../../../kernel/lexical"; import type { SerializedTextNode } from "../../../kernel/lexical"; import { TranslationMutator } from "./TranslationMutator"; @@ -15,6 +17,35 @@ const createTextNode = (text: string): SerializedTextNode => style: "", }) as SerializedTextNode; +/** Direct text leaves, so `top` and `node` are one object — what the collector emits for them. */ +const paragraph = () => { + const leaves = [createTextNode("a "), createTextNode("red"), createTextNode(" car")]; + const container = { type: "paragraph", children: [...leaves] } as SerializedLexicalNode; + const fragments: InlineFragment[] = leaves.map((leaf, at) => ({ + markId: at + 1, + text: leaf.text, + node: leaf, + top: leaf, + })); + return { container, leaves, fragments }; +}; + +const chunkOf = ( + container: SerializedLexicalNode, + fragments: InlineFragment[], + reply?: { markId: number; text: string }[] +): RichContainerChunk => ({ + type: "richContainer", + index: 0, + text: "<1>a <2>red<3> car", + containerRef: container, + fragments, + ...(reply ? { reply } : {}), +}); + +const textsOf = (container: SerializedLexicalNode) => + ((container as unknown as { children?: { text?: string }[] }).children ?? []).map((c) => c.text); + describe("TranslationMutator", () => { const mutator = new TranslationMutator(); @@ -152,4 +183,41 @@ describe("TranslationMutator", () => { expect(data).toEqual({ title: "Привет", slug: "hello", count: 42 }); }); }); + + describe("apply with a RichContainerChunk", () => { + it("rebuilds children in the reply's order", () => { + const { container, fragments } = paragraph(); + const chunk = chunkOf(container, fragments, [ + { markId: 1, text: "une " }, + { markId: 3, text: "voiture " }, + { markId: 2, text: "rouge" }, + ]); + + mutator.apply([chunk], {}); + + expect(textsOf(container)).toEqual(["une ", "voiture ", "rouge"]); + }); + + it("drops the node of a mark that came back empty", () => { + const { container, fragments } = paragraph(); + const chunk = chunkOf(container, fragments, [ + { markId: 1, text: "une voiture rouge" }, + { markId: 2, text: "" }, + { markId: 3, text: "" }, + ]); + + mutator.apply([chunk], {}); + + expect(textsOf(container)).toEqual(["une voiture rouge"]); + }); + + it("leaves the container untouched when no reply was parsed", () => { + const { container, fragments } = paragraph(); + const chunk = chunkOf(container, fragments); + + mutator.apply([chunk], { 0: "<1>une <2>rouge<3> voiture" }); + + expect(textsOf(container)).toEqual(["a ", "red", " car"]); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts index 5262fee75..1fc5a072d 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts @@ -45,15 +45,10 @@ export class TranslationMutator { * @param translations - Map of index -> translated text * @returns Mutation result with count of translated chunks */ - apply( - textChunks: TextChunk[], - translations: Record, - containerFragments?: Record - ): void { + apply(textChunks: TextChunk[], translations: Record): void { for (const chunk of textChunks) { if (isRichContainerChunk(chunk)) { - const parsed = containerFragments?.[chunk.index]; - if (parsed) applyContainer(chunk, parsed); + if (chunk.reply) applyContainer(chunk, chunk.reply); continue; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts index 6d2b4fc14..268717aa6 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation/Translation.stage.ts @@ -1,28 +1,17 @@ import { parseInlineMarks } from "../../../kernel/lexical/inlineMarks"; -import type { ParsedMark } from "../../../kernel/lexical/inlineMarks"; import type { PipelineContext, PipelineStage } from "../../types"; import { isRichContainerChunk } from "../../types"; import type { TranslationProvider } from "../../../domain/translation-providers"; -/** A container missing from the result had a corrupt reply and keeps its source text. */ -function parseContainerReplies( - ctx: PipelineContext, - translations: Record -): Record | undefined { - const containers = (ctx.textChunks ?? []).filter(isRichContainerChunk); - if (containers.length === 0) return undefined; - - const parsed: Record = {}; - - for (const chunk of containers) { +/** Writes onto chunks the expander created, rather than returning — see `RichContainerChunk.reply`. */ +function parseContainerReplies(ctx: PipelineContext, translations: Record): void { + for (const chunk of (ctx.textChunks ?? []).filter(isRichContainerChunk)) { const reply = translations[chunk.index]; if (reply === undefined) continue; const result = parseInlineMarks(reply, chunk.fragments); - if (result.ok) parsed[chunk.index] = result.fragments; + if (result.ok) chunk.reply = result.fragments; } - - return parsed; } /** @@ -50,10 +39,8 @@ export class TranslationStage implements PipelineStage { throw new Error("Translation provider returned null"); } - return { - ...ctx, - translations, - containerFragments: parseContainerReplies(ctx, translations), - }; + parseContainerReplies(ctx, translations); + + return { ...ctx, translations }; } } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts index bb8df0ec9..7f689ff97 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/PipelineContext.ts @@ -1,5 +1,4 @@ import type { FieldLike } from "../../kernel/field-traversal"; -import type { ParsedMark } from "../../kernel/lexical/inlineMarks"; import type { FieldChunk } from "./FieldChunk"; import type { TextChunk } from "./TextChunk"; @@ -20,11 +19,6 @@ export type PipelineContext = { textChunks?: TextChunk[]; textMap?: Record; translations?: Record; - /** - * Parsed marked replies, keyed by chunk index. A container missing here had a corrupt reply and - * must be left in its source language. - */ - containerFragments?: Record; }; /** diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts index c7fa39424..48c0d65d2 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts @@ -1,4 +1,5 @@ import type { InlineFragment } from "../../kernel/lexical/collectInlineFragments"; +import type { ParsedMark } from "../../kernel/lexical/inlineMarks"; import type { SerializedLexicalNode, SerializedTextNode } from "../../kernel/lexical"; /** @@ -46,6 +47,12 @@ export type RichContainerChunk = { containerRef: SerializedLexicalNode; /** Fragments in document order, as collected */ fragments: InlineFragment[]; + /** + * The reply's marks, once the translation stage has parsed a usable one. Absent means this + * container keeps its source text — the same outcome whether no reply came back or it could not + * be parsed, and the applicator must not tell the two apart. + */ + reply?: ParsedMark[]; }; /** From b9f9f17fa541a72c9d06ad0cebdf6d5cc53dc89b Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 20:39:01 +0200 Subject: [PATCH 05/11] refactor(translator): drop a field nobody reads, and make a missed chunk kind a compile error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from an interface review of this PR, none of them behavioural. **`RichContainerChunk.text` was written and never read.** The expander filled it in and no consumer touched it — the applicator and the translation stage both work from `fragments`, `reply` and `containerRef`. It was also the same field name the sibling chunk kinds use for a different thing: on plain and per-node chunks `text` is the source prose the drift guard hashes, while here it held the marked wire string, which is why it needed a comment saying "never hash or display it". A field that has to warn you about its own name is misnamed. The string it duplicated is already in `textMap`, keyed by the same index, so the assertions that read it now read that instead. **A fourth `TextChunk` kind used to compile clean and be silently skipped.** `apply()` branched with three unconnected `if`s and no final else, so a new kind would fall through both tests, never be written, and produce an untranslated field with no error anywhere. Adding the exhaustiveness guard this codebase already uses elsewhere turns that into a compile error in the applicator — verified by adding a fourth member and reading TS2322. **Three consequences of the experimental flag were undocumented.** Turning it back off stops future translations from splitting wrappers but leaves already translated documents split — the one-way door is the first translated document, not the flag. The next major removes the flag, which is also the only way to turn the behaviour off, so it is a schedule rather than a permanent safety valve. And `capabilities.inlineMarks` is core logic that survives the flag's removal, so a third-party provider that never declares it keeps translating node by node indefinitely, in the mode the register itself calls a defect, with no warning surface. All three are now in the register entry, the first two also on the option's own docblock. The drift guard gained one line of why: it is built from per-node expanders deliberately, because a container expander sends marked strings and the guard compares source prose. Verification: 1491 unit tests, unchanged in count and all green; check-types clean; lint 58 warnings and 0 errors, identical to main; declaration build passes. Integration (17 suites, 82 tests) was run against the default path and is green, but it exercises none of container mode — the flag is off there. That gap is the next piece of work. --- .../docs/DEPRECATIONS.md | 11 +++++++++++ .../stages/field-collector/driftGuard.test.ts | 6 ++++-- .../text-expander/RichContainerExpander.ts | 1 - .../text-expander/TextChunkExpander.test.ts | 16 ++++++++-------- .../TranslationMutator.test.ts | 1 - .../translation-applicator/TranslationMutator.ts | 4 ++++ .../core/translation-pipeline/types/TextChunk.ts | 2 -- packages/payload-plugin-translator/src/plugin.ts | 4 ++++ 8 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index 242d16a50..e729f4abd 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -37,6 +37,13 @@ the single source of truth — code annotations link here by anchor instead of d - **Deprecated:** 2026-06-05 / PR #18 (shipped in 0.3.0) - **Replacement:** flat text reference — `collection_slug` + `collection_id`. - **Remove in:** next major +- **What removal takes with it:** the flag is also the only way to turn the behaviour off, so an + install that adopted it to guard against a misbehaving provider has no code-level guard after the + next major — pinning the previous major is the only remaining step-back. Say so before an install + relies on the flag as a safety valve. +- **What turning it back off does not undo:** it stops future translations from splitting wrappers; + documents already translated under it stay split. The one-way door is the first translated + document, not the flag. - **Why:** the relationship field validates the stored value's type against the target collection's ID type, so a string id for a number-id collection silently fails validation and the job hangs in `processing`. Flat text fields make the job input ID-agnostic. See @@ -234,6 +241,10 @@ the single source of truth — code annotations link here by anchor instead of d schedule and step back if its provider misbehaves. The next major removes **the flag**, not the per-node code: that stays as the internal fallback for a mark-shaped source, a single-fragment container, and a corrupt reply. +- **After removal, a provider still decides:** `capabilities.inlineMarks` is core logic, not part of + the flag, and survives it. A third-party `TranslationProvider` that never declares the capability + therefore keeps translating node by node indefinitely — the mode this entry calls a defect — with + no warning surface. Provider authors need telling, separately from this flag's own removal. - **Code refs:** - `src/plugin.ts` (the option) - `src/core/translation-pipeline/translateContent.ts` (the single switch between the two paths) diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts index 5b88773be..107024c0c 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts @@ -81,13 +81,15 @@ const pipelineFieldTexts = (): string[] => { new OverwriteStrategy() ).collect(); + // Per-node expanders only, deliberately: a container expander sends marked strings + // (`<1>a `), and this guard compares source prose against the projection's source prose. const expander = new TextChunkExpander([new RichTextExpander(), new PlainTextExpander()]); // Expand each field chunk independently, then join per field (richText spans multiple nodes). return chunks.map((chunk) => { - const { textChunks } = expander.expand([chunk]); + const { textChunks, textMap } = expander.expand([chunk]); return textChunks - .map((tc) => tc.text) + .map((tc) => textMap[tc.index] ?? "") .join("") .trim(); }); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts index 47b31d564..4a7c9d9cd 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts @@ -71,7 +71,6 @@ export class RichContainerExpander implements TextExpander { emit(text, (at) => ({ type: "richContainer", index: at, - text, containerRef: container.node, fragments: container.fragments, })); diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts index 71df9f40c..3e9576b64 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts @@ -137,8 +137,8 @@ describe("RichTextExpander", () => { expect(result.chunks).toHaveLength(2); expect(result.chunks[0].type).toBe("richText"); - expect(result.chunks[0].text).toBe("Hello"); - expect(result.chunks[1].text).toBe("World"); + expect(result.textMap[0]).toBe("Hello"); + expect(result.textMap[1]).toBe("World"); }); it("assigns sequential indices", () => { @@ -190,7 +190,7 @@ describe("RichTextExpander", () => { const result = expander.expand(chunk, value, 0); expect(result.chunks).toHaveLength(1); - expect(result.chunks[0].text).toBe("Title"); + expect(result.textMap[0]).toBe("Title"); }); it("expands list with multiple items", () => { @@ -224,9 +224,9 @@ describe("RichTextExpander", () => { const result = expander.expand(chunk, value, 0); expect(result.chunks).toHaveLength(3); - expect(result.chunks[0].text).toBe("Click "); - expect(result.chunks[1].text).toBe("here"); - expect(result.chunks[2].text).toBe(" to continue"); + expect(result.textMap[0]).toBe("Click "); + expect(result.textMap[1]).toBe("here"); + expect(result.textMap[2]).toBe(" to continue"); }); it("expands quote with nested paragraph", () => { @@ -240,7 +240,7 @@ describe("RichTextExpander", () => { const result = expander.expand(chunk, value, 0); expect(result.chunks).toHaveLength(1); - expect(result.chunks[0].text).toBe("Famous quote"); + expect(result.textMap[0]).toBe("Famous quote"); }); it("expands paragraph with multiple formatted text nodes", () => { @@ -385,7 +385,7 @@ describe("RichTextExpander", () => { const result = expander.expand(chunk, value, 0); expect(result.chunks).toHaveLength(1); - expect(result.chunks[0].text).toBe("Text content"); + expect(result.textMap[0]).toBe("Text content"); }); it("only collects text nodes from mixed content", () => { diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts index 092c5671c..de7d1a5e2 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.test.ts @@ -37,7 +37,6 @@ const chunkOf = ( ): RichContainerChunk => ({ type: "richContainer", index: 0, - text: "<1>a <2>red<3> car", containerRef: container, fragments, ...(reply ? { reply } : {}), diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts index 1fc5a072d..3ae270bc1 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/translation-applicator/TranslationMutator.ts @@ -59,6 +59,10 @@ export class TranslationMutator { chunk.dataRef[chunk.key] = translation; } else if (isRichTextChunk(chunk)) { chunk.nodeRef.text = translation; + } else { + // Exhaustiveness: a new TextChunk kind must be written here, not silently skipped. + const exhaustive: never = chunk; + throw new Error(`unhandled text chunk: ${String(exhaustive)}`); } } } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts index 48c0d65d2..d0e045241 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/TextChunk.ts @@ -41,8 +41,6 @@ export type RichTextChunk = { export type RichContainerChunk = { type: "richContainer"; index: number; - /** The marked string sent for translation, not source prose — never hash or display it */ - text: string; /** The container whose `children` are rebuilt when the reply reorders marks */ containerRef: SerializedLexicalNode; /** Fragments in document order, as collected */ diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts index c1989decb..78594ebdb 100644 --- a/packages/payload-plugin-translator/src/plugin.ts +++ b/packages/payload-plugin-translator/src/plugin.ts @@ -108,6 +108,10 @@ export type TranslatorPluginConfig = { * translating node by node. A container whose reply comes back with damaged marks is left in * its source language rather than half-written. * + * Turning it back off stops future translations from splitting wrappers; documents already + * translated under it stay split. And the next major removes the flag itself, so it is a + * schedule, not a permanent safety valve. + * * @default false * @deprecated Transitional. Removed in the next major, when this becomes the only mode. * @see docs/DEPRECATIONS.md#experimental-inline-marks — why this exists and when it goes From e7de3f41b8598ea78037b1dfd45cf5dfdb8adb95 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 11 Sep 2026 21:46:13 +0200 Subject: [PATCH 06/11] test(dev): cover container-granular rich text end to end, at every nesting depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite proves container mode in memory. Nothing proved it against a real Payload, a real save and a document read back — which matters here more than usual, because this is the one feature that rewrites the structure of stored content rather than just its text. **The fake translation service no longer reverses strings.** Reversal destroys marks, so with the old fake every container would have fallen back to the per-node path and the feature under test would never have run. The replacement understands marks: each comes back exactly once, its text translated, and by default in reverse order, because reordering is the whole point of the format and a fake that preserved order would leave it untested. It can also keep the order, or return a reply that is damaged in one of three named ways. Values now come back prefixed with the target locale — `de:Title source` rather than `ecruos eltiT`. That reads better, but it also found five existing assertions that compared a French or Spanish result against a German expectation and passed, because a reversed string is the same in every locale. Those five now assert the locale they are actually about. New coverage, all of it against a saved and re-read document: - **Every nesting shape the field walker classifies** — group, array, blocks, a named tab, an unnamed tab, `row`, `collapsible`, and one group → array → blocks combination. The shared fixture carries exactly one rich-text field at the top level, so the three "transparent" shapes had no coverage at all, for this feature or any other. `textarea`, the third translatable leaf type, was also never exercised. - **Every container shape inside a Lexical tree** — a heading and a quote are containers exactly as a paragraph is, each list item is its own container with its own marks numbered from one, the list itself is not a container, and a node carrying no text keeps its place when the rest moves. - **A reply whose marks cannot be parsed** — the container keeps its source text whole, and the rest of the document still translates. - **A provider that never declared it can keep marks** — the flag is on and the core still translates node by node, which is the gate that protects a translation service that is not a language model. - **The non-localized sibling in every shared row** survives. Those rows are one row with per-locale columns, so rewriting a rich-text leaf inside one is where a mistake costs another locale its content. Each suite was checked for discrimination rather than assumed: the nine nesting specs and the five structural ones go red with the flag off, the damaged-reply spec goes red when the damage is removed, and the capability spec goes red when the capability is declared. Verification: 22 suites, 108 tests, all green (17 and 82 before). Run from the repository's main checkout — a linked worktree resolves the plugin through the root `node_modules` symlink and would exercise the wrong code, so these cannot be run from the branch's own worktree. --- .../auto-translate-unknown-locale.int.test.ts | 4 +- .../translator/auto-translate.int.test.ts | 6 +- .../translator/blocks-structure.int.test.ts | 22 ++- .../integration/translator/bootTestPayload.ts | 24 ++- .../translator/data-integrity.int.test.ts | 72 ++++---- .../document-translation.int.test.ts | 24 +-- .../translator/draft-safe-writes.int.test.ts | 6 +- .../translator/exclusive-queue.int.test.ts | 4 +- .../inline-marks-capability.int.test.ts | 100 +++++++++++ .../inline-marks-corrupt.int.test.ts | 100 +++++++++++ .../inline-marks-nesting.int.test.ts | 161 ++++++++++++++++++ .../inline-marks-structure.int.test.ts | 133 +++++++++++++++ .../translator/inline-marks.int.test.ts | 161 ++++++++++++++++++ .../translator/job-extend.int.test.ts | 2 +- .../translator/locale-append.int.test.ts | 4 +- .../locale-workflow-failure.int.test.ts | 6 +- .../translator/locale-workflow.int.test.ts | 6 +- .../translator/multi-target.int.test.ts | 14 +- .../translator/nestedCollections.ts | 88 ++++++++++ .../translator/source-semantics.int.test.ts | 2 +- .../translator/strategies.int.test.ts | 6 +- .../strategy-publish-matrix.int.test.ts | 2 +- apps/dev/src/lib/translator/devToggles.ts | 4 +- apps/dev/src/lib/translator/fakeComplete.ts | 84 +++++++-- 24 files changed, 936 insertions(+), 99 deletions(-) create mode 100644 apps/dev/src/integration/translator/inline-marks-capability.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-structure.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks.int.test.ts create mode 100644 apps/dev/src/integration/translator/nestedCollections.ts diff --git a/apps/dev/src/integration/translator/auto-translate-unknown-locale.int.test.ts b/apps/dev/src/integration/translator/auto-translate-unknown-locale.int.test.ts index e2ce3e80a..c3c27648d 100644 --- a/apps/dev/src/integration/translator/auto-translate-unknown-locale.int.test.ts +++ b/apps/dev/src/integration/translator/auto-translate-unknown-locale.int.test.ts @@ -7,7 +7,7 @@ import type { TestPayload } from "./bootTestPayload"; // (multiple boots per process collide on Payload's module singletons). "xx" is not a configured // locale → dropped at config time with a warning; "de" still translates. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const PROVENANCE = "translator-provenance"; describe("auto-translate — unknown target locale dropped", () => { @@ -27,7 +27,7 @@ describe("auto-translate — unknown target locale dropped", () => { }); const id = String(created.id); const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe(rev("Unknown-locale src")); + expect(de.title).toBe(tr("de", "Unknown-locale src")); const records = await ctx.payload.find({ collection: PROVENANCE, where: { documentId: { equals: id } }, diff --git a/apps/dev/src/integration/translator/auto-translate.int.test.ts b/apps/dev/src/integration/translator/auto-translate.int.test.ts index 3a3f36e2a..8ab9c804e 100644 --- a/apps/dev/src/integration/translator/auto-translate.int.test.ts +++ b/apps/dev/src/integration/translator/auto-translate.int.test.ts @@ -6,7 +6,7 @@ import type { TestPayload } from "./bootTestPayload"; // R5 — auto-translate (#51), one dedicated case per behavior. Trigger is the real afterChange hook: // publishing source-locale content runs the sync pipeline inline and writes the targets. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const PROVENANCE = "translator-provenance"; const provenanceFor = async (ctx: TestPayload, id: string) => { @@ -36,8 +36,8 @@ describe("auto-translate — targets de, fr", () => { const id = String(created.id); const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); const fr = await ctx.payload.findByID({ collection: "docs", id, locale: "fr" }); - expect(de.title).toBe(rev("Auto src")); - expect(fr.title).toBe(rev("Auto src")); + expect(de.title).toBe(tr("de", "Auto src")); + expect(fr.title).toBe(tr("fr", "Auto src")); }); it("publish-gate: a draft (unpublished) save is NOT auto-translated", async () => { diff --git a/apps/dev/src/integration/translator/blocks-structure.int.test.ts b/apps/dev/src/integration/translator/blocks-structure.int.test.ts index 84a7a9bd1..880a5f11d 100644 --- a/apps/dev/src/integration/translator/blocks-structure.int.test.ts +++ b/apps/dev/src/integration/translator/blocks-structure.int.test.ts @@ -11,7 +11,7 @@ import { callEndpoint } from "./callEndpoint"; // without cross-contaminating leaves or corrupting an already-translated locale, and skip_existing // respects per-block edits — matched by id even when only some blocks are filled ("partially differ"). -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); type Block = { id?: string; blockType: string; heading?: string; caption?: string }; @@ -65,7 +65,11 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { await enqueue(ctx, id, "de"); const de = await sectionsOf(ctx, id, "de"); - expect(de.map((b) => b.heading)).toEqual([rev("Alpha"), rev("Bravo"), rev("Charlie")]); + expect(de.map((b) => b.heading)).toEqual([ + tr("de", "Alpha"), + tr("de", "Bravo"), + tr("de", "Charlie"), + ]); }); it("re-mirrors SOURCE order on reorder+edit, without corrupting an already-translated locale", async () => { @@ -112,9 +116,9 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { const de = await sectionsOf(ctx, id, "de"); expect(de.map((b) => b.id)).toEqual([three.id, one.id, two.id]); // ids stable, reordered expect(de.map((b) => b.heading ?? b.caption)).toEqual([ - rev("Three"), - rev("One EDITED"), - rev("Two"), + tr("de", "Three"), + tr("de", "One EDITED"), + tr("de", "Two"), ]); // Source survives the reorder+re-translate. @@ -125,7 +129,11 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { // previously-translated leaves are intact (not wiped by the DE pass) — old "One" translation kept. const fr = await sectionsOf(ctx, id, "fr"); expect(fr.map((b) => b.id)).toEqual([three.id, one.id, two.id]); - expect(fr.map((b) => b.heading ?? b.caption)).toEqual([rev("Three"), rev("One"), rev("Two")]); + expect(fr.map((b) => b.heading ?? b.caption)).toEqual([ + tr("fr", "Three"), + tr("fr", "One"), + tr("fr", "Two"), + ]); }); it("skip_existing fills empty block leaves but keeps a manually-edited one (matched by id)", async () => { @@ -163,6 +171,6 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { const de = await sectionsOf(ctx, id, "de"); // Empty siblings filled from source; the manually-edited middle block kept — paired by id. - expect(de.map((b) => b.heading)).toEqual([rev("Src A"), "MANUAL B", rev("Src C")]); + expect(de.map((b) => b.heading)).toEqual([tr("de", "Src A"), "MANUAL B", tr("de", "Src C")]); }); }); diff --git a/apps/dev/src/integration/translator/bootTestPayload.ts b/apps/dev/src/integration/translator/bootTestPayload.ts index 48ad5be32..b9cbb9710 100644 --- a/apps/dev/src/integration/translator/bootTestPayload.ts +++ b/apps/dev/src/integration/translator/bootTestPayload.ts @@ -19,7 +19,8 @@ import type { CollectionConfig, Payload } from "payload"; import { getPayload } from "payload"; import { createTestDatabase } from "../../lib/database/resolveAdapter"; -import { reverseComplete } from "../../lib/translator/fakeComplete"; +import { fakeComplete } from "../../lib/translator/fakeComplete"; +import type { FakeTranslationOptions } from "../../lib/translator/fakeComplete"; import { buildTestCollections } from "./testCollections"; /** Payload's `autoRun.limit` default — these specs reproduce the cron's batching, not a run of one. */ @@ -83,6 +84,15 @@ export async function bootTestPayload(opts?: { failFor?: string[]; onTranslate?: (targetLng: string) => Promise | void; runner?: TaskRunnerProvider; + /** Turn on container-granular rich-text translation, and declare the provider able to keep marks. */ + inlineMarks?: boolean; + /** + * Whether the provider declares `capabilities.inlineMarks`. Defaults to `inlineMarks`; set it to + * `false` with the flag on to stand in for a third-party provider that cannot keep marks. + */ + declareCapability?: boolean; + /** How the fake answers a marked value — reorder by default, keep order, or corrupt it. */ + fake?: FakeTranslationOptions; }): Promise { const dir = mkdtempSync(join(tmpdir(), "translator-int-")); const { db, drop } = createTestDatabase(join(dir, "test.db")); @@ -96,15 +106,20 @@ export async function bootTestPayload(opts?: { ? collections.map((c) => (c.slug === "docs" ? withAutoTranslate(c, autoTranslate) : c)) : collections; - const baseProvider = createTranslationProvider({ complete: reverseComplete }); + const declaresMarks = opts?.declareCapability ?? opts?.inlineMarks ?? false; + const baseProvider = createTranslationProvider({ + complete: fakeComplete(opts?.fake), + ...(declaresMarks ? { capabilities: { inlineMarks: true } } : {}), + }); const failFor = new Set(opts?.failFor); let translateCalls = 0; const countingProvider: TranslationProvider = { - translate: async (input, sourceLng, targetLng) => { + ...(declaresMarks ? { capabilities: { inlineMarks: true } } : {}), + translate: async (input, sourceLng, targetLng, options) => { translateCalls += 1; await opts?.onTranslate?.(targetLng); if (failFor.has(targetLng)) throw new Error(`provider unavailable for ${targetLng}`); - return await baseProvider.translate(input, sourceLng, targetLng); + return await baseProvider.translate(input, sourceLng, targetLng, options); }, }; @@ -141,6 +156,7 @@ export async function bootTestPayload(opts?: { runner: opts?.runner ?? createSyncRunner(), levels: [documentLevel()], provenance: true, + ...(opts?.inlineMarks ? { experimental: { inlineMarks: true } } : {}), }), ], }); diff --git a/apps/dev/src/integration/translator/data-integrity.int.test.ts b/apps/dev/src/integration/translator/data-integrity.int.test.ts index bebc58faa..1a2d450e0 100644 --- a/apps/dev/src/integration/translator/data-integrity.int.test.ts +++ b/apps/dev/src/integration/translator/data-integrity.int.test.ts @@ -14,7 +14,7 @@ import { callEndpoint } from "./callEndpoint"; // 3. non-localized data inside a shared row survives in every locale, // 4. re-translating the same locale is non-destructive. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); type Block = { id?: string; @@ -99,13 +99,13 @@ describe("data integrity — translation never destroys content", () => { // DE is fully populated before the FR pass. const deBefore = await read(ctx, id, "de"); expect((deBefore.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), ]); expect((deBefore.items as Item[]).map((i) => i.label)).toEqual([ - rev("Item one"), - rev("Item two"), + tr("de", "Item one"), + tr("de", "Item two"), ]); // Translate a second locale — this is what deleted+recreated the shared rows under the bug. @@ -114,11 +114,14 @@ describe("data integrity — translation never destroys content", () => { // DE must be UNCHANGED (the bug wiped it here). const de = await read(ctx, id, "de"); expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), + ]); + expect((de.items as Item[]).map((i) => i.label)).toEqual([ + tr("de", "Item one"), + tr("de", "Item two"), ]); - expect((de.items as Item[]).map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]); // Source (EN) intact; FR populated. const en = await read(ctx, id, "en"); @@ -128,7 +131,10 @@ describe("data integrity — translation never destroys content", () => { "Hero three", ]); const fr = await read(ctx, id, "fr"); - expect((fr.items as Item[]).map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]); + expect((fr.items as Item[]).map((i) => i.label)).toEqual([ + tr("fr", "Item one"), + tr("fr", "Item two"), + ]); }); it("keeps block/array ids stable across translation (in-place update, no recreate)", async () => { @@ -153,9 +159,9 @@ describe("data integrity — translation never destroys content", () => { // Everything above is an ABSENCE of change, which a run that translated nothing satisfies just // as well. The claim is "translated in place", so the run has to be shown doing the translating. expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), ]); }); @@ -177,9 +183,9 @@ describe("data integrity — translation never destroys content", () => { // it the case says only "nothing changed", which is true of a run that did nothing. const de = await read(ctx, id, "de"); expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), ]); }); @@ -233,10 +239,10 @@ describe("data integrity — translation never destroys content", () => { // Positive control: "nothing was deleted" is also what a pipeline that did nothing produces. const de = await readDraft(ctx, id, "de"); expect(headings(de)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), - rev("Draft-only hero"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), + tr("de", "Draft-only hero"), ]); }); @@ -250,8 +256,8 @@ describe("data integrity — translation never destroys content", () => { "Hero three", ]); expect(headings(await readDraft(ctx, id, "de"))).toEqual([ - rev("Hero one"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Hero three"), ]); }); @@ -266,9 +272,9 @@ describe("data integrity — translation never destroys content", () => { "Hero one", ]); expect(headings(await readDraft(ctx, id, "de"))).toEqual([ - rev("Hero three"), - rev("Cta two"), - rev("Hero one"), + tr("de", "Hero three"), + tr("de", "Cta two"), + tr("de", "Hero one"), ]); }); @@ -289,10 +295,10 @@ describe("data integrity — translation never destroys content", () => { "Draft-only hero", ]); expect(headings(await read(ctx, id, "de")), "the locale did not go live").toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), - rev("Draft-only hero"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), + tr("de", "Draft-only hero"), ]); }); }); @@ -305,9 +311,9 @@ describe("data integrity — translation never destroys content", () => { // Pin what the first run PRODUCED before comparing the second to it: "both runs agree" is // satisfied by "both runs produced nothing". expect((first.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta two"), - rev("Hero three"), + tr("de", "Hero one"), + tr("de", "Cta two"), + tr("de", "Hero three"), ]); await enqueue(ctx, id, "de"); // run it again diff --git a/apps/dev/src/integration/translator/document-translation.int.test.ts b/apps/dev/src/integration/translator/document-translation.int.test.ts index 166db230d..a3ab767e5 100644 --- a/apps/dev/src/integration/translator/document-translation.int.test.ts +++ b/apps/dev/src/integration/translator/document-translation.int.test.ts @@ -10,7 +10,7 @@ import { callEndpoint } from "./callEndpoint"; // nesting container + a non-localized field, with >=2 blocks and >=2 array items so id reconciliation // (the c0a49d1b failure mode) is exercised and the source-not-wiped lock is meaningful. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const EN = { _status: "published" as const, @@ -56,30 +56,30 @@ describe("document translation (manual enqueue, en -> de/fr)", () => { it("translates every localized leaf across group / array / blocks / tabs into de", async () => { const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe(rev("Title source")); - expect((de.meta as { subtitle: string }).subtitle).toBe(rev("Subtitle source")); + expect(de.title).toBe(tr("de", "Title source")); + expect((de.meta as { subtitle: string }).subtitle).toBe(tr("de", "Subtitle source")); const items = de.items as { label: string }[]; - expect(items.map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]); + expect(items.map((i) => i.label)).toEqual([tr("de", "Item one"), tr("de", "Item two")]); const sections = de.sections as { blockType: string; heading?: string; caption?: string }[]; expect(sections.map((b) => b.heading ?? b.caption)).toEqual([ - rev("Hero one"), - rev("Cta text"), - rev("Hero two"), + tr("de", "Hero one"), + tr("de", "Cta text"), + tr("de", "Hero two"), ]); - expect((de.seo as { seoTitle: string }).seoTitle).toBe(rev("Seo source")); - expect(de.note).toBe(rev("Note source")); + expect((de.seo as { seoTitle: string }).seoTitle).toBe(tr("de", "Seo source")); + expect(de.note).toBe(tr("de", "Note source")); }); it("populates fr as well (both configured targets)", async () => { const fr = await ctx.payload.findByID({ collection: "docs", id, locale: "fr" }); - expect(fr.title).toBe(rev("Title source")); - expect((fr.seo as { seoTitle: string }).seoTitle).toBe(rev("Seo source")); + expect(fr.title).toBe(tr("fr", "Title source")); + expect((fr.seo as { seoTitle: string }).seoTitle).toBe(tr("fr", "Seo source")); }); it("translates localized fields but leaves non-localized ones untouched", async () => { const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe(rev(EN.title)); + expect(de.title).toBe(tr("de", EN.title)); expect(de.ref).toBe("REF-123"); expect((de.meta as { sku: string }).sku).toBe("SKU-9"); }); diff --git a/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts b/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts index 2bd6ac728..b16cf5389 100644 --- a/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts +++ b/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts @@ -5,9 +5,9 @@ import { bootTestPayload } from "./bootTestPayload"; import { callEndpoint } from "./callEndpoint"; import { plainCollection } from "./testCollections"; -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const SOURCE = "Draft safety"; -const TRANSLATED = rev(SOURCE); +const TRANSLATED = tr("de", SOURCE); const SUBTITLE = "Second field"; const PUBLISHED_NOTE = "PUBLISHED NOTE"; const PUBLISHED_PRICE = 200; @@ -175,7 +175,7 @@ describe("draft-safe and per-locale-safe writes (#102)", () => { const de = await asDraft("docs", id, "de"); expect(de.title).toBe("HUMAN FIX"); - expect(de.subtitle).toBe(rev(SUBTITLE)); + expect(de.subtitle).toBe(tr("de", SUBTITLE)); }); it("publish mode takes the current draft live, including edits the translation did not touch", async () => { diff --git a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts index ac6cfe919..7ebe095b7 100644 --- a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts +++ b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts @@ -118,8 +118,8 @@ describe("with the host's concurrency control on", () => { await first; await runQueue(); - expect(await titleIn(id, "de"), "de was lost").toBe("ecruos evisulcxE"); - expect(await titleIn(id, "fr"), "fr was lost").toBe("ecruos evisulcxE"); + expect(await titleIn(id, "de"), "de was lost").toBe("de:Exclusive source"); + expect(await titleIn(id, "fr"), "fr was lost").toBe("fr:Exclusive source"); }); it("still runs jobs for different documents together", async () => { diff --git a/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts b/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts new file mode 100644 index 000000000..c69ff069b --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { buildNestedCollections } from "./nestedCollections"; +import { callEndpoint } from "./callEndpoint"; + +/** + * The flag is on, the provider never declared it can keep marks. + * + * This is the gate that matters in production: a translation service that is not a language model + * would translate or strip `<1>`, so the core must keep translating node by node whatever the + * plugin config asks for. The evidence is the word order — per-node translation cannot change it, + * and the fake reverses marks it never receives. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const body = () => ({ + root: { + type: "root", + children: [ + { + type: "paragraph", + children: [text("one "), text("two", 1), text(" three")], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, + ], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { text?: string }; +const textsOf = (value: unknown): (string | undefined)[] => + ( + ((value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []) as Child[] + ).map((c) => c.text); + +describe("the flag on, the provider silent about marks", () => { + let ctx: TestPayload; + let de: Record; + + beforeAll(async () => { + ctx = await bootTestPayload({ + inlineMarks: true, + declareCapability: false, + collections: buildNestedCollections(), + }); + + const created = await ctx.payload.create({ + collection: "nested" as "pages", + locale: "en", + data: { body: body(), summary: "Plain summary" } as never, + }); + const id = String((created as { id: string | number }).id); + + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "nested", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: true, + }, + }); + + de = (await ctx.payload.findByID({ + collection: "nested" as "pages", + id, + locale: "de" as "en", + })) as unknown as Record; + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it("translates node by node, so the order is the source's", () => { + expect(textsOf(de.body)).toEqual(["de:one ", "de:two", "de: three"]); + }); + + it("translates the plain leaf as usual", () => { + expect(de.summary).toBe("de:Plain summary"); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts b/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts new file mode 100644 index 000000000..932ca8244 --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { buildNestedCollections } from "./nestedCollections"; +import { callEndpoint } from "./callEndpoint"; + +/** + * What a document looks like after the model returns marks that cannot be parsed. + * + * The rule is all-or-nothing per container: a paragraph whose reply lost a mark keeps its **source** + * text rather than being half rebuilt, because untranslated text is visible to an editor and a + * half-rebuilt paragraph is not. Everything the reply did not damage still translates — the fake + * only mangles values that carry marks, so plain fields come back normally. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const body = () => ({ + root: { + type: "root", + children: [ + { + type: "paragraph", + children: [text("one "), text("two", 1), text(" three")], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, + ], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { text?: string }; +const textsOf = (value: unknown): (string | undefined)[] => + ( + ((value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []) as Child[] + ).map((c) => c.text); + +describe("a reply whose marks cannot be parsed", () => { + let ctx: TestPayload; + let de: Record; + + beforeAll(async () => { + ctx = await bootTestPayload({ + inlineMarks: true, + collections: buildNestedCollections(), + fake: { corrupt: "drop" }, + }); + + const created = await ctx.payload.create({ + collection: "nested" as "pages", + locale: "en", + data: { body: body(), summary: "Plain summary" } as never, + }); + const id = String((created as { id: string | number }).id); + + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "nested", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: true, + }, + }); + + de = (await ctx.payload.findByID({ + collection: "nested" as "pages", + id, + locale: "de" as "en", + })) as unknown as Record; + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it("leaves the container in its source language, whole", () => { + expect(textsOf(de.body)).toEqual(["one ", "two", " three"]); + }); + + it("still translates what the damaged reply did not touch", () => { + expect(de.summary).toBe("de:Plain summary"); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts b/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts new file mode 100644 index 000000000..93ae3188a --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { buildNestedCollections } from "./nestedCollections"; +import { callEndpoint } from "./callEndpoint"; + +/** + * Container-granular rich text under every nesting shape the field walker classifies, saved to a + * real database and read back. + * + * The fake reverses mark order, so a translated paragraph reads back in the opposite order from its + * source. That is the whole assertion: per-node translation cannot produce it, so each position + * that reads back reordered is a position container mode actually reached. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +/** Three leaves, so there is an order for the reply to change. */ +const body = (a: string, b: string, c: string) => ({ + root: { + type: "root", + children: [paragraph([text(a), text(b, 1), text(c)])], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { type: string; text?: string; format?: number; children?: Child[] }; + +const textsOf = (value: unknown): (string | undefined)[] => + ( + ((value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []) as Child[] + ).map((c) => c.text); + +type Doc = Record; +const at = (doc: Doc, path: string): unknown => + path.split(".").reduce((acc, key) => { + const index = Number(key); + return Number.isNaN(index) + ? (acc as Record)?.[key] + : (acc as unknown[])?.[index]; + }, doc); + +/** Every place the fixture puts a rich-text field, by the path it reads back at. */ +const PLACES = [ + "body", + "meta.body", + "items.0.body", + "sections.0.body", + "seo.body", + "looseBody", + "rowBody", + "collapsibleBody", + "deep.rows.0.parts.0.body", +]; + +describe("container mode reaches rich text at every nesting depth", () => { + let ctx: TestPayload; + let de: Doc; + let en: Doc; + + beforeAll(async () => { + ctx = await bootTestPayload({ inlineMarks: true, collections: buildNestedCollections() }); + + const created = await ctx.payload.create({ + collection: "nested" as "pages", + locale: "en", + data: { + body: body("one ", "two", " three"), + summary: "Plain summary", + meta: { body: body("one ", "two", " three") }, + items: [{ body: body("one ", "two", " three"), code: "ITEM-KEEP" }], + sections: [ + { blockType: "hero", body: body("one ", "two", " three"), anchor: "ANCHOR-KEEP" }, + ], + seo: { body: body("one ", "two", " three") }, + looseBody: body("one ", "two", " three"), + rowBody: body("one ", "two", " three"), + collapsibleBody: body("one ", "two", " three"), + deep: { + rows: [ + { + parts: [ + { blockType: "part", body: body("one ", "two", " three"), partRef: "PART-KEEP" }, + ], + }, + ], + }, + } as never, + }); + const id = String((created as { id: string | number }).id); + + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "nested", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: true, + }, + }); + + de = (await ctx.payload.findByID({ + collection: "nested" as "pages", + id, + locale: "de" as "en", + })) as unknown as Doc; + en = (await ctx.payload.findByID({ + collection: "nested" as "pages", + id, + locale: "en", + })) as unknown as Doc; + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + for (const path of PLACES) { + it(`rebuilds the paragraph at ${path}`, () => { + expect(textsOf(at(de, path))).toEqual(["de: three", "de:two", "de:one "]); + }); + } + + it("translates the textarea leaf too", () => { + expect(de.summary).toBe("de:Plain summary"); + }); + + it("leaves the non-localized siblings of every shared row untouched", () => { + expect(at(de, "items.0.code")).toBe("ITEM-KEEP"); + expect(at(de, "sections.0.anchor")).toBe("ANCHOR-KEEP"); + expect(at(de, "deep.rows.0.parts.0.partRef")).toBe("PART-KEEP"); + }); + + it("leaves the source locale's own rich text alone", () => { + for (const path of PLACES) { + expect(textsOf(at(en, path)), path).toEqual(["one ", "two", " three"]); + } + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts b/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts new file mode 100644 index 000000000..a5ee1bbe5 --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { buildNestedCollections } from "./nestedCollections"; +import { callEndpoint } from "./callEndpoint"; + +/** + * One rich-text value carrying every container shape a Lexical tree produces, translated and read + * back from the database. + * + * A container is the nearest node holding at least one direct text child — a rule that names no + * node types, and whose consequences are what this spec pins: a heading and a quote are containers + * exactly as a paragraph is, **each list item is its own container** with its own marks numbered + * from one, and a node carrying no text at all (a line break) still occupies a position the reply + * may move it to. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const node = (type: string, children: unknown[], extra: Record = {}) => ({ + type, + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", + ...extra, +}); + +const listItem = (children: unknown[], value: number) => + node("listitem", children, { value, checked: undefined }); + +type Child = { type: string; text?: string; children?: Child[] }; + +const root = (children: unknown[]) => ({ + root: node("root", children) as unknown, +}); + +const childrenAt = (value: unknown, path: number[]): Child[] => { + let current = (value as { root?: Child }).root as Child | undefined; + for (const index of path) current = current?.children?.[index]; + return current?.children ?? []; +}; + +const textsAt = (value: unknown, path: number[]): (string | undefined)[] => + childrenAt(value, path).map((c) => c.text); + +describe("every Lexical container shape, translated independently", () => { + let ctx: TestPayload; + let de: Record; + + beforeAll(async () => { + ctx = await bootTestPayload({ inlineMarks: true, collections: buildNestedCollections() }); + + const body = root([ + node("heading", [text("chapter "), text("one", 1)], { tag: "h2" }), + node( + "list", + [ + listItem([text("first "), text("item", 1)], 1), + listItem([text("second "), text("item", 1)], 2), + ], + { listType: "bullet", start: 1, tag: "ul" } + ), + node("quote", [text("quoted "), text("words", 1)]), + node("paragraph", [text("before "), { type: "linebreak", version: 1 }, text(" after")]), + ]); + + const created = await ctx.payload.create({ + collection: "nested" as "pages", + locale: "en", + data: { body, summary: "S" } as never, + }); + const id = String((created as { id: string | number }).id); + + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "nested", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: true, + }, + }); + + de = (await ctx.payload.findByID({ + collection: "nested" as "pages", + id, + locale: "de" as "en", + })) as unknown as Record; + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it("treats a heading as a container", () => { + expect(textsAt(de.body, [0])).toEqual(["de:one", "de:chapter "]); + }); + + it("treats each list item as its own container, numbered from one", () => { + // Independent numbering is the point: item two's marks restart, so a reply that reordered item + // one cannot disturb item two. + expect(textsAt(de.body, [1, 0])).toEqual(["de:item", "de:first "]); + expect(textsAt(de.body, [1, 1])).toEqual(["de:item", "de:second "]); + }); + + it("treats a quote as a container", () => { + expect(textsAt(de.body, [2])).toEqual(["de:words", "de:quoted "]); + }); + + it("moves a text-free node with the rest", () => { + const kinds = childrenAt(de.body, [3]).map((c) => c.type); + + expect(kinds).toEqual(["text", "linebreak", "text"]); + expect(textsAt(de.body, [3])).toEqual(["de: after", undefined, "de:before "]); + }); + + it("does not turn the list itself into a container", () => { + // The list holds list items, not text, so it is walked past rather than translated as one + // string — otherwise both items would share one numbering and could swap places. + expect(childrenAt(de.body, [1]).map((c) => c.type)).toEqual(["listitem", "listitem"]); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks.int.test.ts b/apps/dev/src/integration/translator/inline-marks.int.test.ts new file mode 100644 index 000000000..fe2d241c1 --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks.int.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; +import type { TestPayload } from "./bootTestPayload"; + +/** + * Container-granular rich-text translation, end to end: a real Payload, a real save, and the + * document read back out of the database. + * + * The fake reverses mark order, which is what a language with different word order does and what + * the marked format exists to survive. With the flag off the same reply cannot be produced at all — + * each node is translated alone — so every assertion here fails, which is how these specs were + * first run. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const linkTo = (url: string, children: unknown[]) => ({ + type: "link", + fields: { url, linkType: "custom" }, + children, + version: 3, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +const richText = (children: unknown[]) => ({ + root: { + type: "root", + children: [paragraph(children)], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { type: string; text?: string; fields?: { url?: string }; children?: Child[] }; + +const childrenOf = (doc: Record): Child[] => + ((doc.body as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0] + ?.children ?? []) as Child[]; + +const textsOf = (doc: Record): (string | undefined)[] => + childrenOf(doc).map((c) => (c.type === "link" ? c.children?.[0]?.text : c.text)); + +describe("container-granular rich text, saved and read back", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ inlineMarks: true }); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + const translate = async (body: unknown) => { + const created = await ctx.payload.create({ + collection: "docs" as "pages", + locale: "en", + data: { title: "Marks source", body } as never, + }); + const id = String((created as { id: string | number }).id); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "docs", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: true, + }, + }); + return (await ctx.payload.findByID({ + collection: "docs" as "pages", + id, + locale: "de" as "en", + })) as unknown as Record; + }; + + it("rebuilds a paragraph in the order the reply came back", async () => { + const de = await translate(richText([text("a "), text("red", 1), text(" car")])); + + expect(textsOf(de)).toEqual(["de: car", "de:red", "de:a "]); + }); + + it("carries a word's formatting with it when the word moves", async () => { + const de = await translate(richText([text("a "), text("red", 1), text(" car")])); + + // Both halves: the order proves the reply moved things, the formats prove each one took its + // own formatting along. Per-node translation keeps the source order, so this pair cannot pass + // without the container path. + expect(childrenOf(de).map((c) => [c.text, (c as { format?: number }).format])).toEqual([ + ["de: car", 0], + ["de:red", 1], + ["de:a ", 0], + ]); + }); + + it("moves a link with its word, keeping the href", async () => { + // The link sits first, so a reply that reorders has to put it last — a middle position would + // look the same in both modes and prove nothing. + const de = await translate(richText([linkTo("/docs", [text("manual")]), text(" first")])); + const kinds = childrenOf(de).map((c) => c.type); + const link = childrenOf(de).find((c) => c.type === "link"); + + expect(kinds).toEqual(["text", "link"]); + expect(link?.fields?.url).toBe("/docs"); + expect(link?.children?.[0]?.text).toBe("de:manual"); + }); + + it("splits a wrapper that holds two differently formatted words, keeping both hrefs", async () => { + const de = await translate( + richText([ + text("See "), + linkTo("/docs", [text("read the "), text("manual", 1)]), + text(" now"), + ]) + ); + const links = childrenOf(de).filter((c) => c.type === "link"); + + // D12: one source wrapper becomes several adjacent wrappers, not merged back. + expect(links).toHaveLength(2); + expect(links.map((l) => l.fields?.url)).toEqual(["/docs", "/docs"]); + }); + + it("does not split a wrapper again when the document is translated twice", async () => { + const de = await translate( + richText([ + text("See "), + linkTo("/docs", [text("read the "), text("manual", 1)]), + text(" now"), + ]) + ); + expect(childrenOf(de).filter((c) => c.type === "link")).toHaveLength(2); + + const again = (await ctx.payload.findByID({ + collection: "docs" as "pages", + id: String((de as { id: string | number }).id), + locale: "de" as "en", + })) as unknown as Record; + + expect(childrenOf(again).filter((c) => c.type === "link")).toHaveLength(2); + }); +}); diff --git a/apps/dev/src/integration/translator/job-extend.int.test.ts b/apps/dev/src/integration/translator/job-extend.int.test.ts index 16481d493..1635f296a 100644 --- a/apps/dev/src/integration/translator/job-extend.int.test.ts +++ b/apps/dev/src/integration/translator/job-extend.int.test.ts @@ -92,7 +92,7 @@ describe("a second request extends the live job rather than replacing it", () => fallbackLocale: false, draft: true, })) as Record; - expect(doc.title, `${locale} was not translated`).toBe("crS"); + expect(doc.title, `${locale} was not translated`).toBe(`${locale}:Src`); } }); diff --git a/apps/dev/src/integration/translator/locale-append.int.test.ts b/apps/dev/src/integration/translator/locale-append.int.test.ts index 42f56cf1e..33584d130 100644 --- a/apps/dev/src/integration/translator/locale-append.int.test.ts +++ b/apps/dev/src/integration/translator/locale-append.int.test.ts @@ -16,7 +16,7 @@ type Job = { log?: Array<{ state: string; input?: { target_lng?: string } }>; }; -const rev = (value: string) => [...value].reverse().join(""); +const tr = (locale: string, value: string) => (value.trim() ? `${locale}:${value}` : value); let ctx: TestPayload; let held: (() => void) | undefined; @@ -115,7 +115,7 @@ describe("adding a locale to a running job", () => { logged.map((l) => l[0]), "the appended locale never ran" ).toEqual(["de", "fr", "es"]); - expect(esTitle, "the appended locale was not translated").toBe(rev("Append source")); + expect(esTitle, "the appended locale was not translated").toBe(tr("es", "Append source")); const { totalDocs } = await ctx.payload.count({ collection: "payload-jobs" as "pages", diff --git a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts index 56e5c34fb..ccda800cc 100644 --- a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts @@ -8,7 +8,7 @@ import { callEndpoint } from "./callEndpoint"; // Its own file: the failing provider is fixed at boot, and a boot is per process (see // `bootTestPayload`). -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); let failing: TestPayload; @@ -57,7 +57,9 @@ describe("when one locale's provider fails", () => { })) as Record ).title; - expect(await read("de"), "the locale before the failure should have landed").toBe(rev(source)); + expect(await read("de"), "the locale before the failure should have landed").toBe( + tr("de", source) + ); expect(await read("fr"), "the failing locale should not have landed").toBeUndefined(); expect(await read("es"), "the locale after the failure should be untouched").toBeUndefined(); diff --git a/apps/dev/src/integration/translator/locale-workflow.int.test.ts b/apps/dev/src/integration/translator/locale-workflow.int.test.ts index 9f0c1accd..2133de200 100644 --- a/apps/dev/src/integration/translator/locale-workflow.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow.int.test.ts @@ -8,7 +8,7 @@ import { callEndpoint } from "./callEndpoint"; // Must boot the real jobs runner: `createSyncRunner` translates inline and in order, so the fan-out // this file guards against cannot occur under it. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); let ctx: TestPayload; @@ -78,8 +78,8 @@ describe("translating one document into several locales", () => { await enqueue(id, ["de", "fr"]); await runQueue(); - expect(await titleIn(id, "de"), "de was not translated").toBe(rev(source)); - expect(await titleIn(id, "fr"), "fr was not translated").toBe(rev(source)); + expect(await titleIn(id, "de"), "de was not translated").toBe(tr("de", source)); + expect(await titleIn(id, "fr"), "fr was not translated").toBe(tr("fr", source)); }); it("runs the locales one after another, never overlapping", async () => { diff --git a/apps/dev/src/integration/translator/multi-target.int.test.ts b/apps/dev/src/integration/translator/multi-target.int.test.ts index 8606bd451..8645e49bb 100644 --- a/apps/dev/src/integration/translator/multi-target.int.test.ts +++ b/apps/dev/src/integration/translator/multi-target.int.test.ts @@ -10,7 +10,7 @@ import { callEndpoint } from "./callEndpoint"; // UI `targetSelection` mode. Provenance (one row per target) is the durable per-target record, so it // doubles as the assertion that every queued target is tracked independently. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const PROV = "translator-provenance"; type ProvRow = { targetLocale: string; sourceLocale: string }; @@ -67,8 +67,8 @@ describe("multi-target enqueue fan-out (#46)", () => { expect(res.status).toBe(200); expect(queuedOf(res)).toBe(2); // 1 doc × 2 targets - expect(await titleIn(ctx, id, "de")).toBe(rev("Fanout src")); - expect(await titleIn(ctx, id, "fr")).toBe(rev("Fanout src")); + expect(await titleIn(ctx, id, "de")).toBe(tr("de", "Fanout src")); + expect(await titleIn(ctx, id, "fr")).toBe(tr("fr", "Fanout src")); expect(await titleIn(ctx, id, "en")).toBe("Fanout src"); // source intact expect(await provTargets(ctx, id)).toEqual(["de", "fr"]); // independent per-target rows }); @@ -83,8 +83,8 @@ describe("multi-target enqueue fan-out (#46)", () => { for (const id of [a, b]) { expect(await provTargets(ctx, id)).toEqual(["de", "fr"]); } - expect(await titleIn(ctx, a, "fr")).toBe(rev("Doc A")); - expect(await titleIn(ctx, b, "de")).toBe(rev("Doc B")); + expect(await titleIn(ctx, a, "fr")).toBe(tr("fr", "Doc A")); + expect(await titleIn(ctx, b, "de")).toBe(tr("de", "Doc B")); }); it("drops an unknown target locale and still runs the valid ones (AC4)", async () => { @@ -94,7 +94,7 @@ describe("multi-target enqueue fan-out (#46)", () => { expect(queuedOf(res)).toBe(1); // only the configured "de" survives expect(await provTargets(ctx, id)).toEqual(["de"]); // no phantom "xx" row - expect(await titleIn(ctx, id, "de")).toBe(rev("Unknown src")); + expect(await titleIn(ctx, id, "de")).toBe(tr("de", "Unknown src")); }); it("de-dups duplicate target locales to one task per locale (AC5)", async () => { @@ -130,7 +130,7 @@ describe("multi-target enqueue fan-out (#46)", () => { const res = await enqueue(ctx, [id], "de"); expect(queuedOf(res)).toBe(1); - expect(await titleIn(ctx, id, "de")).toBe(rev("Scalar src")); + expect(await titleIn(ctx, id, "de")).toBe(tr("de", "Scalar src")); expect(await provTargets(ctx, id)).toEqual(["de"]); }); }); diff --git a/apps/dev/src/integration/translator/nestedCollections.ts b/apps/dev/src/integration/translator/nestedCollections.ts new file mode 100644 index 000000000..214fc10e4 --- /dev/null +++ b/apps/dev/src/integration/translator/nestedCollections.ts @@ -0,0 +1,88 @@ +import type { Block, CollectionConfig } from "payload"; + +/** + * A collection that puts a localized `richText` field under **every** container the field walker + * classifies — group, array, blocks, a named tab, an unnamed tab, and the two "transparent" shapes + * (`row`, `collapsible`) that carry fields without owning a data level — plus one combination three + * containers deep. + * + * The shared `docs` fixture proves the walker reaches a localized `text` under each of those. It + * carries exactly one rich-text field, at the top level, so container-granular translation — which + * rewrites a rich-text value in place inside whatever row holds it — has never been exercised + * anywhere but the shallowest possible position. + * + * Every array and block row also carries a NON-localized sibling. Those rows are shared across + * locales: one row, per-locale leaf columns, shared columns for the rest. Rewriting a rich-text + * leaf inside such a row is where a mistake costs another locale its content, so each spec asserts + * the sibling survived. + */ + +const partBlock: Block = { + slug: "part", + fields: [ + { name: "body", type: "richText", localized: true }, + { name: "partRef", type: "text" }, + ], +}; + +const heroBlock: Block = { + slug: "hero", + fields: [ + { name: "body", type: "richText", localized: true }, + { name: "anchor", type: "text" }, + ], +}; + +export function buildNestedCollections(): CollectionConfig[] { + const users: CollectionConfig = { slug: "users", auth: true, fields: [] }; + + const nested: CollectionConfig = { + slug: "nested", + admin: { useAsTitle: "summary" }, + fields: [ + { name: "body", type: "richText", localized: true }, + // The one translatable leaf type the suite never used. + { name: "summary", type: "textarea", localized: true }, + { + name: "meta", + type: "group", + fields: [{ name: "body", type: "richText", localized: true }], + }, + { + name: "items", + type: "array", + fields: [ + { name: "body", type: "richText", localized: true }, + { name: "code", type: "text" }, + ], + }, + { name: "sections", type: "blocks", blocks: [heroBlock] }, + { + type: "tabs", + tabs: [ + { name: "seo", fields: [{ name: "body", type: "richText", localized: true }] }, + { label: "Loose", fields: [{ name: "looseBody", type: "richText", localized: true }] }, + ], + }, + { type: "row", fields: [{ name: "rowBody", type: "richText", localized: true }] }, + { + type: "collapsible", + label: "More", + fields: [{ name: "collapsibleBody", type: "richText", localized: true }], + }, + { + name: "deep", + type: "group", + fields: [ + { + name: "rows", + type: "array", + fields: [{ name: "parts", type: "blocks", blocks: [partBlock] }], + }, + ], + }, + ], + }; + + return [users, nested]; +} diff --git a/apps/dev/src/integration/translator/source-semantics.int.test.ts b/apps/dev/src/integration/translator/source-semantics.int.test.ts index c318f3376..2709a4ee0 100644 --- a/apps/dev/src/integration/translator/source-semantics.int.test.ts +++ b/apps/dev/src/integration/translator/source-semantics.int.test.ts @@ -10,7 +10,7 @@ import { callEndpoint } from "./callEndpoint"; // existing spec. // The fake provider translates by reversing the string. -const machineTranslated = (s: string) => [...s].reverse().join(""); +const machineTranslated = (s: string, locale = "de") => `${locale}:${s}`; const EN = "Hello from EN"; type Locale = "en" | "de" | "fr"; diff --git a/apps/dev/src/integration/translator/strategies.int.test.ts b/apps/dev/src/integration/translator/strategies.int.test.ts index e235d5a7e..3d4215e26 100644 --- a/apps/dev/src/integration/translator/strategies.int.test.ts +++ b/apps/dev/src/integration/translator/strategies.int.test.ts @@ -8,7 +8,7 @@ import { callEndpoint } from "./callEndpoint"; // overwrite → replaces an existing target value with the new translation. // skip_existing → keeps an already-filled target value, but still fills an EMPTY sibling. -const rev = (s: string) => [...s].reverse().join(""); +const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); const enqueue = (ctx: TestPayload, id: string, strategy: "overwrite" | "skip_existing") => callEndpoint(ctx.payload, "post", "/translate/enqueue", { @@ -49,7 +49,7 @@ describe("translation strategies", () => { await enqueue(ctx, id, "overwrite"); const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe(rev("En title")); // replaced + expect(de.title).toBe(tr("de", "En title")); // replaced expect(de.title).not.toBe("Manual DE"); }); @@ -72,6 +72,6 @@ describe("translation strategies", () => { const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); expect(de.title).toBe("Keep DE"); // existing kept - expect((de.meta as { subtitle?: string }).subtitle).toBe(rev("En sub")); // empty filled + expect((de.meta as { subtitle?: string }).subtitle).toBe(tr("de", "En sub")); // empty filled }); }); diff --git a/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts b/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts index 2815250a4..9b87e4703 100644 --- a/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts +++ b/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts @@ -8,7 +8,7 @@ type Slug = "docs" | "versioned"; type Locale = "en" | "de" | "fr"; const SOURCE = "Hello world"; -const MACHINE = [...SOURCE].reverse().join(""); +const MACHINE = `de:${SOURCE}`; const REVIEWED = "REVIEWED BY A HUMAN"; let payload: Payload; diff --git a/apps/dev/src/lib/translator/devToggles.ts b/apps/dev/src/lib/translator/devToggles.ts index f6419b369..cc935463c 100644 --- a/apps/dev/src/lib/translator/devToggles.ts +++ b/apps/dev/src/lib/translator/devToggles.ts @@ -9,7 +9,7 @@ import type { TranslationProvider, } from "@focus-reactive/payload-plugin-translator"; -import { failingComplete, reverseComplete } from "./fakeComplete"; +import { fakeComplete, failingComplete } from "./fakeComplete"; // See apps/dev/docs/multi-db-verification.md. @@ -33,7 +33,7 @@ export function resolveTranslationProvider(): TranslationProvider { } if (process.env.TRANSLATOR_DRY_RUN === "1" || !process.env.OPENAI_API_KEY) { - return createTranslationProvider({ complete: reverseComplete }); + return createTranslationProvider({ complete: fakeComplete() }); } return createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }); diff --git a/apps/dev/src/lib/translator/fakeComplete.ts b/apps/dev/src/lib/translator/fakeComplete.ts index 8703cf4be..e769c6240 100644 --- a/apps/dev/src/lib/translator/fakeComplete.ts +++ b/apps/dev/src/lib/translator/fakeComplete.ts @@ -1,22 +1,84 @@ import type { CompletionFn } from "@focus-reactive/payload-plugin-translator"; +/** `<1>text` or `<1/>` — the marked wire format, as the core writes it. */ +const MARK = /<(\d+)>([\s\S]*?)<\/\1>|<(\d+)\/>/gu; + /** - * A stand-in for a translation service: reverses every value, reaches no network, needs no API key. - * - * Reversal is by code point, not by UTF-16 unit — the integration suite asserts against - * `[...s].reverse().join("")`, and the two disagree on anything outside the basic plane. + * The prompt is the only place the target language reaches a completion function. Throwing rather + * than defaulting is deliberate: a reworded prompt then breaks the suite loudly instead of + * silently labelling every translation with the wrong locale. */ -export const reverseComplete: CompletionFn = ({ userContent }) => { - const input = JSON.parse(userContent) as Record; - const reversed: Record = {}; +const targetLocaleOf = (systemPrompt: string): string => { + const found = /\binto ([A-Za-z-]+)/u.exec(systemPrompt)?.[1]; + if (!found) throw new Error(`fakeComplete: no target locale in the system prompt`); + return found; +}; + +const translated = (locale: string, text: string): string => + text.trim() ? `${locale}:${text}` : text; + +type Mark = { id: string; text: string | null }; - for (const [key, value] of Object.entries(input)) { - reversed[key] = value.trim() ? [...value].reverse().join("") : value; - } +const parseMarks = (value: string): Mark[] => + [...value.matchAll(MARK)].map((m) => + m[3] === undefined ? { id: m[1] as string, text: m[2] as string } : { id: m[3], text: null } + ); + +const render = (marks: Mark[]): string => + marks.map((m) => (m.text === null ? `<${m.id}/>` : `<${m.id}>${m.text}`)).join(""); + +/** How the fake should mangle a marked reply, for specs that exercise the fallback. */ +export type MarkCorruption = "drop" | "repeat" | "unclosed"; + +const corrupted = (marks: Mark[], how: MarkCorruption): string => { + if (how === "drop") return render(marks.slice(1)); + if (how === "repeat") return render([...marks, marks[0] as Mark]); + return `${render(marks)}<${marks[0]?.id ?? 1}>`; +}; - return Promise.resolve(JSON.stringify(reversed)); +export type FakeTranslationOptions = { + /** Return marks in the order they were sent. Off by default: reordering is what marks are for. */ + keepMarkOrder?: boolean; + /** Break every marked reply this way, so the caller has to keep the source text. */ + corrupt?: MarkCorruption; }; +/** + * A stand-in for a translation service: deterministic, reaches no network, needs no API key. + * + * Every value comes back prefixed with the target locale, so a spec can tell *which* locale's + * translation landed and can never mistake a hand-typed value for a translated one. + * + * A value carrying numbered marks is understood rather than mangled: each mark comes back exactly + * once with its own text translated, and — unless asked otherwise — in reverse order, because + * reordering is the whole point of the marked format and a fake that preserved order would leave + * the feature untested. + */ +export const fakeComplete = + (options?: FakeTranslationOptions): CompletionFn => + ({ systemPrompt, userContent }) => { + const locale = targetLocaleOf(systemPrompt); + const input = JSON.parse(userContent) as Record; + const out: Record = {}; + + for (const [key, value] of Object.entries(input)) { + const marks = parseMarks(value); + if (marks.length === 0) { + out[key] = translated(locale, value); + continue; + } + + const done = marks.map((m) => ({ + ...m, + text: m.text === null ? null : translated(locale, m.text), + })); + const ordered = options?.keepMarkOrder ? done : [...done].reverse(); + out[key] = options?.corrupt ? corrupted(ordered, options.corrupt) : render(ordered); + } + + return Promise.resolve(JSON.stringify(out)); + }; + export const failingComplete: CompletionFn = () => { throw new Error("forced translation failure"); }; From 8cd5794f9bd5ee4140c572c644d5aa7a47ce08b3 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sat, 12 Sep 2026 15:07:52 +0200 Subject: [PATCH 07/11] test(dev): make the sandbox able to exercise container-granular rich text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration suite proves the feature against a stub. Nothing let a person drive it by hand against a real model, which is where the questions that matter turn up — whether the translation is any good, and whether it is better than what the old path produced. Four changes, each needed before the sandbox could show the feature at all: - **The mode is on by default here**, unlike the plugin, where it stays behind the flag. A sandbox run that silently used the old path looks exactly like a feature that does not work, and that is a bad half-hour to spend. `TRANSLATOR_INLINE_MARKS=0` goes back to translating node by node, which is also how the two modes get compared on the same document. - **The local fake declares the capability it genuinely has.** The real OpenAI provider declares it; the fake did not, so a dry run fell back to the old path no matter what the flag said. - **`playground` keeps drafts and versions.** `pages` already had them, but its shape is flat, so until now no versioned document had nested blocks — and a translation writes into the draft, which is the behaviour worth being able to watch. - **A third seeded article, "Word order".** Every sentence in it is one German forces out of English order: the participle or the infinitive lands at the end of the clause, negation moves behind the object. The formatting and the links sit on exactly those travelling words. The existing two articles cannot show the difference between the modes, because their French and German read in English order anyway. `importMap.js` is deliberately not part of this commit. Regenerating it here drops the analytics plugin's components and `@/lead-actions-admin`, because this machine has no GA4 credentials and the plugin disables itself — a record of a missing environment, not a change to the app. --- apps/dev/src/collections/Playground.ts | 5 ++ apps/dev/src/lib/translator/devToggles.ts | 15 ++++- apps/dev/src/payload-types.ts | 46 +++++++++++++- apps/dev/src/payload.config.ts | 7 ++- apps/dev/src/seed.ts | 77 +++++++++++++++++++++++ 5 files changed, 147 insertions(+), 3 deletions(-) diff --git a/apps/dev/src/collections/Playground.ts b/apps/dev/src/collections/Playground.ts index 3ca849bbb..b4e0424f9 100644 --- a/apps/dev/src/collections/Playground.ts +++ b/apps/dev/src/collections/Playground.ts @@ -7,6 +7,8 @@ import { DeepNestBlock } from "../blocks/DeepNest"; // The top-level `layout` blocks field is intentionally NOT localized (the leaves inside DeepNest // are) — see blocks/DeepNest.ts for the rationale. Create a doc, fill the fields in `en`, save, // switch locale, then use the per-field translate control at any depth. +// Drafts are on here and nowhere else with this depth: `pages` has versions but a flat +// shape, so this is the only place where an unpublished draft meets nested blocks. export const Playground: CollectionConfig = { slug: "playground", admin: { @@ -25,4 +27,7 @@ export const Playground: CollectionConfig = { type: "blocks", }, ], + versions: { + drafts: true, + }, }; diff --git a/apps/dev/src/lib/translator/devToggles.ts b/apps/dev/src/lib/translator/devToggles.ts index cc935463c..e8ae2ff19 100644 --- a/apps/dev/src/lib/translator/devToggles.ts +++ b/apps/dev/src/lib/translator/devToggles.ts @@ -33,8 +33,21 @@ export function resolveTranslationProvider(): TranslationProvider { } if (process.env.TRANSLATOR_DRY_RUN === "1" || !process.env.OPENAI_API_KEY) { - return createTranslationProvider({ complete: fakeComplete() }); + return createTranslationProvider({ + capabilities: { inlineMarks: true }, + complete: fakeComplete(), + }); } return createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }); } + +/** + * Rich text is translated one container at a time, with numbered inline marks, so the translation + * may reorder the pieces. On by default here, unlike the plugin itself: this sandbox exists to + * exercise the current behaviour, and a run that silently used the old path would look like a + * feature that does not work. `TRANSLATOR_INLINE_MARKS=0` returns to translating node by node. + */ +export function resolveInlineMarks(): boolean { + return process.env.TRANSLATOR_INLINE_MARKS !== "0"; +} diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index 0cac1eadc..b37eebfee 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -132,7 +132,9 @@ export interface Config { output: unknown; }; }; - workflows: unknown; + workflows: { + translate_document_locales: WorkflowTranslateDocumentLocales; + }; }; } export interface UserAuthOperations { @@ -417,6 +419,7 @@ export interface Playground { | null; updatedAt: string; createdAt: string; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -849,6 +852,7 @@ export interface PayloadJob { id?: string | null; }[] | null; + workflowSlug?: 'translate_document_locales' | null; taskSlug?: ('inline' | 'translate_document' | 'schedulePublish') | null; queue?: string | null; waitUntil?: string | null; @@ -1108,6 +1112,7 @@ export interface PlaygroundSelect { }; updatedAt?: T; createdAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1327,6 +1332,7 @@ export interface PayloadJobsSelect { error?: T; id?: T; }; + workflowSlug?: T; taskSlug?: T; queue?: T; waitUntil?: T; @@ -1480,6 +1486,44 @@ export interface TaskSchedulePublish { }; output?: unknown; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "WorkflowTranslate_document_locales". + */ +export interface WorkflowTranslateDocumentLocales { + input: { + collection_slug: string; + collection_id: string; + /** + * Deprecated. See docs/DEPRECATIONS.md#jobs-input-collection-field + */ + collection?: + | ({ + relationTo: 'pages'; + value: number | Page; + } | null) + | ({ + relationTo: 'articles'; + value: number | Article; + } | null) + | ({ + relationTo: 'playground'; + value: number | Playground; + } | null); + source_lng: string; + strategy: string; + publish_on_translation?: boolean | null; + target_lngs: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/apps/dev/src/payload.config.ts b/apps/dev/src/payload.config.ts index ca41297f5..7b5d96ac8 100644 --- a/apps/dev/src/payload.config.ts +++ b/apps/dev/src/payload.config.ts @@ -27,7 +27,11 @@ import { Header } from "./globals/Header"; import { abAdapter } from "./lib/ab-testing/dbAdapter"; import { resolveDbAdapter } from "./lib/database/resolveAdapter"; import { loggingLifecycle } from "./lib/translator/lifecycleLogging"; -import { resolveTranslationProvider, resolveTranslatorRunner } from "./lib/translator/devToggles"; +import { + resolveInlineMarks, + resolveTranslationProvider, + resolveTranslatorRunner, +} from "./lib/translator/devToggles"; const baseDir = path.dirname(fileURLToPath(import.meta.url)); @@ -103,6 +107,7 @@ export default buildConfig({ levels: [documentLevel(), collectionLevel(), fieldLevel()], provenance: true, lifecycle: loggingLifecycle, + experimental: { inlineMarks: resolveInlineMarks() }, }), analyticsPlugin({ ga4: { diff --git a/apps/dev/src/seed.ts b/apps/dev/src/seed.ts index 2b3da0f4f..f0e537902 100644 --- a/apps/dev/src/seed.ts +++ b/apps/dev/src/seed.ts @@ -255,6 +255,83 @@ const run = async () => { quote("A good seed exercises every node the editor can produce.") ), }, + { + // Every sentence here is one German forces out of English order: the participle or the + // infinitive lands at the end of the clause, and negation moves behind the object. The + // formatting and the links sit on exactly those travelling words, so a translation that + // keeps each piece in its source slot produces visibly broken German — which is what + // separates translating a container as one string from translating node by node. + title: "Word order", + content: doc( + heading("h2", "Why word order ", txt("matters", BOLD)), + para( + txt("The team has "), + txt("published", BOLD), + txt(" the "), + txt("new documentation", ITALIC), + txt(".") + ), + para( + txt("We can "), + txt("send", BOLD), + txt(" you the "), + link("full report", "https://payloadcms.com"), + txt(" tomorrow.") + ), + para( + txt("She did "), + txt("not", BOLD), + txt(" read the "), + txt("manual", ITALIC), + txt(" before she started.") + ), + para( + txt("If you "), + txt("register", BOLD), + txt(" today, you will receive the "), + txt("early access", ITALIC), + txt(".") + ), + para( + txt("An editor must be able to "), + txt("reorder", BOLD), + txt(" the "), + link("inline links", "https://payloadcms.com/docs"), + txt(" freely.") + ), + list( + "bullet", + li( + txt("You have "), + txt("already", BOLD), + txt(" translated this "), + txt("page", ITALIC), + txt(".") + ), + li( + txt("We will "), + txt("not", BOLD), + txt(" publish it "), + txt("today", ITALIC), + txt(".") + ), + li( + txt("The reviewer has "), + txt("approved", BOLD), + txt(" every "), + link("open change", "https://payloadcms.com"), + txt(".") + ) + ), + quote( + txt("Nobody can "), + txt("guarantee", BOLD), + txt(" that the "), + txt("source order", ITALIC), + txt(" survives a translation.") + ) + ), + }, { title: "Second article", content: doc( From 1b9066cc47a292326179a28082d9203c9a0af3e0 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sat, 12 Sep 2026 15:25:13 +0200 Subject: [PATCH 08/11] fix(translator): let the per-field control translate rich text by container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createFieldRoute` accepted `inlineMarks` in its argument type and never passed it to the handler, so `translateContent` saw `undefined` and took the per-node path every time. One of the plugin's three translation surfaces quietly ignored the option this release exists to introduce: an editor pressing translate on a single rich-text field got the old behaviour no matter how the plugin was configured. Proven with the local stub, which reverses mark order, so the two modes are distinguishable without a model: through the field endpoint the order never changed, at either flag value; through document translation it changed with the flag on and not with it off. **The flag is now required rather than optional**, on the field feature's config and on the shared `TranslationContext` it derives from. `plugin.ts` computes `experimental?.inlineMarks === true`, which is always a definite boolean — the value is never genuinely absent, so optionality described a state the system cannot be in, and that is what let a dropped argument compile into a silent fall-back. Deleting the argument at the call site is now an error that names the file. The only production change this forced is the route itself; every other edit is a test constructing a context. **Three specs cover the surface, because nothing did.** All twenty-six existing mark specs boot `levels: [documentLevel()]`, so the field route's wiring was never exercised — which is why a dropped argument survived review, a type check and a full suite. The new ones drive the real endpoint through `callEndpoint` against a booted Payload, one configuration per file as everywhere else here: marks reordered with the mode on, source order with it off, and source order when the provider never declared it can keep marks. The first was red against this defect before the fix. The README gains the option, what the two gates do when they stop it, and why the entry is deprecated on the day it ships while `experimental` itself is not. --- .../integration/translator/bootTestPayload.ts | 5 +- ...nline-marks-field-surface-gate.int.test.ts | 108 ++++++++++ ...inline-marks-field-surface-off.int.test.ts | 106 ++++++++++ .../inline-marks-field-surface.int.test.ts | 106 ++++++++++ packages/payload-plugin-translator/README.md | 41 ++++ ...6-09-12-field-surface-inline-marks.task.md | 185 ++++++++++++++++++ .../src/composition/levels/fieldLevel.test.ts | 1 + .../src/composition/levels/levels.test.ts | 1 + .../features/translate-field/handler.test.ts | 6 +- .../server/features/translate-field/model.ts | 7 +- .../features/translate-field/route.test.ts | 8 +- .../server/features/translate-field/route.ts | 3 +- .../PluginConfigBuilder.test.ts | 1 + .../translation-levels/PluginConfigBuilder.ts | 2 +- .../modules/translation-levels/types.ts | 2 +- 15 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md diff --git a/apps/dev/src/integration/translator/bootTestPayload.ts b/apps/dev/src/integration/translator/bootTestPayload.ts index b9cbb9710..888889a4d 100644 --- a/apps/dev/src/integration/translator/bootTestPayload.ts +++ b/apps/dev/src/integration/translator/bootTestPayload.ts @@ -7,6 +7,7 @@ import { createTranslationProvider, createSyncRunner, documentLevel, + fieldLevel, translatorPlugin, withAutoTranslate, } from "@focus-reactive/payload-plugin-translator"; @@ -93,6 +94,8 @@ export async function bootTestPayload(opts?: { declareCapability?: boolean; /** How the fake answers a marked value — reorder by default, keep order, or corrupt it. */ fake?: FakeTranslationOptions; + /** Also register the synchronous per-field surface, `POST {basePath}/field`. */ + fieldSurface?: boolean; }): Promise { const dir = mkdtempSync(join(tmpdir(), "translator-int-")); const { db, drop } = createTestDatabase(join(dir, "test.db")); @@ -154,7 +157,7 @@ export async function bootTestPayload(opts?: { collections: managed, translationProvider: countingProvider, runner: opts?.runner ?? createSyncRunner(), - levels: [documentLevel()], + levels: opts?.fieldSurface ? [documentLevel(), fieldLevel()] : [documentLevel()], provenance: true, ...(opts?.inlineMarks ? { experimental: { inlineMarks: true } } : {}), }), diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts new file mode 100644 index 000000000..af67a70cf --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; +import type { TestPayload } from "./bootTestPayload"; + +/** + * The per-field control with the mode on and a provider that never said it can keep marks. + * + * Every other mark spec drives the document surface. These exist because the two surfaces reach + * `translateContent` down different wiring, and only one of them was ever exercised — so the field + * route could accept the option, drop it, and stay green everywhere. + * + * The endpoint returns the translated value instead of saving it, so this reads the reply rather + * than the document. One boot per file, as everywhere in this suite. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +const richText = (children: unknown[]) => ({ + root: { + type: "root", + children: [paragraph(children)], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { type: string; text?: string; format?: number }; + +const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { + const children = + (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []; + return children.map((c) => [c.text, c.format]); +}; + +/** + * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be + * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared + * object survives only its first boot. + */ +const source = () => richText([text("a "), text("red", 1), text(" car")]); + +const translateField = async (ctx: TestPayload) => { + const created = await ctx.payload.create({ + collection: "docs" as "pages", + locale: "en", + data: { title: "Field surface source", body: source() } as never, + }); + + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: String((created as { id: string | number }).id), + }, + }); + + expect(res.status).toBe(200); + const reply = (res.data as { data: { status: string; value: unknown } }).data; + expect(reply.status).toBe("translated"); + return piecesOf(reply.value); +}; + +describe("per-field translation, the mode on but the provider silent about marks", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ + inlineMarks: true, + declareCapability: false, + fieldSurface: true, + }); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it("keeps the per-node path, because the capability gate outranks the option", async () => { + expect(await translateField(ctx)).toEqual([ + ["de:a ", 0], + ["de:red", 1], + ["de: car", 0], + ]); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts new file mode 100644 index 000000000..e5b6965eb --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; +import type { TestPayload } from "./bootTestPayload"; + +/** + * The per-field translate control with the mode OFF — the control for its sibling spec. + * + * Every other mark spec drives the document surface. These exist because the two surfaces reach + * `translateContent` down different wiring, and only one of them was ever exercised — so the field + * route could accept the option, drop it, and stay green everywhere. + * + * The endpoint returns the translated value instead of saving it, so this reads the reply rather + * than the document. One boot per file, as everywhere in this suite. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +const richText = (children: unknown[]) => ({ + root: { + type: "root", + children: [paragraph(children)], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { type: string; text?: string; format?: number }; + +const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { + const children = + (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []; + return children.map((c) => [c.text, c.format]); +}; + +/** + * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be + * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared + * object survives only its first boot. + */ +const source = () => richText([text("a "), text("red", 1), text(" car")]); + +const translateField = async (ctx: TestPayload) => { + const created = await ctx.payload.create({ + collection: "docs" as "pages", + locale: "en", + data: { title: "Field surface source", body: source() } as never, + }); + + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: String((created as { id: string | number }).id), + }, + }); + + expect(res.status).toBe(200); + const reply = (res.data as { data: { status: string; value: unknown } }).data; + expect(reply.status).toBe("translated"); + return piecesOf(reply.value); +}; + +describe("per-field translation, the mode off", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ fieldSurface: true }); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + // Without this the "mode on" spec would also pass on a build that ignores the option, as long as + // something else reordered the pieces. + it("keeps source order, translating node by node", async () => { + expect(await translateField(ctx)).toEqual([ + ["de:a ", 0], + ["de:red", 1], + ["de: car", 0], + ]); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts new file mode 100644 index 000000000..cc162cf89 --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; +import type { TestPayload } from "./bootTestPayload"; + +/** + * The per-field translate control, `POST /translate/field`, with container-granular rich text on. + * + * Every other mark spec drives the document surface. These exist because the two surfaces reach + * `translateContent` down different wiring, and only one of them was ever exercised — so the field + * route could accept the option, drop it, and stay green everywhere. + * + * The endpoint returns the translated value instead of saving it, so this reads the reply rather + * than the document. One boot per file, as everywhere in this suite. + */ + +const text = (value: string, format = 0) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const paragraph = (children: unknown[]) => ({ + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", +}); + +const richText = (children: unknown[]) => ({ + root: { + type: "root", + children: [paragraph(children)], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +type Child = { type: string; text?: string; format?: number }; + +const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { + const children = + (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []; + return children.map((c) => [c.text, c.format]); +}; + +/** + * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be + * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared + * object survives only its first boot. + */ +const source = () => richText([text("a "), text("red", 1), text(" car")]); + +const translateField = async (ctx: TestPayload) => { + const created = await ctx.payload.create({ + collection: "docs" as "pages", + locale: "en", + data: { title: "Field surface source", body: source() } as never, + }); + + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: String((created as { id: string | number }).id), + }, + }); + + expect(res.status).toBe(200); + const reply = (res.data as { data: { status: string; value: unknown } }).data; + expect(reply.status).toBe("translated"); + return piecesOf(reply.value); +}; + +describe("per-field translation, the mode on", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ inlineMarks: true, fieldSurface: true }); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it("returns the paragraph in the order the reply came back", async () => { + // Order and formats together: the order proves the reply moved the pieces, the formats prove + // each one took its own formatting along. Per-node translation can produce neither. + expect(await translateField(ctx)).toEqual([ + ["de: car", 0], + ["de:red", 1], + ["de:a ", 0], + ]); + }); +}); diff --git a/packages/payload-plugin-translator/README.md b/packages/payload-plugin-translator/README.md index 9ed3d2842..aac31d7d0 100644 --- a/packages/payload-plugin-translator/README.md +++ b/packages/payload-plugin-translator/README.md @@ -145,6 +145,7 @@ Allowed on **`text`, `textarea`, and `richText`** fields (a compile error on oth | `provenance` | `boolean \| { slug?: string }` | No | `false` (disabled) | Opt in to recording a provenance record per translation. _Since v0.7.0._ See [Provenance](#provenance-opt-in) below. | | `lifecycle` | `{ onQueued?, onCompleted?, onFailed? }` | No | `undefined` | Server-side callbacks fired around each task. _Since v0.7.0._ See [Lifecycle callbacks](#lifecycle-callbacks). | | `targetSelection` | `'single' \| 'multi'` | No | `'single'` | Let an editor pick several target locales in one run. _Since v0.10.0._ See [Target-language selection](#target-language-selection) below. | +| `experimental` | `{ inlineMarks?: boolean }` | No | `{}` | Transitional switches, adopted per install. See [Rich text, one container at a time](#rich-text-one-container-at-a-time) below. _Since v0.13.0._ | ```typescript translatorPlugin({ @@ -155,6 +156,46 @@ translatorPlugin({ }); ``` +### Rich text, one container at a time + +`experimental.inlineMarks` changes how rich text is translated. Off, each text node goes to the +translation service on its own, so every word stays in the slot its English counterpart occupied — +which is wrong the moment the target language wants a different order, and it pins formatting to a +position rather than to a word. On, the whole container — a paragraph, a heading, one list item — +goes as a single string with its formatting written as numbered marks: + +``` +<1>The team has <2>published<3> the <4>new documentation<5>. +``` + +The service returns the same marks, translated and in whatever order the target language needs, and +the container is rebuilt from that reply. Emphasis and links travel with their words. + +```typescript +translatorPlugin({ + collections: [Posts], + translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }), + runner: createPayloadJobsRunner(), + experimental: { inlineMarks: true }, +}); +``` + +Two things gate it, both silent by design — a translation still happens either way: + +- **The provider must declare it can keep marks** (`capabilities.inlineMarks`). + `createOpenAIProvider` does; a provider built from your own `complete` function declares it only + if you pass `capabilities: { inlineMarks: true }`. A transport that is not a language model + would mangle the marks, so the default is to assume it cannot. +- **A reply whose marks cannot be used** — one missing, one repeated, one left unclosed — leaves + that container in its source language rather than writing half of it. The rest of the document + still translates. + +`experimental` is permanent; **this entry is deprecated the day it ships**. The next major removes +the switch, not the behaviour: translating node by node stays as the internal fall-back for a +source that already contains marks, a single-fragment container, and an unusable reply. Turning the +switch back off stops future translations from splitting formatting wrappers; documents already +translated under it keep the shape they were given. _Since v0.13.0._ + ### Target-language selection _Since v0.10.0._ diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md new file mode 100644 index 000000000..195771f00 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md @@ -0,0 +1,185 @@ +# Container-granular rich text on the per-field surface + +PR #139 (issue #134) · branch `feat/translator-container-mode` · 2026-09-12 + +## Requirements / Task restatement + +Manual verification of container mode against a live model found that the per-field +translate control never uses it. `createFieldRoute` accepts `inlineMarks` in its argument +type and does not forward it to the handler, so `translateContent` is always called with +`undefined` and always takes the per-node path. One of the plugin's three translation +surfaces silently ignores the option the release announces. + +Four things follow from that, and a fifth rides along because the release is the one that +introduces the mode and should introduce it in the state we want to live with: + +1. Forward the flag (the defect). +2. Make omitting it a compile error rather than a silent fall-back to the old path. +3. Cover the per-field surface with marks in the integration suite — nothing did. +4. Document the option in the README. +5. Measure whether the mark instruction in the system prompt can be improved, on the + axis the live run showed to be weak. + +### Evidence for the defect + +Probed with the local stub, which reverses mark order, so the two modes are +distinguishable without a model: + +| Surface | flag `1` | flag `0` | +|---|---|---| +| `POST /translate/field` | order unchanged | order unchanged | +| document translation | order reversed | order unchanged | + +The document path forwards the flag (`wireTranslateRunner.ts:55`); the field path drops +it (`route.ts:18-24`). + +## Decisions + +### D1 — `inlineMarks` becomes required on the shared config context, not just on the field feature + +**Chosen:** `readonly inlineMarks: boolean` on `TranslationContext` +(`server/modules/translation-levels/types.ts`) and on `FieldTranslationConfig` +(`server/features/translate-field/model.ts`), which `CreateFieldRouteArgs` derives from. + +**Rejected — require it only on `FieldTranslationConfig`.** That makes `createFieldRoute` +forward it, but `fieldLevel.ts` passes `ctx.inlineMarks`, which stays `boolean | +undefined`, so the level would have to write `?? false` — the same silent default, moved +one frame up, and the next reader would have no way to tell the coercion from a real +decision. + +**Constraint cited:** `plugin.ts:163` computes `experimental?.inlineMarks === true`, which +is always a definite boolean. The optionality described a state the system cannot be in. +A type that admits an impossible state is what turned a dropped argument into a working +build. + +**Rejected — tighten `wireTranslateRunner` and the document handler in the same pass.** +They carry the same `= false` default. They are wired correctly and each has one caller, +so changing them is a refactor of code the defect never touched. Handled as the Phase 4 +same-class sweep instead, where the evidence for acting is gathered rather than assumed. + +### D2 — the guard is the type plus an integration spec, not a unit test at the seam + +**Chosen:** the required field makes *this* defect a build error; the integration spec +proves the whole path from HTTP body to translated value. + +**Rejected — a unit test asserting `createFieldRoute` forwards the flag to the handler.** +With D1 in place it can no longer fail, so it would assert what the compiler already +guarantees; and it would not have caught a handler that accepted the flag and ignored it, +which is the failure one frame further along. + +**Constraint cited:** every existing mark suite drives the plugin through +`callEndpoint(payload, method, path, body)` against a booted Payload +(`apps/dev/src/integration/translator/callEndpoint.ts`). The per-field surface is an HTTP +endpoint on the same config, so it is reachable by the same precedent — no new harness. + +### D3 — item 5 is a measured probe, not a rewording + +`buildSystemPrompt.ts` carries an explicit constraint above the instruction: + +> Wording validated against 396 live translations (French, German, Japanese × four +> models) before it shipped: the fallback rate was ~1% on gpt-4o and zero on newer +> models. Reword it only with the same measurement in hand. + +The budget for this task is 4–5 live runs of one ten-paragraph document, one model, one +language. That is not the same measurement and cannot be made into one here. + +**Chosen:** try variants, measure two axes, and change the wording only if mark integrity +stays perfect in the sample *and* the target axis clearly improves. Whatever ships records +in the docblock what it was actually measured against, so the next reader is not misled +into thinking the new line carries the old evidence. + +- **Validated axis** (the one the warning protects): every mark returned exactly once, no + duplicated or lost words, no container falling back to its source text. +- **Target axis** (what the live run showed to be weak): German word order actually + corrected, and emphasis covering the same extent as the source. + +**Rejected — reword to whatever scores best on ten sentences.** The axis the warning +protects is the one whose failure costs a user their content; the axis being improved is +one whose failure costs a clumsy sentence. Trading the first for the second on a +forty-times smaller sample is the wrong direction. + +**Rejected — skip item 5.** The gap is real and was observed: the instruction already +permits reordering and the model took it in five paragraphs out of ten. + +**Measurement hygiene:** the sandbox provider gets `sampling: { temperature: 0 }` for the +run, so variants differ by wording rather than by sampling. Dropped if the model rejects +the parameter. + +### Placement + +Everything lands in files that already exist: `route.ts`, `model.ts`, `types.ts`, +`route.test.ts`, `bootTestPayload.ts`, a new spec beside the other mark specs, `README.md`, +`buildSystemPrompt.ts`. + +### New surface + +None. No new abstraction, no new module, no new dependency. + +### Written contract? + +No unit here owes callers anything its signature cannot state once `inlineMarks` is +required. Items 1–3 are one bug fix, so Phase 3 takes the bug-fix path: the test goes red +against the broken code, never against a stub. + +### Escalate to architecture? + +No. One module, no new pattern, no data-model change, no migration. + +## Acceptance Criteria + +| # | Criterion | How it is checked | Passes when | +|---|---|---|---| +| 1 | With the mode on, a per-field translation of rich text returns marks the translation reordered | new spec, `apps/dev`: `bun run test:integration -t "field surface"` | green, **and red on the pre-fix code** | +| 2 | With the mode off, the same call keeps source order | same spec | green, and the two cases differ | +| 3 | A provider that does not declare the capability keeps the per-node path on this surface, even with the mode on | same spec | green; red if the capability gate is removed | +| 4 | Building the field route without the flag is a compile error | delete the argument at `fieldLevel.ts:35`, run `bun run check-types`, restore | the run names `fieldLevel.ts`; clean again after restore | +| 5 | Nothing already passing breaks | `bunx vitest run` (package) · `bun run test:integration` (apps/dev) · `bun run check-types` · `bun run lint` · `bunx turbo run build` | 1491 unit · 22 files/108 integration · types clean · lint 58 warnings 0 errors · build passes | +| 6 | The README documents `experimental.inlineMarks` with its version | read the config table in `README.md` | a row naming the option, its default, and `Since v0.13.0` | +| 7 | The shipped prompt wording loses nothing on the validated axis | live run of article "Word order" (10 paragraphs) into German, compared leaf by leaf against the English source | every mark present once, no duplicated or lost word, no container left in source language | +| 8 | The prompt decision is made on measurement, not impression | the same live runs, one per variant, recorded in this file | a table of variants × both axes, and a stated choice | + +Criteria 7 and 8 can only be witnessed against a running sandbox and a live model. That is +a fact about them, recorded here rather than discovered at grading time. + +## Pre-flight + +Run against the untouched tree on 2026-09-12, before any edit. + +| # | Command | Result | Class | +|---|---|---|---| +| 1–3 | spec does not exist yet | — | change — must be red on current code when written | +| 4 | `bun run check-types` | 5 tasks successful, clean | change — a call without the flag compiles today, so the criterion fails now: correct polarity | +| 5 | `bunx vitest run` | **1491 passed** | invariant — passes now | +| 5 | `bun run test:integration` | **22 files, 108 tests passed** | invariant — passes now | +| 6 | `grep -c experimental README.md` | **0** | change — fails now: correct polarity | +| 7–8 | baseline captured before this task from a live run of the same fixture | container mode: 5/10 correct German order, 0 duplicated, 0 lost, emphasis boundaries drift; per-node mode: 2/10 correct, 3 paragraphs with duplicated words | change — the baseline is a recorded observation, not an invented value | + +## Risk notes + +- **`TranslationContext` is shared.** `PluginConfigBuilderDeps` is it exactly; + `StalenessConfig` and `TranslationRoutesDeps` derive by `Pick`/`extends`. Making a field + required ripples to every constructor of those, including three test files. This is why + the task is classified high risk. +- **The prompt is the one change no test can grade.** A wording that scores better on ten + German sentences can be worse in Japanese or on another model. D3's asymmetry rule is + the mitigation; the residual risk is accepted and recorded rather than removed. +- **Sample size.** Ten paragraphs, one model, one language, one run per variant. Enough to + reject a clearly worse wording; not enough to certify a better one. Any change ships + labelled with exactly that. +- **`importMap.js`** is regenerated by a running dev server and loses the analytics + plugin's components on a machine without GA4 credentials. Reverted before every commit; + it is a record of the environment, not a change. + +## Human choices + +- **2026-09-12** — the owner stepped away and instructed: no questions at the design gate, + take the optimal decision, record it here, continue through all five items. Commits are + allowed; pushing to the shared repository is not, until the owner returns. +- **2026-09-12** — the owner asked for the prompt work to try several wordings and to aim + at "the minimum of model invention", while sparing the API budget (4–5 live runs total). + D3 is the reading of that instruction against the constraint already recorded in + `buildSystemPrompt.ts`. + +## Review log + +_(empty — appended by review passes)_ diff --git a/packages/payload-plugin-translator/src/composition/levels/fieldLevel.test.ts b/packages/payload-plugin-translator/src/composition/levels/fieldLevel.test.ts index f079d0966..2f4a45a1b 100644 --- a/packages/payload-plugin-translator/src/composition/levels/fieldLevel.test.ts +++ b/packages/payload-plugin-translator/src/composition/levels/fieldLevel.test.ts @@ -19,6 +19,7 @@ const makeCtx = (): LevelContext => ({ ]) as CollectionSchemaMap, translationProvider: { translate: vi.fn() }, targetSelection: "single", + inlineMarks: false, addEndpoints: vi.fn(), addCollectionComponent: vi.fn(), }); diff --git a/packages/payload-plugin-translator/src/composition/levels/levels.test.ts b/packages/payload-plugin-translator/src/composition/levels/levels.test.ts index 5ef0b4cac..b435122d5 100644 --- a/packages/payload-plugin-translator/src/composition/levels/levels.test.ts +++ b/packages/payload-plugin-translator/src/composition/levels/levels.test.ts @@ -32,6 +32,7 @@ const makeCtx = (): LevelContext => ({ schemaMap: new Map(), translationProvider: { translate: vi.fn() }, targetSelection: "single", + inlineMarks: false, addEndpoints: vi.fn(), addCollectionComponent: vi.fn(), }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts index e87d7f128..e8c54ce5e 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts @@ -83,7 +83,11 @@ beforeEach(() => { ] as Field[], ], ]); - const config: FieldTranslationConfig = { schemaMap, translationProvider: provider }; + const config: FieldTranslationConfig = { + schemaMap, + translationProvider: provider, + inlineMarks: false, + }; handler = new TranslateFieldHandler(config); }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts index bd3ffe23a..73f411260 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts @@ -36,5 +36,10 @@ export type FieldTranslationInput = z.infer; export type FieldTranslationConfig = { schemaMap: CollectionSchemaMap; translationProvider: TranslationProvider; - inlineMarks?: boolean; + /** + * Required rather than optional, though `false` is the common value: the plugin always knows + * this at config time, so an absent one could only ever mean a caller forgot to pass it — and + * that read as "translate node by node" instead of failing to build. + */ + inlineMarks: boolean; }; diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/route.test.ts b/packages/payload-plugin-translator/src/server/features/translate-field/route.test.ts index c595d6e18..e51731217 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/route.test.ts @@ -17,7 +17,11 @@ const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; describe("createFieldRoute (contract)", () => { it("registers POST at {basePath}/field with the default basePath", () => { - const endpoint = createFieldRoute({ schemaMap, translationProvider: provider }); + const endpoint = createFieldRoute({ + schemaMap, + translationProvider: provider, + inlineMarks: false, + }); expect(endpoint.path).toBe("/translate/field"); expect(endpoint.method).toBe("post"); }); @@ -26,6 +30,7 @@ describe("createFieldRoute (contract)", () => { const endpoint = createFieldRoute({ schemaMap, translationProvider: provider, + inlineMarks: false, basePath: "/i18n", }); expect(endpoint.path).toBe("/i18n/field"); @@ -35,6 +40,7 @@ describe("createFieldRoute (contract)", () => { const endpoint = createFieldRoute({ schemaMap, translationProvider: provider, + inlineMarks: false, access: denyAccess, }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/route.ts b/packages/payload-plugin-translator/src/server/features/translate-field/route.ts index 236dfcd28..d6ac8eb36 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/route.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/route.ts @@ -18,10 +18,11 @@ export type CreateFieldRouteArgs = FieldTranslationConfig & { export function createFieldRoute({ schemaMap, translationProvider, + inlineMarks, access, basePath = "/translate", }: CreateFieldRouteArgs): Endpoint { - const handler = new TranslateFieldHandler({ schemaMap, translationProvider }); + const handler = new TranslateFieldHandler({ schemaMap, translationProvider, inlineMarks }); return { path: `${basePath}/field`, diff --git a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.test.ts b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.test.ts index 2324b2d8c..c386b10bf 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.test.ts @@ -12,6 +12,7 @@ const deps = (collections: Array<{ slug: string }> = []) => ({ schemaMap: new Map(), translationProvider: { translate: vi.fn() }, targetSelection: "single" as const, + inlineMarks: false, }); const ep = (method: string, path: string): Endpoint => diff --git a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts index bf0bbf455..b46ca7ce8 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-levels/PluginConfigBuilder.ts @@ -75,7 +75,7 @@ export class PluginConfigBuilder implements LevelContext { readonly translationProvider: TranslationProvider; readonly provenanceServiceFactory?: ProvenanceServiceFactory; readonly targetSelection: TargetSelectionMode; - readonly inlineMarks?: boolean; + readonly inlineMarks: boolean; private readonly endpoints: Endpoint[] = []; private readonly collectionComponents: CollectionComponent[] = []; diff --git a/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts b/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts index 00b8bce1a..ab4b424bb 100644 --- a/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/translation-levels/types.ts @@ -30,7 +30,7 @@ export type TranslationContext = { /** Resolved target-language selection mode (`'single'` default) — drives which target control the * admin forms render. */ readonly targetSelection: TargetSelectionMode; - readonly inlineMarks?: boolean; + readonly inlineMarks: boolean; }; /** From 8f06ac4d2312c5bac2d33fdcbb60a78631d942b5 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sat, 12 Sep 2026 16:08:11 +0200 Subject: [PATCH 09/11] test(translator): tell the configured flag apart from the provider's capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the three specs added with the fix found they covered only three corners of (flag × capability) — on/on, on/off, off/off — and that in all three the two signals agree on the answer. A route reading `provider.capabilities?.inlineMarks` in place of the flag its caller passed would have satisfied every one of them, which is the same class of wiring defect the fix exists to close. The fourth corner separates them: the flag off while the provider declares it can keep marks. Verified rather than argued — `createFieldRoute` was temporarily made to read the capability instead of the flag, and that turned only the new spec red while the other three stayed green. Two other things travel with it. The field route now passes the handler's config through whole instead of naming its fields to rebuild the object: seven of the eight route factories in this layer already do that, and the one that did not is the one that dropped a field. And the three specs, which were sixty byte-identical lines apart from their boot options, now share one fixture module — so the note about `payload.create` writing ids into the value it is handed exists once rather than three times. The prompt wording is unchanged, and the docblock above it now says what was measured and rejected: a rule asking the model to keep each mark around the same words tightens mark boundaries and costs target word order. Four live runs of a ten-paragraph German fixture are recorded in the plan document — every one of them returned every mark exactly once, with no duplicated or lost word, so mark integrity was never the weak axis. --- .../translator/fieldSurfaceFixture.ts | 99 +++++++++++++++++++ ...e-marks-field-surface-flag-off.int.test.ts | 22 +++++ ...nline-marks-field-surface-gate.int.test.ts | 88 +---------------- ...inline-marks-field-surface-off.int.test.ts | 90 +---------------- .../inline-marks-field-surface.int.test.ts | 90 +---------------- ...6-09-12-field-surface-inline-marks.task.md | 81 ++++++++++++++- .../server/features/translate-field/model.ts | 6 +- .../server/features/translate-field/route.ts | 7 +- .../shared/buildSystemPrompt.ts | 4 + 9 files changed, 217 insertions(+), 270 deletions(-) create mode 100644 apps/dev/src/integration/translator/fieldSurfaceFixture.ts create mode 100644 apps/dev/src/integration/translator/inline-marks-field-surface-flag-off.int.test.ts diff --git a/apps/dev/src/integration/translator/fieldSurfaceFixture.ts b/apps/dev/src/integration/translator/fieldSurfaceFixture.ts new file mode 100644 index 000000000..94e08caa8 --- /dev/null +++ b/apps/dev/src/integration/translator/fieldSurfaceFixture.ts @@ -0,0 +1,99 @@ +import type { Payload } from "payload"; + +import { callEndpoint } from "./callEndpoint"; +import type { TestPayload } from "./bootTestPayload"; + +/** Lexical's format bitfield, for the one bit these specs care about. */ +export const BOLD = 1; +export const UNFORMATTED = 0; + +const text = (value: string, format = UNFORMATTED) => ({ + type: "text", + text: value, + format, + detail: 0, + mode: "normal", + style: "", + version: 1, +}); + +const richText = (children: unknown[]) => ({ + root: { + type: "root", + children: [ + { + type: "paragraph", + children, + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, + ], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +const source = () => richText([text("a "), text("red", BOLD), text(" car")]); + +type Child = { type: string; text?: string; format?: number }; + +const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { + const children = + (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? + []; + return children.map((c) => [c.text, c.format]); +}; + +/** + * `docs` is declared by the test collections rather than by the app, so Payload's generated slug + * and data types do not know it. A named signature says what this call takes instead. + */ +type CreateDoc = (args: { + collection: string; + locale: string; + data: Record; +}) => Promise<{ id: string | number }>; + +/** + * Create a paragraph of three fragments, translate its field through `POST /translate/field`, and + * return the reply's pieces as `[text, format]` pairs. + * + * A fresh value per call: `payload.create` writes ids into the object it is handed. + */ +export const translateField = async ( + ctx: TestPayload +): Promise<[string | undefined, number | undefined][]> => { + const create = (ctx.payload as Payload).create as unknown as CreateDoc; + const created = await create({ + collection: "docs", + locale: "en", + data: { title: "Field surface source", body: source() }, + }); + + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: String(created.id), + }, + }); + + const reply = (res.data as { data: { status: string; value: unknown } }).data; + if (res.status !== 200 || reply.status !== "translated") { + throw new Error(`field translation did not happen: ${res.status} ${JSON.stringify(res.data)}`); + } + return piecesOf(reply.value); +}; + +/** What every fall-back path returns here: the source order, each fragment translated in place. */ +export const SOURCE_ORDER: [string, number][] = [ + ["de:a ", UNFORMATTED], + ["de:red", BOLD], + ["de: car", UNFORMATTED], +]; diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface-flag-off.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface-flag-off.int.test.ts new file mode 100644 index 000000000..684c6bc58 --- /dev/null +++ b/apps/dev/src/integration/translator/inline-marks-field-surface-flag-off.int.test.ts @@ -0,0 +1,22 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootTestPayload } from "./bootTestPayload"; +import { SOURCE_ORDER, translateField } from "./fieldSurfaceFixture"; +import type { TestPayload } from "./bootTestPayload"; + +describe("per-field translation, the provider able but the mode off", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ declareCapability: true, fieldSurface: true }); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + // The fourth corner of (flag × capability), and the only one that separates "forwards the + // configured flag" from "reads the provider's capability": in the other three the two agree. + it("keeps the per-node path, because the option is what decides", async () => { + expect(await translateField(ctx)).toEqual(SOURCE_ORDER); + }); +}); diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts index af67a70cf..b8c42697e 100644 --- a/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-field-surface-gate.int.test.ts @@ -1,88 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { bootTestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; +import { SOURCE_ORDER, translateField } from "./fieldSurfaceFixture"; import type { TestPayload } from "./bootTestPayload"; -/** - * The per-field control with the mode on and a provider that never said it can keep marks. - * - * Every other mark spec drives the document surface. These exist because the two surfaces reach - * `translateContent` down different wiring, and only one of them was ever exercised — so the field - * route could accept the option, drop it, and stay green everywhere. - * - * The endpoint returns the translated value instead of saving it, so this reads the reply rather - * than the document. One boot per file, as everywhere in this suite. - */ - -const text = (value: string, format = 0) => ({ - type: "text", - text: value, - format, - detail: 0, - mode: "normal", - style: "", - version: 1, -}); - -const paragraph = (children: unknown[]) => ({ - type: "paragraph", - children, - format: "", - indent: 0, - version: 1, - direction: "ltr", -}); - -const richText = (children: unknown[]) => ({ - root: { - type: "root", - children: [paragraph(children)], - format: "", - indent: 0, - version: 1, - direction: "ltr", - }, -}); - -type Child = { type: string; text?: string; format?: number }; - -const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { - const children = - (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? - []; - return children.map((c) => [c.text, c.format]); -}; - -/** - * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be - * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared - * object survives only its first boot. - */ -const source = () => richText([text("a "), text("red", 1), text(" car")]); - -const translateField = async (ctx: TestPayload) => { - const created = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Field surface source", body: source() } as never, - }); - - const res = await callEndpoint(ctx.payload, "post", "/translate/field", { - body: { - collection_slug: "docs", - field_path: "body", - target_lng: "de", - source_lng: "en", - doc_id: String((created as { id: string | number }).id), - }, - }); - - expect(res.status).toBe(200); - const reply = (res.data as { data: { status: string; value: unknown } }).data; - expect(reply.status).toBe("translated"); - return piecesOf(reply.value); -}; - describe("per-field translation, the mode on but the provider silent about marks", () => { let ctx: TestPayload; @@ -99,10 +19,6 @@ describe("per-field translation, the mode on but the provider silent about marks }); it("keeps the per-node path, because the capability gate outranks the option", async () => { - expect(await translateField(ctx)).toEqual([ - ["de:a ", 0], - ["de:red", 1], - ["de: car", 0], - ]); + expect(await translateField(ctx)).toEqual(SOURCE_ORDER); }); }); diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts index e5b6965eb..7b702c402 100644 --- a/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-field-surface-off.int.test.ts @@ -1,88 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { bootTestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; +import { SOURCE_ORDER, translateField } from "./fieldSurfaceFixture"; import type { TestPayload } from "./bootTestPayload"; -/** - * The per-field translate control with the mode OFF — the control for its sibling spec. - * - * Every other mark spec drives the document surface. These exist because the two surfaces reach - * `translateContent` down different wiring, and only one of them was ever exercised — so the field - * route could accept the option, drop it, and stay green everywhere. - * - * The endpoint returns the translated value instead of saving it, so this reads the reply rather - * than the document. One boot per file, as everywhere in this suite. - */ - -const text = (value: string, format = 0) => ({ - type: "text", - text: value, - format, - detail: 0, - mode: "normal", - style: "", - version: 1, -}); - -const paragraph = (children: unknown[]) => ({ - type: "paragraph", - children, - format: "", - indent: 0, - version: 1, - direction: "ltr", -}); - -const richText = (children: unknown[]) => ({ - root: { - type: "root", - children: [paragraph(children)], - format: "", - indent: 0, - version: 1, - direction: "ltr", - }, -}); - -type Child = { type: string; text?: string; format?: number }; - -const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { - const children = - (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? - []; - return children.map((c) => [c.text, c.format]); -}; - -/** - * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be - * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared - * object survives only its first boot. - */ -const source = () => richText([text("a "), text("red", 1), text(" car")]); - -const translateField = async (ctx: TestPayload) => { - const created = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Field surface source", body: source() } as never, - }); - - const res = await callEndpoint(ctx.payload, "post", "/translate/field", { - body: { - collection_slug: "docs", - field_path: "body", - target_lng: "de", - source_lng: "en", - doc_id: String((created as { id: string | number }).id), - }, - }); - - expect(res.status).toBe(200); - const reply = (res.data as { data: { status: string; value: unknown } }).data; - expect(reply.status).toBe("translated"); - return piecesOf(reply.value); -}; - describe("per-field translation, the mode off", () => { let ctx: TestPayload; @@ -94,13 +14,7 @@ describe("per-field translation, the mode off", () => { await ctx.cleanup(); }); - // Without this the "mode on" spec would also pass on a build that ignores the option, as long as - // something else reordered the pieces. it("keeps source order, translating node by node", async () => { - expect(await translateField(ctx)).toEqual([ - ["de:a ", 0], - ["de:red", 1], - ["de: car", 0], - ]); + expect(await translateField(ctx)).toEqual(SOURCE_ORDER); }); }); diff --git a/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts b/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts index cc162cf89..ace16704b 100644 --- a/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-field-surface.int.test.ts @@ -1,88 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { bootTestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; +import { BOLD, translateField, UNFORMATTED } from "./fieldSurfaceFixture"; import type { TestPayload } from "./bootTestPayload"; -/** - * The per-field translate control, `POST /translate/field`, with container-granular rich text on. - * - * Every other mark spec drives the document surface. These exist because the two surfaces reach - * `translateContent` down different wiring, and only one of them was ever exercised — so the field - * route could accept the option, drop it, and stay green everywhere. - * - * The endpoint returns the translated value instead of saving it, so this reads the reply rather - * than the document. One boot per file, as everywhere in this suite. - */ - -const text = (value: string, format = 0) => ({ - type: "text", - text: value, - format, - detail: 0, - mode: "normal", - style: "", - version: 1, -}); - -const paragraph = (children: unknown[]) => ({ - type: "paragraph", - children, - format: "", - indent: 0, - version: 1, - direction: "ltr", -}); - -const richText = (children: unknown[]) => ({ - root: { - type: "root", - children: [paragraph(children)], - format: "", - indent: 0, - version: 1, - direction: "ltr", - }, -}); - -type Child = { type: string; text?: string; format?: number }; - -const piecesOf = (value: unknown): [string | undefined, number | undefined][] => { - const children = - (value as { root?: { children?: { children?: Child[] }[] } })?.root?.children?.[0]?.children ?? - []; - return children.map((c) => [c.text, c.format]); -}; - -/** - * `a `, **red**, ` car` — three fragments whose reversal is visible and whose formats can be - * traced. Built per call: `payload.create` writes ids into the value it is handed, so a shared - * object survives only its first boot. - */ -const source = () => richText([text("a "), text("red", 1), text(" car")]); - -const translateField = async (ctx: TestPayload) => { - const created = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Field surface source", body: source() } as never, - }); - - const res = await callEndpoint(ctx.payload, "post", "/translate/field", { - body: { - collection_slug: "docs", - field_path: "body", - target_lng: "de", - source_lng: "en", - doc_id: String((created as { id: string | number }).id), - }, - }); - - expect(res.status).toBe(200); - const reply = (res.data as { data: { status: string; value: unknown } }).data; - expect(reply.status).toBe("translated"); - return piecesOf(reply.value); -}; - describe("per-field translation, the mode on", () => { let ctx: TestPayload; @@ -95,12 +15,10 @@ describe("per-field translation, the mode on", () => { }); it("returns the paragraph in the order the reply came back", async () => { - // Order and formats together: the order proves the reply moved the pieces, the formats prove - // each one took its own formatting along. Per-node translation can produce neither. expect(await translateField(ctx)).toEqual([ - ["de: car", 0], - ["de:red", 1], - ["de:a ", 0], + ["de: car", UNFORMATTED], + ["de:red", BOLD], + ["de:a ", UNFORMATTED], ]); }); }); diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md index 195771f00..b40c7862e 100644 --- a/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md +++ b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-surface-inline-marks.task.md @@ -133,10 +133,11 @@ No. One module, no new pattern, no data-model change, no migration. | 2 | With the mode off, the same call keeps source order | same spec | green, and the two cases differ | | 3 | A provider that does not declare the capability keeps the per-node path on this surface, even with the mode on | same spec | green; red if the capability gate is removed | | 4 | Building the field route without the flag is a compile error | delete the argument at `fieldLevel.ts:35`, run `bun run check-types`, restore | the run names `fieldLevel.ts`; clean again after restore | -| 5 | Nothing already passing breaks | `bunx vitest run` (package) · `bun run test:integration` (apps/dev) · `bun run check-types` · `bun run lint` · `bunx turbo run build` | 1491 unit · 22 files/108 integration · types clean · lint 58 warnings 0 errors · build passes | +| 5 | Nothing already passing breaks | `bunx vitest run` (package) · `bun run test:integration` (apps/dev) · `bun run check-types` · `bun run lint` · `bunx turbo run build` | 1491 unit · 22 files/108 integration before, more after · types clean · lint 58 warnings 0 errors · build passes | | 6 | The README documents `experimental.inlineMarks` with its version | read the config table in `README.md` | a row naming the option, its default, and `Since v0.13.0` | | 7 | The shipped prompt wording loses nothing on the validated axis | live run of article "Word order" (10 paragraphs) into German, compared leaf by leaf against the English source | every mark present once, no duplicated or lost word, no container left in source language | | 8 | The prompt decision is made on measurement, not impression | the same live runs, one per variant, recorded in this file | a table of variants × both axes, and a stated choice | +| 9 | The suite can tell "forwards the configured flag" from "reads the provider's capability" | the fourth (flag × capability) corner: flag off, capability declared | green normally; red when the route substitutes the capability for the flag | Criteria 7 and 8 can only be witnessed against a running sandbox and a live model. That is a fact about them, recorded here rather than discovered at grading time. @@ -170,6 +171,84 @@ Run against the untouched tree on 2026-09-12, before any edit. plugin's components on a machine without GA4 credentials. Reverted before every commit; it is a record of the environment, not a change. +## Item 5 — the prompt measurement + +Four live runs of the ten-paragraph "Word order" article, English into German, `gpt-5.4-mini`, +`temperature: 0` so the variants differ by wording rather than by sampling. Every run translated +the same document through the document surface; the German was compared against the English piece +by piece. + +| Wording | Correct German order | Emphasis matches source extent | Marks intact | +|---|---|---|---| +| **V0 — shipped, unchanged** | **6/10** | 8/10 | 10/10 | +| V1 — V0 plus "word order is the target language's" and "keep each mark around the words it wrapped" | 5/10 | 9/10 | 10/10 | +| V2 — V0 plus "marks need not run in ascending order" and the same boundary rule | 4/10 | 10/10 | 10/10 | +| V3 — V0 plus an explicit priority: order first, boundaries where the wording allows | 4/10 | 10/10 | 10/10 | + +**Decision: ship V0 unchanged.** + +The three additions move both columns, in opposite directions, every time. A mark's extent and the +freedom to rewrite across it are the same freedom: telling the model to keep each mark around its +source words is telling it not to redistribute them, and redistributing them is what correct German +sometimes requires. V3 tried to have both by stating a priority and landed exactly where V2 did, +which is what makes this a property of the instruction rather than of one phrasing. + +Of the two columns, word order is the one worth spending on. Emphasis covering a word more than +intended is cosmetic and the content is whole; a participle stranded in the middle of a German +clause reads as broken language. V0 is the best of the four on that column and ties on the third. + +**What did not turn out to be a problem.** The owner's target was "the minimum of model invention". +Across all four runs and forty paragraphs there was **not one** duplicated word, lost word, missing +mark, or container that fell back to its source text. Invention is not the current weakness of the +format — word order is, and these three wordings make it worse rather than better. + +**What this measurement is not.** Ten paragraphs, one language, one model, one run per variant. It +is enough to reject a wording that is clearly worse, which is what happened three times. It would +not have been enough to certify one as better, and D3 set that bar before the runs rather than +after. + +Residual finding, not fixed here: paragraphs 1, 2, 5 and 8 came back in English order under every +wording, including V0 — the model consistently refuses to send a participle or infinitive to the end +of the clause when it sits inside a mark. That is stable behaviour, not sampling noise, and it is +the real ceiling on this axis. Improving it needs something other than a sentence in the system +prompt. + +## Review log + +### 2026-09-12 · phase 5 + +- **Checks:** `bunx vitest run` → 1491 passed (113 files) · `bun run test:integration` → 112 passed (26 files) · + `bun run check-types` → 5 tasks successful · `bunx ultracite check` over the package and the + integration suite → 58 warnings, 0 errors (the main branch's level) · + `bunx turbo run build --filter='./packages/*'` → 8 tasks successful. +- **Gates:** sp-diff-checks clean (5 checks, no findings); sp-lint-delta mode=two-run, **no findings + introduced**. +- **Reviewers:** correctness, regression, intent, tests — run concurrently. Three returned nothing. + `tests` returned one major finding, confirmed and fixed (criterion 9 below). +- **Criteria:** 8 met · 1 partial · 0 not-run · 0 not met. Partial is #5: everything in it holds + except the whole-repo `turbo run build`, where `dev#build` fails on a **pre-existing** type + mismatch unrelated to this work — `31cad546` removed `content` from the SEO plugin's + `SeoFieldPaths`, and the sandbox config from `1ffcb1d3` still passes `content: "sections"`. + Neither commit belongs to this task, and the file is not in its diff. The publishable packages + build clean. +- **Left open:** the `dev#build` failure above, for the owner to decide on separately. Also the + residual prompt finding in `## Item 5` — four paragraphs come back in English order under every + wording tried, which no sentence in the system prompt fixed. +- **Pin:** see below. + +#### The finding that was fixed + +The `tests` reviewer observed that the three original specs covered only three corners of +(flag × capability) — (on, on), (on, off), (off, off) — and that in all three the two signals agree +on the answer. An implementation reading `provider.capabilities?.inlineMarks` in place of the +configured flag would therefore pass all three, which is the same class of wiring defect this task +exists to close. + +Verified rather than assumed: a fourth spec was added at (flag off, capability declared), and +`createFieldRoute` was then deliberately changed to read the capability instead of the flag. That +mutation turned **only** the new spec red and left the other three green, exactly as predicted. The +mutation was reverted and the suite re-run. + ## Human choices - **2026-09-12** — the owner stepped away and instructed: no questions at the design gate, diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts index 73f411260..a0a618f33 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/model.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/model.ts @@ -36,10 +36,6 @@ export type FieldTranslationInput = z.infer; export type FieldTranslationConfig = { schemaMap: CollectionSchemaMap; translationProvider: TranslationProvider; - /** - * Required rather than optional, though `false` is the common value: the plugin always knows - * this at config time, so an absent one could only ever mean a caller forgot to pass it — and - * that read as "translate node by node" instead of failing to build. - */ + /** Not optional: an omitted flag would silently select per-node translation instead of failing to compile. */ inlineMarks: boolean; }; diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/route.ts b/packages/payload-plugin-translator/src/server/features/translate-field/route.ts index d6ac8eb36..30bc599be 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/route.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/route.ts @@ -16,13 +16,12 @@ export type CreateFieldRouteArgs = FieldTranslationConfig & { * Wired with the same access + error-envelope plumbing as the document routes. */ export function createFieldRoute({ - schemaMap, - translationProvider, - inlineMarks, access, basePath = "/translate", + ...config }: CreateFieldRouteArgs): Endpoint { - const handler = new TranslateFieldHandler({ schemaMap, translationProvider, inlineMarks }); + // Passed through whole: naming the fields here is how one gets dropped without the build noticing. + const handler = new TranslateFieldHandler(config); return { path: `${basePath}/field`, diff --git a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts index 986efb09b..fcad49ada 100644 --- a/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts +++ b/packages/payload-plugin-translator/src/translation-providers/shared/buildSystemPrompt.ts @@ -2,6 +2,10 @@ * Wording validated against 396 live translations (French, German, Japanese × four models) before * it shipped: the fallback rate was ~1% on gpt-4o and zero on newer models. Reword it only with * the same measurement in hand. + * + * A rule telling the model to keep each mark around the same words was measured and rejected: it + * tightens mark boundaries at the cost of target word order. See + * `docs/plans/2026-09-12-field-surface-inline-marks.task.md`. */ const INLINE_MARKS_INSTRUCTION = `Some values contain numbered inline marks, written as <1>text or <5/>. They carry formatting, not content. Return every mark exactly once, keeping its number, and put each mark where the translated sentence needs it — the order of marks may change. From ba05d86c7de8ba8cdb8b7c1de3c944dc376c7c47 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sat, 12 Sep 2026 21:50:44 +0200 Subject: [PATCH 10/11] test(translator): cover the per-field surface, and write down what it owes callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-field translate control had fifteen unit tests and no integration coverage at all. That distinction matters more here than it usually does: the unit tests hand the handler a schema map built by hand, while in service the plugin projects one from the real collection configs — and a path into `blocks` is resolved against the saved document's own `blockType`, with rows, unnamed tabs and collapsibles vanishing from the path entirely. A hand-built map and the real one are exactly the pair that can disagree, and nothing compared them. **The contract came first, and writing it down produced facts that existed nowhere.** The types stated the shapes; nothing stated that the value translated is the *saved* one rather than the form's, that nothing is written in any locale, that a per-field translate has no strategy to choose, that "I cannot translate this" is a success rather than an error in six named situations, or that the one warning among them marks the case where an answer is possible but would be wrong. `FieldTranslation` binds that prose to the method, so the signature is now checked rather than described. **Eighteen checks, written against the contract by an author that could not read the implementation** — it was removed from the checkout that author worked in, so blindness is a fact rather than a request. The red run took three attempts and the first two were dishonest, which is worth recording because both failures look like success from a distance: - the specs died in setup — Payload's `update` loses its receiver when pulled off the object through a cast — so no check reached the code under test; - then sixteen went red and **two passed**: both "writes nothing" checks are satisfied by a handler that does nothing whatsoever. They now assert the request was answered before asserting the document is untouched, and a mutation that makes the handler persist turns exactly one of them red. Four more mutations pin the rest: removing the exclusion branch, the localized-list branch, or the size guard reddens its own checks, and hardcoding the source locale reddens exactly the check that says the locale comes from the request. One check earned a fixture guard rather than a mutation: the 413 case now asserts the stored value really does exceed the cap, because it silently stopped doing so once and passed anyway. --- .../field-surface-errors.int.test.ts | 163 +++++++++++++ .../translator/field-surface-noop.int.test.ts | 219 ++++++++++++++++++ .../field-surface-source-of-truth.int.test.ts | 117 ++++++++++ .../features/translate-field/handler.ts | 53 ++++- 4 files changed, 543 insertions(+), 9 deletions(-) create mode 100644 apps/dev/src/integration/translator/field-surface-errors.int.test.ts create mode 100644 apps/dev/src/integration/translator/field-surface-noop.int.test.ts create mode 100644 apps/dev/src/integration/translator/field-surface-source-of-truth.int.test.ts diff --git a/apps/dev/src/integration/translator/field-surface-errors.int.test.ts b/apps/dev/src/integration/translator/field-surface-errors.int.test.ts new file mode 100644 index 000000000..317ced3f4 --- /dev/null +++ b/apps/dev/src/integration/translator/field-surface-errors.int.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// The four situations `POST /translate/field` answers with an HTTP error. Only the status is +// asserted: the contract fixes the codes and says nothing about the error bodies. + +type Reply = { status: string; value: unknown }; + +/** + * `docs` is declared by the test collections rather than by the app, so Payload's generated slug + * and data types do not know it. Named signatures say what these calls take instead. + */ +type CreateDoc = (args: { + collection: string; + locale: string; + data: Record; +}) => Promise<{ id: string | number }>; + +const MAX_FIELD_VALUE_BYTES = 256 * 1024; + +const post = (ctx: TestPayload, body: Record) => + callEndpoint(ctx.payload, "post", "/translate/field", { body }); + +const paragraph = (text: string) => ({ + root: { + type: "root", + children: [ + { + type: "paragraph", + children: [ + { type: "text", text, format: 0, detail: 0, mode: "normal", style: "", version: 1 }, + ], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, + ], + format: "", + indent: 0, + version: 1, + direction: "ltr", + }, +}); + +describe("per-field translation — the errors that stay errors", () => { + let ctx: TestPayload; + let id: string; + let oversizedId: string; + let oversizedBytes: number; + let underCapId: string; + let underCapBytes: number; + + beforeAll(async () => { + ctx = await bootTestPayload({ fieldSurface: true }); + const create = ctx.payload.create.bind(ctx.payload) as unknown as CreateDoc; + + const created = await create({ + collection: "docs", + locale: "en", + data: { title: "Error source" }, + }); + id = String(created.id); + + // Rich text, not `title`: Payload validates a text field's length, so an oversized string + // never reaches the endpoint under test. + const oversized = await create({ + collection: "docs", + locale: "en", + data: { title: "Oversized", body: paragraph("x".repeat(MAX_FIELD_VALUE_BYTES + 10_000)) }, + }); + oversizedId = String(oversized.id); + oversizedBytes = new TextEncoder().encode( + JSON.stringify(paragraph("x".repeat(MAX_FIELD_VALUE_BYTES + 10_000))) + ).length; + + const underCap = await create({ + collection: "docs", + locale: "en", + data: { title: "Under cap", body: paragraph("y".repeat(100_000)) }, + }); + underCapId = String(underCap.id); + underCapBytes = new TextEncoder().encode(JSON.stringify(paragraph("y".repeat(100_000)))).length; + }); + + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("rejects a body missing a required field with 400", async () => { + const res = await post(ctx, { + collection_slug: "docs", + field_path: "title", + source_lng: "en", + doc_id: id, + }); + expect(res.status).toBe(400); + }); + + it("rejects a body whose field_path is empty with 400", async () => { + const res = await post(ctx, { + collection_slug: "docs", + field_path: "", + target_lng: "de", + source_lng: "en", + doc_id: id, + }); + expect(res.status).toBe(400); + }); + + it("rejects a collection the plugin does not manage with 400", async () => { + const res = await post(ctx, { + collection_slug: "ghosts", + field_path: "title", + target_lng: "de", + source_lng: "en", + doc_id: id, + }); + expect(res.status).toBe(400); + }); + + it("rejects a path that names no field in the collection with 400", async () => { + const res = await post(ctx, { + collection_slug: "docs", + field_path: "nosuchfield", + target_lng: "de", + source_lng: "en", + doc_id: id, + }); + expect(res.status).toBe(400); + }); + + it("rejects a saved value over MAX_FIELD_VALUE_BYTES with 413", async () => { + // The fixture has to actually exceed the cap, or a 200 here would read as a missing guard. + expect(oversizedBytes).toBeGreaterThan(MAX_FIELD_VALUE_BYTES); + + const res = await post(ctx, { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: oversizedId, + }); + expect(res.status).toBe(413); + }); + + it("translates a large saved value that stays under the cap", async () => { + expect(underCapBytes).toBeLessThan(MAX_FIELD_VALUE_BYTES); + + const res = await post(ctx, { + collection_slug: "docs", + field_path: "body", + target_lng: "de", + source_lng: "en", + doc_id: underCapId, + }); + expect(res.status).toBe(200); + expect((res.data as { data: Reply }).data.status).toBe("translated"); + }); +}); diff --git a/apps/dev/src/integration/translator/field-surface-noop.int.test.ts b/apps/dev/src/integration/translator/field-surface-noop.int.test.ts new file mode 100644 index 000000000..47f95e0a5 --- /dev/null +++ b/apps/dev/src/integration/translator/field-surface-noop.int.test.ts @@ -0,0 +1,219 @@ +import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator"; +import type { Block, CollectionConfig } from "payload"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// The six situations `POST /translate/field` answers with `200` + `status: "noop"` instead of an +// error. One collection carries all six, because a spec file may boot Payload only once and each +// situation needs its own field shape. + +type Notice = { level: string; message: string }; +type Reply = { status: string; value: unknown; notice?: Notice }; + +/** + * `surface` is declared by this spec rather than by the app, so Payload's generated slug and data + * types do not know it. Named signatures say what these calls take instead. + */ +type CreateDoc = (args: { + collection: string; + locale: string; + data: Record; +}) => Promise<{ id: string | number }>; + +type UpdateDoc = (args: { + collection: string; + id: string; + locale: string; + data: Record; +}) => Promise; + +type FindDoc = (args: { + collection: string; + id: string; + locale: string; +}) => Promise>; + +/** Lives under a NON-localized `blocks` field, so its leaf carries the per-locale value itself. */ +const heroBlock: Block = { + slug: "hero", + fields: [{ name: "heading", type: "text", localized: true }], +}; + +/** Lives under a LOCALIZED `blocks` field, which owns the per-locale split for the whole row. */ +const cardBlock: Block = { + slug: "card", + fields: [{ name: "heading", type: "text" }], +}; + +const buildSurfaceCollections = (): CollectionConfig[] => [ + { slug: "users", auth: true, fields: [] }, + { + slug: "surface", + fields: [ + { name: "title", type: "text", localized: true }, + // Localized and populated in `en`, so only its type can disqualify it. + { name: "views", type: "number", localized: true }, + withFieldTranslation({ name: "secret", type: "text", localized: true }, { exclude: true }), + { name: "sections", type: "blocks", blocks: [heroBlock] }, + { name: "cards", type: "blocks", localized: true, blocks: [cardBlock] }, + { name: "rows", type: "array", localized: true, fields: [{ name: "label", type: "text" }] }, + { + name: "stats", + type: "group", + fields: [ + withFieldTranslation({ name: "note", type: "text", localized: true }, { exclude: true }), + ], + }, + ], + }, +]; + +const EN = { + // `title` is deliberately absent: the "holds nothing in the source locale" case. + views: 42, + secret: "classified", + sections: [], + cards: [{ blockType: "card", heading: "Card one" }], + rows: [{ label: "Row one" }], + stats: { note: "Only note" }, +}; + +const CASES = { + empty: "title", + untranslatableType: "views", + excluded: "secret", + blocksWithoutRow: "sections.0.heading", + localizedBlocks: "cards.0.heading", + localizedArray: "rows.0.label", + nothingInTheSubtree: "stats", +} as const; + +type CaseName = keyof typeof CASES; +type Sent = { status: number; reply: Reply }; + +describe("per-field translation — the six answers that are a noop, not an error", () => { + let ctx: TestPayload; + let id: string; + let replies: Record; + + beforeAll(async () => { + ctx = await bootTestPayload({ fieldSurface: true, collections: buildSurfaceCollections() }); + + const create = ctx.payload.create.bind(ctx.payload) as unknown as CreateDoc; + const update = ctx.payload.update.bind(ctx.payload) as unknown as UpdateDoc; + const created = await create({ collection: "surface", locale: "en", data: EN }); + id = String(created.id); + // `de` holds a title while `en` does not, so "the field holds nothing" is judged per locale. + await update({ + collection: "surface", + id, + locale: "de", + data: { title: "Vorhandener Titel" }, + }); + + const collected: Partial> = {}; + for (const [name, fieldPath] of Object.entries(CASES) as [CaseName, string][]) { + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "surface", + field_path: fieldPath, + target_lng: "de", + source_lng: "en", + doc_id: id, + }, + }); + collected[name] = { status: res.status, reply: (res.data as { data: Reply }).data }; + } + replies = collected as Record; + }); + + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("a field holding nothing in the source locale is a noop with an info notice", () => { + const { status, reply } = replies.empty; + expect(status).toBe(200); + expect(reply.status).toBe("noop"); + expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + }); + + it("a field whose type is not one the plugin translates is a noop with an info notice", () => { + const { status, reply } = replies.untranslatableType; + expect(status).toBe(200); + expect(reply).toEqual({ + status: "noop", + value: 42, + notice: { level: "info", message: expect.any(String) }, + }); + }); + + it("an excluded field is a noop with an info notice", () => { + const { status, reply } = replies.excluded; + expect(status).toBe(200); + expect(reply).toEqual({ + status: "noop", + value: "classified", + notice: { level: "info", message: expect.any(String) }, + }); + }); + + it("a path into blocks the saved document has no row for is a noop with an info notice", () => { + const { status, reply } = replies.blocksWithoutRow; + expect(status).toBe(200); + expect(reply.status).toBe("noop"); + expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + }); + + it("a path through a localized blocks field is a noop with a warning notice", () => { + const { status, reply } = replies.localizedBlocks; + expect(status).toBe(200); + expect(reply).toEqual({ + status: "noop", + value: "Card one", + notice: { level: "warning", message: expect.any(String) }, + }); + }); + + it("a path through a localized array is a noop with a warning notice", () => { + const { status, reply } = replies.localizedArray; + expect(status).toBe(200); + expect(reply).toEqual({ + status: "noop", + value: "Row one", + notice: { level: "warning", message: expect.any(String) }, + }); + }); + + it("a subtree holding nothing translatable is a noop with an info notice", () => { + const { status, reply } = replies.nothingInTheSubtree; + expect(status).toBe(200); + expect(reply.status).toBe("noop"); + expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + }); + + it("writes nothing — the document is unchanged in every locale", async () => { + // First that every request was answered at all: an endpoint that fails outright also leaves + // the document untouched, and without this the check cannot tell the two apart. + expect(Object.values(replies).map((r) => r.status)).toEqual(Object.keys(CASES).map(() => 200)); + + const find = ctx.payload.findByID.bind(ctx.payload) as unknown as FindDoc; + + const en = await find({ collection: "surface", id, locale: "en" }); + expect(en.title ?? null).toBeNull(); + expect(en.views).toBe(42); + expect(en.secret).toBe("classified"); + expect((en.cards as { heading: string }[]).map((c) => c.heading)).toEqual(["Card one"]); + expect((en.rows as { label: string }[]).map((r) => r.label)).toEqual(["Row one"]); + expect((en.stats as { note: string }).note).toBe("Only note"); + + const de = await find({ collection: "surface", id, locale: "de" }); + expect(de.title).toBe("Vorhandener Titel"); + + const fr = await find({ collection: "surface", id, locale: "fr" }); + expect(fr.title ?? null).toBeNull(); + }); +}); diff --git a/apps/dev/src/integration/translator/field-surface-source-of-truth.int.test.ts b/apps/dev/src/integration/translator/field-surface-source-of-truth.int.test.ts new file mode 100644 index 000000000..f6595ba32 --- /dev/null +++ b/apps/dev/src/integration/translator/field-surface-source-of-truth.int.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// The standing guarantees of `POST /translate/field`: the value translated is the one saved in +// `source_lng`, an occupied target is translated again anyway, and the document is left as it was. + +type Reply = { status: string; value: unknown }; + +/** + * `docs` is declared by the test collections rather than by the app, so Payload's generated slug + * and data types do not know it. Named signatures say what these calls take instead. + */ +type CreateDoc = (args: { + collection: string; + locale: string; + data: Record; +}) => Promise<{ id: string | number }>; + +type UpdateDoc = (args: { + collection: string; + id: string; + locale: string; + data: Record; +}) => Promise; + +type FindDoc = (args: { + collection: string; + id: string; + locale: string; +}) => Promise>; + +describe("per-field translation — where the value comes from and what it leaves behind", () => { + let ctx: TestPayload; + let id: string; + let fromEnglish: { status: number; reply: Reply }; + let fromFrench: { status: number; reply: Reply }; + let ontoOccupiedTarget: { status: number; reply: Reply }; + + beforeAll(async () => { + ctx = await bootTestPayload({ fieldSurface: true }); + + const create = ctx.payload.create.bind(ctx.payload) as unknown as CreateDoc; + const update = ctx.payload.update.bind(ctx.payload) as unknown as UpdateDoc; + const created = await create({ + collection: "docs", + locale: "en", + data: { title: "Saved title", ref: "REF-1" }, + }); + id = String(created.id); + await update({ collection: "docs", id, locale: "de", data: { title: "Vorhandener Titel" } }); + await update({ collection: "docs", id, locale: "fr", data: { title: "Titre source" } }); + + const send = async (sourceLng: string, targetLng: string) => { + const res = await callEndpoint(ctx.payload, "post", "/translate/field", { + body: { + collection_slug: "docs", + field_path: "title", + target_lng: targetLng, + source_lng: sourceLng, + doc_id: id, + }, + }); + return { status: res.status, reply: (res.data as { data: Reply }).data }; + }; + + fromEnglish = await send("en", "es"); + fromFrench = await send("fr", "es"); + ontoOccupiedTarget = await send("en", "de"); + }); + + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("translates the value saved in the document, though the request carries none", () => { + expect(fromEnglish.status).toBe(200); + expect(fromEnglish.reply).toEqual({ status: "translated", value: "es:Saved title" }); + }); + + it("reads the locale named by source_lng, not the default one", () => { + expect(fromFrench.status).toBe(200); + expect(fromFrench.reply).toEqual({ status: "translated", value: "es:Titre source" }); + }); + + it("always overwrites — a target locale that already holds a value is translated again", () => { + expect(ontoOccupiedTarget.status).toBe(200); + expect(ontoOccupiedTarget.reply).toEqual({ status: "translated", value: "de:Saved title" }); + }); + + it("writes nothing — the document is unchanged in every locale", async () => { + // First that all three translations actually happened: a handler that answers nothing also + // leaves the document untouched, and without this the check cannot tell the two apart. + expect( + [fromEnglish, fromFrench, ontoOccupiedTarget].map( + (r) => (r.reply as { status: string }).status + ) + ).toEqual(["translated", "translated", "translated"]); + + const find = ctx.payload.findByID.bind(ctx.payload) as unknown as FindDoc; + + const en = await find({ collection: "docs", id, locale: "en" }); + expect(en.title).toBe("Saved title"); + expect(en.ref).toBe("REF-1"); + + const de = await find({ collection: "docs", id, locale: "de" }); + expect(de.title).toBe("Vorhandener Titel"); + + const fr = await find({ collection: "docs", id, locale: "fr" }); + expect(fr.title).toBe("Titre source"); + + const es = await find({ collection: "docs", id, locale: "es" }); + expect(es.title ?? null).toBeNull(); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts index 69a23c841..cb7f31b4b 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts @@ -26,15 +26,50 @@ const noop = ( }); /** - * Synchronous single-field translation: read the field's value from the saved document in the - * chosen source locale, resolve the declared field path to its schema subtree, run - * `translateContent`, and return the translated value. No persistence — the result is written to - * form state by the caller. + * What a caller of `POST {basePath}/field` is owed. The types state the shapes; this states what + * they mean. * - * From-locale only: `source_lng` + `doc_id` are required (validated by the schema), so there is - * always exactly one DB read. Reserves HTTP errors for genuine errors — "nothing to translate" - * and "couldn't resolve the block" come back as a 200 `noop` with a notice. + * **The value translated is the saved one.** It is read from the document at `doc_id` in + * `source_lng` — never from the request, which carries no value. Unsaved edits in the form are + * therefore invisible here, and that is the contract, not an oversight. + * + * **Nothing is written.** The reply carries the translated value; persisting it is the caller's + * job. The source document is left as it was, in every locale. + * + * **It always overwrites.** A per-field translate is an explicit "translate this one now", so + * there is no strategy to choose and `skip_existing` has no meaning on this surface. + * + * **"I cannot translate this" is a success, not an error.** Six situations come back `200` with + * `status: "noop"`, the source value unchanged, and a notice explaining which: + * + * | Situation | Notice level | + * |---|---| + * | the field holds nothing in the source locale | `info` | + * | the field's type is not one this plugin translates | `info` | + * | the field is excluded via `withFieldTranslation({ exclude: true })` | `info` | + * | the path runs into `blocks` and the saved document does not say which block | `info` | + * | the path runs through a **localized** `blocks` or `array` | `warning` | + * | nothing translatable was found once the subtree was walked | `info` | + * + * The one `warning` is the case where the request is answerable but the answer would be wrong: + * a localized list has its own order per locale, so an index in the path cannot be matched across + * locales, and translating the whole document is the only correct route. + * + * **HTTP errors are kept for genuine errors:** `400` for a body that fails validation, `400` for a + * collection the plugin does not manage, `400` for a path naming no field in that collection, and + * `413` for a source value whose serialized size exceeds {@link MAX_FIELD_VALUE_BYTES}. The size is + * measured on the value read from the document, because that is what is held in memory across the + * provider call — the request body no longer carries one. + * + * **A path is resolved against the saved data, not the schema alone.** A segment inside `blocks` + * needs the document's own `blockType` to know which block's fields apply. Containers that carry no + * name — a row, an unnamed tab, a collapsible — do not appear in the path at all. + * + * @param req - the Payload request; the body must satisfy {@link FieldTranslationInputSchema} + * @returns `200` with a {@link FieldTranslationResult}, or one of the errors above */ +export type FieldTranslation = (req: PayloadRequest) => Promise; + export class TranslateFieldHandler { private readonly config: FieldTranslationConfig; @@ -42,7 +77,7 @@ export class TranslateFieldHandler { this.config = config; } - async handle(req: PayloadRequest): Promise { + handle: FieldTranslation = async (req) => { const parsed = FieldTranslationInputSchema.safeParse(await req.json?.()); if (parsed.error) return ServerResponse.validationError(parsed.error.issues); @@ -133,5 +168,5 @@ export class TranslateFieldHandler { value: translated[resolution.fieldName], }; return ServerResponse.success(result); - } + }; } From 5ded8b4151f7a8c4a0e1f27076999e9c545b4402 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Sat, 12 Sep 2026 22:08:35 +0200 Subject: [PATCH 11/11] feat(translator): name why a per-field translation declined, for code not just for a reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST {basePath}/field` answers `200` with `status: "noop"` in five situations where it will not translate. Four carried `level: "info"`, and two carried the same sentence word for word — so the admin control, which renders that sentence, could not tell "this field is empty" from "this field opted out", and neither could a test. The notice gains a `reason` beside its `message`. **The distinction already existed and was discarded one step early.** `resolveFieldSubtree` returns five named statuses, and its own docblock records the intent: `excluded` is kept distinct from `not-translatable` "so the notice can say *why* (a deliberate opt-out, not a wrong type)". The handler then collapsed five names into two levels and four strings. This finishes that. **A union on the wire type, not a class per reason.** The package already has a code taxonomy in `TranslationFailureCode`, but that one is shaped by being *thrown* — a class is how a throwable carries structure through a `catch`. These are `200` bodies, so the same idea costs one union instead of five files. Reusing the resolver's own status names was rejected for two reasons: the fifth situation arises after resolution and has no status at all, and it would pin a wire format to an internal helper's vocabulary. **No behaviour changes.** Every status, level and message is byte-identical; only the notice is wider. The two identical messages stay identical — `reason` is what tells them apart now, and rewording is a copy decision nobody has made. Writing the codes down settled something the contract had left open, and the first run found it: a container named directly by the path is judged by its own type before anything walks inside it, so a group whose only leaf is excluded answers `not-translatable`, not `nothing-translatable`. The spec that assumed otherwise now says what it actually pins, and the rule is in the docblock — whose table also drops from six situations to the five the code has. --- .../translator/field-surface-noop.int.test.ts | 40 ++-- ...2026-09-12-field-noop-reason-codes.task.md | 212 ++++++++++++++++++ .../features/translate-field/handler.test.ts | 26 ++- .../features/translate-field/handler.ts | 32 +-- .../src/types/wire/field-translation.ts | 28 ++- 5 files changed, 309 insertions(+), 29 deletions(-) create mode 100644 packages/payload-plugin-translator/docs/plans/2026-09-12-field-noop-reason-codes.task.md diff --git a/apps/dev/src/integration/translator/field-surface-noop.int.test.ts b/apps/dev/src/integration/translator/field-surface-noop.int.test.ts index 47f95e0a5..53c6f85f5 100644 --- a/apps/dev/src/integration/translator/field-surface-noop.int.test.ts +++ b/apps/dev/src/integration/translator/field-surface-noop.int.test.ts @@ -6,11 +6,11 @@ import { bootTestPayload } from "./bootTestPayload"; import type { TestPayload } from "./bootTestPayload"; import { callEndpoint } from "./callEndpoint"; -// The six situations `POST /translate/field` answers with `200` + `status: "noop"` instead of an -// error. One collection carries all six, because a spec file may boot Payload only once and each -// situation needs its own field shape. +// The five reasons `POST /translate/field` answers with `200` + `status: "noop"` instead of an +// error. One collection carries every shape that produces one, because a spec file may boot +// Payload only once. -type Notice = { level: string; message: string }; +type Notice = { level: string; reason: string; message: string }; type Reply = { status: string; value: unknown; notice?: Notice }; /** @@ -94,7 +94,7 @@ const CASES = { type CaseName = keyof typeof CASES; type Sent = { status: number; reply: Reply }; -describe("per-field translation — the six answers that are a noop, not an error", () => { +describe("per-field translation — the reasons a noop is a noop", () => { let ctx: TestPayload; let id: string; let replies: Record; @@ -138,7 +138,11 @@ describe("per-field translation — the six answers that are a noop, not an erro const { status, reply } = replies.empty; expect(status).toBe(200); expect(reply.status).toBe("noop"); - expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + expect(reply.notice).toEqual({ + level: "info", + reason: "nothing-translatable", + message: expect.any(String), + }); }); it("a field whose type is not one the plugin translates is a noop with an info notice", () => { @@ -147,7 +151,7 @@ describe("per-field translation — the six answers that are a noop, not an erro expect(reply).toEqual({ status: "noop", value: 42, - notice: { level: "info", message: expect.any(String) }, + notice: { level: "info", reason: "not-translatable", message: expect.any(String) }, }); }); @@ -157,7 +161,7 @@ describe("per-field translation — the six answers that are a noop, not an erro expect(reply).toEqual({ status: "noop", value: "classified", - notice: { level: "info", message: expect.any(String) }, + notice: { level: "info", reason: "excluded", message: expect.any(String) }, }); }); @@ -165,7 +169,11 @@ describe("per-field translation — the six answers that are a noop, not an erro const { status, reply } = replies.blocksWithoutRow; expect(status).toBe(200); expect(reply.status).toBe("noop"); - expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + expect(reply.notice).toEqual({ + level: "info", + reason: "block-unresolved", + message: expect.any(String), + }); }); it("a path through a localized blocks field is a noop with a warning notice", () => { @@ -174,7 +182,7 @@ describe("per-field translation — the six answers that are a noop, not an erro expect(reply).toEqual({ status: "noop", value: "Card one", - notice: { level: "warning", message: expect.any(String) }, + notice: { level: "warning", reason: "localized-list", message: expect.any(String) }, }); }); @@ -184,15 +192,21 @@ describe("per-field translation — the six answers that are a noop, not an erro expect(reply).toEqual({ status: "noop", value: "Row one", - notice: { level: "warning", message: expect.any(String) }, + notice: { level: "warning", reason: "localized-list", message: expect.any(String) }, }); }); - it("a subtree holding nothing translatable is a noop with an info notice", () => { + // A container named directly by the path is judged by its own type, not by what it holds — so a + // group whose only leaf is excluded answers `not-translatable`, the same as a number field would. + it("a container named by the path is judged by its own type", () => { const { status, reply } = replies.nothingInTheSubtree; expect(status).toBe(200); expect(reply.status).toBe("noop"); - expect(reply.notice).toEqual({ level: "info", message: expect.any(String) }); + expect(reply.notice).toEqual({ + level: "info", + reason: "not-translatable", + message: expect.any(String), + }); }); it("writes nothing — the document is unchanged in every locale", async () => { diff --git a/packages/payload-plugin-translator/docs/plans/2026-09-12-field-noop-reason-codes.task.md b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-noop-reason-codes.task.md new file mode 100644 index 000000000..f051467ba --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-09-12-field-noop-reason-codes.task.md @@ -0,0 +1,212 @@ +# A machine-readable reason on a field-translation noop + +Branch `test/translator-field-surface-coverage`, off `feat/translator-container-mode` (PR #139) · 2026-09-12 + +## Requirements / Task restatement + +`POST {basePath}/field` answers `200` with `status: "noop"` when it will not translate a +field. Which of several unrelated situations produced that answer is knowable only from a +sentence written for a human — and two of the situations share that sentence **word for +word**. Neither the admin control nor a test can tell "this field is empty" from "this +field opted out of translation". + +Give the notice a machine-readable `reason` alongside its human `message`. + +## What Phase 1 found, including a correction + +**There are five noop branches, not the six the brief assumed.** "The field is empty in +the source locale" is not its own branch: an empty value resolves normally, +`translateContent` returns `null`, and it lands in the same branch as "the subtree held +nothing translatable". + +| # | Branch | level | message today | +|---|---|---|---| +| 1 | `inside-blocks` | info | "Couldn't resolve the block for this field in the source document" | +| 2 | `localized-list-ancestor` | warning | "This field is inside a localized block — translate the whole document instead…" | +| 3 | `not-translatable` | info | **"Nothing to translate in this field"** | +| 4 | `excluded` | info | "This field is excluded from translation" | +| 5 | `!translated` (after `translateContent`) | info | **"Nothing to translate in this field"** | + +Rows 3 and 5 are byte-identical. Four of the five share `level: "info"`. + +**The distinction exists and is thrown away one step before the boundary.** +`resolveFieldSubtree` returns five named statuses, and its own docblock already records +the intent: `excluded` is *"Kept distinct from `not-translatable` so the notice can say +**why** (a deliberate opt-out, not a wrong type)"*. The handler then collapses five names +into two levels and four strings. This task finishes a job the code started. + +**The "non-localized translatable field" gap the brief asked about is not a defect.** +`resolveFieldSubtree` judges translatability by type alone, so such a field resolves; the +core's `isTranslatableLeaf` then rejects it because it is not localized, `translateContent` +returns `null`, and it lands in branch 5. Nothing is translated and nothing is overwritten. +What is wrong is only the sentence: a field full of text is told "nothing to translate". + +**Precedent:** `TranslationFailureCode` +(`src/translation-providers/shared/errors/TranslationProviderError.ts`) — a kebab-case +string-literal union declared beside the thing that reports it, with the human message +kept separate. The same idea, one floor down. + +**Blast radius:** the notice type (not exported from `src/index.ts`), the `noop` helper and +five branches in `handler.ts`, one consumer (`TranslateFieldControl.tsx`, which reads +`level` and `message` and nothing else), 7 unit assertions and 18 integration checks. + +**Risk: low** — one module, no data at stake, no public surface, strong existing coverage. +Consequences: one reviewer in Phase 5b rather than three; the regression sweep is done but +not ceremonially evidenced; a test is still required, because observability is the whole +deliverable. + +## Decisions + +### D1 — the reason is a string union on the wire type, not a class hierarchy + +**Chosen:** `FieldTranslationReason`, a kebab-case string-literal union declared in +`src/types/wire/field-translation.ts` beside `FieldTranslationNotice`, which gains +`reason: FieldTranslationReason`. + +**Rejected — a class per reason carrying a `code`, mirroring `TranslationProviderError`.** +That precedent's shape exists because those are *thrown* — a class is how a throwable +carries structured data through a `catch`. These are `200` responses whose value travels +as JSON in a body, so the class machinery buys nothing and costs five files. + +**Rejected — reuse `FieldSubtreeResolution`'s status names directly as the wire reason.** +Two constraints kill it: branch 5 is produced *after* resolution and has no resolver +status at all, so the union could not cover it; and it would pin a wire format to the +vocabulary of an internal helper, so renaming a resolver status would be a breaking wire +change. + +**Constraint cited:** the precedent's *idea* is "a stable kebab-case code beside the human +message"; its *implementation* is shaped by being throwable. Following the idea rather +than copying the machinery is what keeps this one union instead of five classes. + +### D2 — one reason per existing branch, and no new branches in this task + +**Chosen:** five reasons, one per branch as they stand. No condition is added, no +behaviour changes: the same request gets the same status, the same `level` and the same +`message` as before, plus a `reason`. + +**Rejected — split branch 5 into three (`empty-source`, `not-localized`, +`nothing-translatable`).** Each names a genuinely different situation an editor meets, and +both predicates already exist (`isEmpty` in `src/core/kernel/utils/isEmpty.ts`, +`isLocalizedField` in `src/server/shared/guards/field-guards.ts`), so it is cheap. But it +is *new behaviour*, not observability: today the code does not make those distinctions, and +making them is a separate decision about what an editor should be told. Raised at the gate +as its own question rather than folded in silently. + +**Constraint cited:** the stated problem is "five of six are indistinguishable", and five +codes on five branches solves exactly that. Anything beyond it is a second feature wearing +this one's clothes. + +### D3 — `message` stays, unchanged + +**Chosen:** `reason` is added; every existing `level` and `message` is left byte-identical. + +**Rejected — replace the message with a code and let the client compose the text.** The +admin control renders `message` directly today, so removing it would require a matching +client change in the same commit, turning a server task into a two-surface one. The two +identical messages stay identical: `reason` is what tells them apart, and rewording is a +copy decision nobody has made. + +### Placement + +`src/types/wire/field-translation.ts` (the union + the field) and +`src/server/features/translate-field/handler.ts` (the `noop` helper's signature and its +five call sites). Nothing else changes. + +### New surface + +One type, `FieldTranslationReason`, in a file that is not exported from the package index. +Callers: the `noop` helper, and the tests that assert it. Not a public API addition, so +the package's `@since` rule does not apply — verified: `src/index.ts` re-exports neither +`FieldTranslationNotice` nor `FieldTranslationResult`. + +### Written contract? + +The behavioural contract for this surface was written in the previous task, as a docblock +above `TranslateFieldHandler`. **It must be corrected here**: its table lists six +situations where the code has five, and it names "the field holds nothing in the source +locale" as its own row. That correction ships with this change. + +### Escalate to architecture? + +No. One module, one file pair, no new pattern, no data-model change. + +## Acceptance Criteria + +| # | Criterion | How it is checked | Passes when | +|---|---|---|---| +| 1 | Each of the five noop branches answers with its own `reason` | new cases in `src/server/features/translate-field/handler.test.ts`: `bunx vitest run src/server/features/translate-field` | green, and red before the change (the field does not exist) | +| 2 | The two branches sharing a message word for word are told apart by `reason` | same file: one case asserting `not-translatable` and one asserting the branch after `translateContent`, both with the same `message` and different `reason` | green; red if both branches are given the same reason | +| 3 | Every existing `level` and `message` is unchanged | the 7 notice assertions already in `handler.test.ts`, plus the 18 integration checks | all still green, none edited to fit | +| 4 | The integration specs assert the reason rather than "some string" | `apps/dev`: `bun run test:integration -- translator/field-surface-` | green, and red before the change | +| 5 | Nothing already passing breaks | `bunx vitest run` (package) · `bun run test:integration` (apps/dev) · `bun run check-types` · `bunx ultracite check packages/payload-plugin-translator/src apps/dev/src/integration/translator` · `bunx turbo run build --filter='./packages/*'` | 1491 unit · 29 files/130 integration · types clean · 58 warnings 0 errors · 8 packages build | +| 6 | The admin control keeps working on the wider notice | `bun run check-types`, and read `TranslateFieldControl.tsx` to confirm it reads `level`/`message` only | compiles; the file needs no edit | +| 7 | The behavioural contract matches the code | read the docblock above `TranslateFieldHandler` | five situations, not six; no row claiming "empty source" is its own branch | + +## Pre-flight + +Run against the untouched tree, 2026-09-12, before any edit. + +| # | Command | Result | Class | +|---|---|---|---| +| 1, 2, 4 | `grep -rn reason src/types/wire/field-translation.ts` | **no match** | change — fails now: correct polarity | +| 3, 5 | `bunx vitest run` | **1491 passed** | invariant — passes now | +| 3, 5 | `bun run test:integration` | **29 files, 130 passed** | invariant — passes now | +| 5 | `bun run check-types` | 5 tasks successful | invariant — passes now | +| 6 | `TranslateFieldControl.tsx:114-115` reads `notice.level` and `notice.message` | confirmed by reading | invariant | +| 7 | the docblock lists six situations | it does | change — fails now: correct polarity | + +## Risk notes + +- **Two identical messages stay identical.** After this change the only thing telling rows + 3 and 5 apart is `reason`. That is the point, but it means a future reader comparing + messages will still see a duplicate and may "tidy" one away. The reason codes are what + the tests assert, so such an edit would be caught. +- **The contract correction is the second time this table has been written.** It was + written from the branches in the previous task and got the count wrong; it is being + rewritten from them again. If it is wrong twice, the table is the wrong instrument and + the branches should carry the documentation directly. +- **Uncommitted work from the previous task shares this tree** — the behavioural contract + and three integration spec files. Whether it is committed first is a gate question. + +## Human choices + +- **2026-09-12** — the owner approved adding a machine-readable reason and asked for it to + follow the existing code-taxonomy precedent rather than a fourth mechanism. +- **2026-09-12, at the gate** — asked whether to split branch 5 into three (empty source / + not localized / nothing translatable inside): **codes only, branches untouched**. The + split is a separate task; this one changes no behaviour. +- **2026-09-12, at the gate** — the previous task's work (the behavioural contract and three + integration spec files) is committed on its own **before** this change, so the two diffs + do not mix. The contract is committed as it stands, with its six-row table; correcting it + to five is criterion 7 of this task. + +## Review log + +### 2026-09-12 · phase 5 + +- **Checks:** `bunx vitest run` → 1492 passed (was 1491; one new case) · `bun run test:integration` + → 130 passed, 29 files · `bun run check-types` → 5 tasks successful · + `bunx ultracite check` over the package and the integration suite → 58 warnings, 0 errors, the + main branch's level · `bunx turbo run build --filter='./packages/*'` → 8 tasks successful. +- **Gates:** sp-diff-checks clean (5 checks, no findings); sp-lint-delta mode=two-run, **no findings + introduced**. +- **Reviewers:** one (`full`), the count Phase 1's low-risk classification calls for. One minor + finding, fixed: the integration spec's header comment and `describe` title still said "six" while + the same diff corrected that count in the handler's docblock — two disagreeing counts one file + apart is exactly what criterion 7 exists to prevent. +- **Criteria:** 7 met · 0 partial · 0 not-run · 0 not met. +- **Left open:** splitting branch 5 into three (empty source / not localized / nothing translatable + inside), declined at the gate as new behaviour rather than observability. Both predicates already + exist, so it stays cheap whenever it is wanted. +- **Pin:** `e66925f8d789`. + +#### What the green run turned up + +One integration check failed on correct code, and it was a finding rather than a defect. Its +fixture named a `group` whose only leaf is excluded and expected `nothing-translatable`; the code +answered `not-translatable`, because `resolveFieldSubtree` judges a container by its own type +before anything walks inside it. The blind author of that spec had predicted exactly this +ambiguity and reported it as a place the contract was silent. The reason codes made it visible on +the first run. The check was renamed to say what it actually pins, its expectation corrected, and +the rule written into the contract — the resolver was not touched, and a reviewer confirmed that +independently from the file's history. diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts index e8c54ce5e..571c07bd5 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.test.ts @@ -126,6 +126,7 @@ describe("TranslateFieldHandler", () => { const body = (await res.json()).data; expect(body.status).toBe("noop"); expect(body.notice.level).toBe("info"); + expect(body.notice.reason).toBe("nothing-translatable"); expect(await importTranslateContent()).toHaveBeenCalledWith( expect.objectContaining({ sourceData: { title: "Hello" } }) ); @@ -177,6 +178,7 @@ describe("TranslateFieldHandler", () => { const body = (await res.json()).data; expect(body.status).toBe("noop"); expect(body.notice.level).toBe("info"); + expect(body.notice.reason).toBe("block-unresolved"); expect(translateContent).not.toHaveBeenCalled(); }); @@ -193,6 +195,7 @@ describe("TranslateFieldHandler", () => { const body = (await res.json()).data; expect(body.status).toBe("noop"); expect(body.notice.level).toBe("warning"); + expect(body.notice.reason).toBe("localized-list"); expect(translateContent).not.toHaveBeenCalled(); }); @@ -205,6 +208,7 @@ describe("TranslateFieldHandler", () => { const body = (await res.json()).data; expect(body.status).toBe("noop"); expect(body.notice.level).toBe("warning"); + expect(body.notice.reason).toBe("localized-list"); expect(translateContent).not.toHaveBeenCalled(); }); @@ -213,10 +217,29 @@ describe("TranslateFieldHandler", () => { const res = await handler.handle(reqWithDoc({ id: "p1", count: 5 }, { field_path: "count" })); expect(res.status).toBe(200); - expect((await res.json()).data.notice.level).toBe("info"); + const body = (await res.json()).data; + expect(body.notice.level).toBe("info"); + expect(body.notice.reason).toBe("not-translatable"); expect(translateContent).not.toHaveBeenCalled(); }); + it("tells apart the two no-ops that share a message word for word", async () => { + const translateContent = await importTranslateContent(); + + const wrongType = await handler.handle( + reqWithDoc({ id: "p1", count: 5 }, { field_path: "count" }) + ); + translateContent.mockResolvedValue(null); + const nothingInside = await handler.handle(reqWithDoc({ id: "p1", title: "Hello" })); + + const a = (await wrongType.json()).data.notice; + const b = (await nothingInside.json()).data.notice; + + expect(a.message).toBe(b.message); + expect(a.reason).toBe("not-translatable"); + expect(b.reason).toBe("nothing-translatable"); + }); + it("no-ops a field excluded via withFieldTranslation({ exclude }) — provider never called (D1)", async () => { const translateContent = await importTranslateContent(); @@ -228,6 +251,7 @@ describe("TranslateFieldHandler", () => { expect(body.status).toBe("noop"); expect(body.notice.level).toBe("info"); expect(body.notice.message).toContain("excluded from translation"); + expect(body.notice.reason).toBe("excluded"); expect(translateContent).not.toHaveBeenCalled(); }); diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts index cb7f31b4b..e60a1963e 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/handler.ts @@ -5,6 +5,7 @@ import { translateContent } from "../../../core/translation-pipeline"; import type { FieldTranslationNotice, + FieldTranslationReason, FieldTranslationResult, } from "../../../types/wire/field-translation"; import { FieldTranslationInputSchema, MAX_FIELD_VALUE_BYTES } from "./model"; @@ -18,11 +19,12 @@ const byteLength = (value: unknown): number => const noop = ( value: unknown, level: FieldTranslationNotice["level"], + reason: FieldTranslationReason, message: string ): FieldTranslationResult => ({ status: "noop", value, - notice: { level, message }, + notice: { level, reason, message }, }); /** @@ -39,17 +41,17 @@ const noop = ( * **It always overwrites.** A per-field translate is an explicit "translate this one now", so * there is no strategy to choose and `skip_existing` has no meaning on this surface. * - * **"I cannot translate this" is a success, not an error.** Six situations come back `200` with - * `status: "noop"`, the source value unchanged, and a notice explaining which: + * **"I cannot translate this" is a success, not an error.** Five situations come back `200` with + * `status: "noop"`, the source value unchanged, and a notice naming which. `reason` is what tells + * them apart: two of the five carry the same `message` word for word. * - * | Situation | Notice level | - * |---|---| - * | the field holds nothing in the source locale | `info` | - * | the field's type is not one this plugin translates | `info` | - * | the field is excluded via `withFieldTranslation({ exclude: true })` | `info` | - * | the path runs into `blocks` and the saved document does not say which block | `info` | - * | the path runs through a **localized** `blocks` or `array` | `warning` | - * | nothing translatable was found once the subtree was walked | `info` | + * | `reason` | When | level | + * |---|---|---| + * | `block-unresolved` | the path runs into `blocks` and the saved document does not say which block sits there | `info` | + * | `localized-list` | the path runs through a **localized** `blocks` or `array` | `warning` | + * | `not-translatable` | the path lands on a field whose type this plugin does not translate — a container named directly is judged by its own type, not by what it holds | `info` | + * | `excluded` | the field opted out via `withFieldTranslation({ exclude: true })` | `info` | + * | `nothing-translatable` | the path landed on a translatable leaf that held no translatable text: empty in the source locale, or not localized, so there is one value for every locale | `info` | * * The one `warning` is the case where the request is answerable but the answer would be wrong: * a localized list has its own order per locale, so an index in the path cannot be matched across @@ -120,6 +122,7 @@ export class TranslateFieldHandler { noop( sourceValue, "info", + "block-unresolved", "Couldn't resolve the block for this field in the source document" ) ); @@ -131,18 +134,19 @@ export class TranslateFieldHandler { noop( sourceValue, "warning", + "localized-list", "This field is inside a localized block — translate the whole document instead, so blocks stay aligned across locales" ) ); } if (resolution.status === "not-translatable") { return ServerResponse.success( - noop(sourceValue, "info", "Nothing to translate in this field") + noop(sourceValue, "info", "not-translatable", "Nothing to translate in this field") ); } if (resolution.status === "excluded") { return ServerResponse.success( - noop(sourceValue, "info", "This field is excluded from translation") + noop(sourceValue, "info", "excluded", "This field is excluded from translation") ); } @@ -159,7 +163,7 @@ export class TranslateFieldHandler { if (!translated) { return ServerResponse.success( - noop(sourceValue, "info", "Nothing to translate in this field") + noop(sourceValue, "info", "nothing-translatable", "Nothing to translate in this field") ); } diff --git a/packages/payload-plugin-translator/src/types/wire/field-translation.ts b/packages/payload-plugin-translator/src/types/wire/field-translation.ts index c43a920af..ef685f393 100644 --- a/packages/payload-plugin-translator/src/types/wire/field-translation.ts +++ b/packages/payload-plugin-translator/src/types/wire/field-translation.ts @@ -1,4 +1,30 @@ -export type FieldTranslationNotice = { level: "info" | "warning"; message: string }; +/** + * Why the endpoint declined to translate, for code rather than for a reader. `message` is the + * sentence shown to an editor and may be reworded at any time; these values may not — two of the + * five situations carry the same sentence word for word, so this is the only thing that tells + * them apart. + * + * - `block-unresolved` — the path descends into `blocks` and the saved document does not say + * which block sits at that position. + * - `localized-list` — the path descends through a **localized** `blocks` or `array`, whose order + * is its own per locale, so a positional path cannot be matched across them. + * - `not-translatable` — the path lands on a field whose type this plugin does not translate. + * - `excluded` — the field opted out via `withFieldTranslation({ exclude: true })`. + * - `nothing-translatable` — the subtree resolved, but held no translatable text: an empty value, + * or one whose leaves are all excluded or not localized. + */ +export type FieldTranslationReason = + | "block-unresolved" + | "localized-list" + | "not-translatable" + | "excluded" + | "nothing-translatable"; + +export type FieldTranslationNotice = { + level: "info" | "warning"; + reason: FieldTranslationReason; + message: string; +}; /** * Successful response. Never an error for "couldn't translate": a field with no