Skip to content

feat(translator): translate rich text one container at a time, behind a flag - #139

Merged
SearheiParkhamchuk merged 11 commits into
mainfrom
feat/translator-container-mode
Sep 12, 2026
Merged

SearheiParkhamchuk merged 11 commits into
mainfrom
feat/translator-container-mode

Conversation

@SearheiParkhamchuk

@SearheiParkhamchuk SearheiParkhamchuk commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Closes #134. Builds on the two kernel modules from #137.

The defect

A paragraph is translated one text node at a time, each node in isolation. Word order 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:

un |**rouge**| voiture      ← today: ungrammatical, emphasis on the wrong word

What this does

Sends the whole container — paragraph, heading, list item — as one string with numbered marks, and rebuilds its children in the order the reply comes back:

sent : <1>a </1><2>red</2><3> car</3>
back : <1>une </1><3>voiture </3><2>rouge</2>

Same sentence, same model, flag on:

une |**voiture rouge**       ← word order rebuilt, emphasis carried across

The third node merged away — the model returned its mark empty — so three children became two. That is the designed behaviour for a merge, not a loss.

Two gates, both closed by default

  • experimental: { inlineMarks: true } in the plugin config.
  • and the provider must declare capabilities.inlineMarks. A transport that is not a language model (DeepL, Google Translate) would translate or strip the marks, so this is part of the provider contract rather than a config option.

The mark instruction is appended after a systemPrompt override, not inside defaultPrompt — a builder is free to ignore the default, and would otherwise silently drop the one rule the format depends on, with no way for the operator to see why every container fell back.

When it will not use marks

A container falls back to the per-node path, and says which reason applies: 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 rebuilt paragraph into the document. Silently, for now — the report that records those places is the next change.

A known structural change (D12)

One source wrapper holding several differently-formatted leaves becomes several adjacent wrappers in the result, and they are not merged back in this version. Confirmed live, German:

source : Please |[read the **manual**](/docs)| before you start.
result : Bitte |[lesen Sie das ](/docs)|[**Handbuch**](/docs)| bevor Sie beginnen.

One link became two siblings with the same href. It renders identically; merging siblings that share an origin is polish, and correctness does not depend on it.

One write path, not two

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: the second branch cost a flag, a four-clause predicate and three tests that existed only to keep the two branches agreeing, and rebuilding one array per paragraph costs nothing measurable. Removing it was also what exposed a coverage gap — every pipeline-level container test used flat text nodes, so placing a bare leaf instead of its wrapper kept the whole core suite green. That case is now covered.

Verification

  • 1488 unit tests, 19 new. check-types clean. Lint 58 warnings, 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, a wrapper's href preserved on both halves.

Review note

The load-bearing pair is node (where text is written) and top (what is placed into the rebuilt array) from #137. This PR is what consumes that distinction: TranslationMutator.applyContainer writes through node and places top.

… a flag

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 </1><2>red</2><3> car</3>`, 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.
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ideal-cms Ready Ready Preview Sep 12, 2026 8:11pm UTC

Request Review

`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.
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.
`TranslationStage` put parsed reply marks in `PipelineContext.containerFragments`,
a `Record<number, ParsedMark[]>`, 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.
…unk kind a compile error

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.
…sting depth

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.
…text

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.
…tainer

`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.
…capability

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.
… owes callers

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.
… not just for a reader

`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.
@SearheiParkhamchuk
SearheiParkhamchuk merged commit 225a6b3 into main Sep 12, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.13.0 🎉

The release is available on npm package (@latest dist-tag)

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

translator: rich text is translated node-by-node, so word order and inline marks come out wrong

1 participant