The translation editor destroys content. Any element outside the schema of its bundled CKEditor build is removed from the target value and written to the autosaved draft, with no warning, no console error, and an untouched source - so the loss is invisible in the workspace and surfaces, if ever, on the published translation.
extension/components/RichTextField.tsx builds a ClassicEditor from a fixed plugin list (Essentials, Paragraph, Bold, Italic, Underline, Link, List) with no GeneralHtmlSupport. CKEditor 5's data processor therefore drops every unknown element on load, and the stripped result is what onChange writes back.
The server side is not at fault: ApiTranslateController::editorFormats() resolves the real text format and sends its real toolbar, exactly as documented. The mismatch is that the client cannot honor what it is sent, and fails open, it discards the content rather than declining to edit it.
Steps to reproduce
- Give an SDC
text/htmlprop a text format that permits more than the seven bundled plugins can express, e.g. one withmedia_embed, headings or tables. (We use the<a href="https://www.drupal.org/project/canvas_full_html">canvas_full_html</a>module; any rich format reproduces it.) - On a Canvas page, place a component with that prop and author content using one of those capabilities - a media embed, an
<h2>, a table. - Open the page in Canvas Translate for a target language.
- Press the editor's own Copy source text into this field.
- The element is gone from the target, and the autosaved draft has it stripped.
Editing the field by hand reproduces it identically; opening the editor does not, because onChange does not fire on mount. So the trigger is a translator doing exactly what the UI invites.
Measured (2026-08-17, Chrome, Drupal 11.4.5)
On one page with a <drupal-media> PDF embed in a rich-text prop:
- The API sends
format: <canvas_full_html>and all 26 toolbar items. - The editor renders 8 buttons:
Bold, Italic, Underline, Link, Bulleted List, Numbered List, Undo, Redo. - 14 items are dropped with
toolbarview-item-unavailable:heading, strikethrough, superscript, subscript, removeFormat, alignment, indent, outdent, blockQuote, insertTable, drupalMedia, horizontalLine, specialCharacters, sourceEditing. - Source HTML contains
<drupal-media …>; after copy-source the editor's data does not, and neither does the saved draft.
The dropped toolbar buttons are cosmetic. The deleted content is not.
On spec violation
The module's own content-translation capability lists source-fallback (no data loss) as a requirement (openspec/specs/content-translation/spec.md). An editor that silently discards markup breaks that requirement regardless of which plugins it happens to ship.
Proposed fix
Do not hand a value to an editor that cannot round-trip it. When a value contains markup outside the bundled build's representable set, report the row as plain text; FieldRow already renders a textarea for that case. The translator then edits raw HTML, plainer, but nothing is destroyed.
This is a small, self-contained server-side change and needs no bundle rebuild:
- a helper that answers "can the bundled editor round-trip this value?", keyed on the element names the shipped plugin set can represent;
- two call sites:
ApiTranslateController::getTranslation()andConfigTranslatableGlue::rows()- pass the row's source and target through it before emittingformat.
Patch attached, happy to open it as an MR.
On the general case
Whatever the bundled editor eventually supports, it can never match every format a site configures (drupalMedia in particular is a Drupal-side plugin with no npm equivalent). So a guard of this shape is worth having permanently, not only until the plugin set grows. It fails closed instead of open, which is the property the spec requirement actually needs.
| Comment | File | Size | Author |
|---|
Issue fork canvas_translate-3617651
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
f0ns commentedTested the patch against 1.0.x. It applies cleanly, the premise checks out (
RichTextField.tsxreally does build only Essentials, Paragraph, Bold, Italic, Underline, Link, List, with no GeneralHtmlSupport), and reportingformat: nullis safe: the row's format only picks the widget, and the publish path writes#textonly, so the stored text format survives a downgraded row. I wrote 20 unit cases againstExtensionEditorCapability::formatFor()and they all pass.Two things that need attention:
1. The
stag is allowlisted but the bundle cannot represent it.REPRESENTABLEincludes's', butStrikethroughis a separate plugin and is not inPLUGINS(only Bold, Italic and Underline are). So a strikethrough element passes the guard, reaches the rich editor, and is dropped: exactly the bug this patch exists to prevent. Either drop's'from the list or addStrikethroughto the build.2. Attribute-level loss is not covered, and it is probably the more common case. Without GeneralHtmlSupport, CKEditor drops unknown attributes as well as unknown elements, but the guard only inspects tag names. Drupal's editor link plugin stores internal links like this:
The anchor tag is allowlisted, so the guard says "representable", the row goes to CKEditor, and the
data-entity-typeanddata-entity-uuidattributes are stripped, quietly turning a managed entity reference into a bare href. Same for aclassattribute on a paragraph, ortargetandrelon a link. On most sites that markup is far more widespread than media embeds, so the guard currently gives some false confidence.Which raises a design question: is the allowlist the right primary fix? Adding
GeneralHtmlSupportto the bundle would preserve unknown elements and attributes through the model, addressing the second point structurally rather than tag by tag. I do not think it is either/or, and I agree the guard should be permanent (no plugin set can match every site's format config, anddrupalMediahas no npm equivalent). But shipping only the guard means accepting thatclassanddata-*attributes keep disappearing silently.Smaller notes: the
banditags are allowlisted and semantically safe, but CKEditor normalises them tostrongandemon load, so opening a row rewrites markup the translator never edited. The tag regex also matches inside attribute values and comments, which only costs an unnecessary textarea, so it fails in the safe direction. And the PHP allowlist has to track the TypeScript plugin list by hand with nothing to catch drift, which is how point 1 arose in the first place.Comment #3
f0ns commentedComment #4
davituri commentedThanks, both are fixed and the second one changed my mind about the shape of the guard.
1.
swas allowlisted with noStrikethroughplugin behind it. Confirmed and removed.2. Attribute-level loss. Also confirmed and you are right that it is the more common case —
data-entity-type/data-entity-uuidon an internal link is written by editor's link dialog and losing it silently converts an entity reference into a bare path that breaks on the next alias change.target/relandtext-align-*go the same way.What changed in the re-roll
The guard now parses the value and checks each element and its attributes against what the seven plugins actually produce.
REPRESENTABLEbecame a map of element to permitted attributes;akeeps onlyhref, since the build configures no link decorators.That also removes the false positives you noted, where the regex matched
<taginside an attribute value or an HTML comment and stranded an editable row on a textarea.Against 13 vectors covering every loss mode named in this issue, the parsing guard is correct on 13; the regex was correct on 7:
<drupal-media>/<h2>/<table>/<blockquote><s>strikethrough<a data-entity-type data-entity-uuid><a target rel><p class="text-align-center">On your
b/iobservation: I left them allowed and documented the line as normalisation is accepted where deletion is not. They are upcast tostrongandem, which rewrites markup the translator never touched but does not change what renders. Sending every<b>to a raw textarea seemed a poor trade for that.On the drift
Agreed, the capability lives in
RichTextField.tsxand the guard lives in PHP, so nothing can fail when they disagree. Two ways out, both better than the current patch:RichTextFieldnext toPLUGINS— render a textarea instead of<CKEditor>when the value is unrepresentable. The two lists become one list in one file. I kept it server-side only because we consume this as a composer patch and cannot rebuilddist/index.global.js.b→strongandi→em.I can write either, but that is a bigger change than this issue, and the architecture is your call.
Comment #5
f0ns commentedI don't feel like the architecture is my call but the maintainer's call.
The changes look good.
One more thing I had as a comment on my issue as well by the maintainer:
This will need specs (cfr. https://www.drupal.org/project/canvas_translate/issues/3616274#comment-1...)
Comment #6
davituri commentedRight.
I'll put up an MR with the spec first, then the test cases against it.
Draft of the requirement, before we write tests against it:
@lauriii - one question: The spec above holds whichever way it is implemented, so I'll carry the server-side guard from #4 unless you'd rather have one of:
RichTextField.tsx, next toPLUGINS, so the capability list exists once instead of twice;GeneralHtmlSupportin the build shrinks what any guard has to catch but cannot replace it, sincedrupalMediahas no npm equivalent and would round-trip as inert markup a translator cannot see.Comment #8
f0ns commentedOpened an MR that takes a different route from the allowlist patch: rich text is now edited in the text format's own CKEditor 5 (Drupal core's, the same editor Canvas uses), loaded into the extension through a new editor-assets endpoint.
Media embeds, headings, tables and `data-entity-*` links are kept because the format's own plugins handle them, not because a guard catches them.
@davituri's option 2 from #4 is in as a safety net: each value is round-tripped through that editor before it is loaded, and anything it would drop is edited as HTML instead, unchanged. The spec from #6 is included, along with the adjacent-field scenario. Could you retest this?
E2E tests cover a real `` embed, a managed link and a heading through copy source → publish, plus the HTML fallback. Both fail on 1.0.x.
@lauriii since #6 left the architecture to you: this drops the bundled editor entirely (bundle 1.28 MB → 595 KB). Reasoning is in the MR description.
Comment #9
f0ns commentedComment #10
f0ns commentedComment #12
f0ns commentedReviewed the latest commits (74cfdad, 4ee0ffa). Agree with dropping the loss check: with the format's own editor, only markup the format disallows is dropped, same as in Canvas.
Locally on 4ee0ffa: type-check, build (dist in sync), Vitest 39/39, kernel 43/43, no new phpcs findings.
- The MR pipeline failed on an artifact upload error in the `composer` job (413), not on the code. This just needs a re-run.
- I'll update the MR description, which still mentions the loss check.
Comment #13
f0ns commentedPipeline is blocked by an unrelated CI failure (composer artifact too large, 413). Fix in #3628831: Composer CI job fails with 413: artifact too large; needs a rebase once that's in.
Comment #14
lauriiiWe were missing logic to account for the fact that user may not have permission to use the text format. I also removed
happy-domwhich wasn't being used anymore since we got rid of the rich text loss tests. I think this should be ready now.@f0ns do you want to check the most recent changes still?
Comment #16
lauriiiThank you @f0ns and @davituri for the help with this! Merged! 🚀