Skip to content

feat: support Strapi Blocks (rich text) fields in content transfer (LOC-4580) - #129

Merged
david-vaclavek merged 2 commits into
mainfrom
LOC-4580_blocks-content-transfer
Aug 3, 2026
Merged

david-vaclavek merged 2 commits into
mainfrom
LOC-4580_blocks-content-transfer

Conversation

@david-vaclavek

@david-vaclavek david-vaclavek commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds type-aware support for Strapi 5 Blocks (rich text) fields to the Content Transfer pipeline, per Fibery task LOC-4580. Blocks fields now appear in Content Transfer Setup and round-trip through Localazy as translatable text segments with formatting carried as structure — not as literal markup inside a string.

Reworked from the earlier "one story = one key" prototype after David's review: the original AC (per-text-node segments, structure preserved) is now met.

How it works

  • Whitelist (supported-content-type-fields.ts): blocks added, so blocks fields surface as selectable leaves in Setup.
  • Upload (blocks-to-translatable.tsprojectBlocksFields, wired into localazy-transfer-upload-service.ts): before flattening, each blocks AST is projected to a text-only skeleton — only text leaves are kept, in their original positions; block types, headings, lists, links, marks, images, quotes and code blocks are dropped from the payload. flattenObject then emits one translatable segment per text node. Blocks nested inside components / dynamic zones are reached by walking the schema.
  • Download (overlayBlocksTranslation, wired into parsed-localazy-entry-to-create-entry.ts, which serves both create and update): the translated text skeleton is overlaid onto the source-locale AST — structure and formatting come from the source, only text is swapped. Consequences:
    • A document Strapi accepts, always (structure is source-derived) — validated as a top-level array; no source AST → field skipped, never a corrupt write.
    • A translation identical to the source round-trips byte-identically.
    • Marks, links, headings, nested lists, images, quotes and code blocks all survive.
    • Text nodes left untranslated fall back to the source text; a fully-untranslated field is left empty and the source locale is untouched.
  • model-utils.ts: isBlocks now reuses the shared BLOCKS_ATTRIBUTE_TYPE constant (review suggestion).

Test plan

  • npm run test:server — 234/234 pass (new blocks-to-translatable.test.ts covering projection + overlay incl. byte-identical round-trip, marks/links/lists/images/quotes/code survival, partial fallback, nesting; new parsed-localazy-entry-to-create-entry.blocks.test.ts covering the download wiring — the earlier review's should-fix)
  • tsc server + admin — clean
  • eslint + prettier --check — clean
  • npm run build — succeeds
  • Manual (David — no Strapi host in sandbox): content type with a localized blocks field → appears in Setup; upload sends per-node text segments (no JSON/markup); download renders in the admin editor; round-trip preserves headings, lists (incl. nesting), marks, links, images, quotes, code; empty/partial locale handled.

Notes

  • richtext fields are unchanged (already supported via the plain string path; a markdown string, not a Blocks AST).

🤖 Generated with Claude Code

Blocks fields are now whitelisted for Content Transfer. Each blocks field
travels as a single translatable key: on upload its AST is serialized to one
JSON string (per David's "one story = one key"), and on download that string
is parsed back and stored verbatim so what is stored is what renders — no
source-AST overlay. Nested blocks in components and dynamic zones are covered
by walking the schema. Malformed download payloads are skipped rather than
written to avoid corrupting the field.

Resolves LOC-4580

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@david-vaclavek

david-vaclavek commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer — round 1 (head 286e196e)

DECISION: PASS_WITH_SUGGESTIONS — no must-fix items; correct, mergeable, CI 6/6 green. One should-fix (a missing download-wiring test) plus minor polish, and this change is inherently reliant on David's live round-trip check since there is no Strapi/Localazy host in the sandbox.

Context: implementation follows David's decision, not the written AC

The task description's acceptance criteria ask for per-node translatable segments with formatting carried as structure, not literal markup in the string. David's Fibery decision comment overrode that with:

  1. One story = one key — serialize the whole blocks AST to a single translatable key.
  2. No overlay — on download, parse it back and store verbatim ("what's stored is what's rendered").

I verified this override is authoritative (Fibery task comment, 2026-07-31). The PR implements David's simpler approach correctly, so the AC bullets about segment/structure granularity are superseded and not treated as findings.

What I checked

  • Read all 7 changed files end-to-end + the surrounding round-trip machinery (localazy-transfer-upload-service, parsed-localazy-entry-to-create-entry, flatten-object, should-set-downloaded-property, localazy-transfer-download-service).
  • Upload: serializeBlocksFields deep-clones the entry (source not mutated), walks the schema, and JSON.stringifys every blocks value — including blocks nested in single/repeatable components and dynamic zones. Because the value becomes a string before flattenObject, it stays one leaf key (behaves like a string/text field) rather than exploding like json. Correct.
  • Download: a top-level blocks key resolves to shouldSetDownloadedProperty → YES (exact enabled path), the translated string is set verbatim, then parsedLocalazyEntryToCreateEntry hits the leaf branch where isBlocks(attribute) routes it through parseBlocksFieldValue. Nested blocks resolve identically to a nested string field. Correct by symmetry with the existing string path.
  • Malformed-skip: parseBlocksFieldValue requires a top-level array; on failure the leaf branch does return, which (inside the forEach) skips only that field and still writes the rest of the entry. Good — satisfies "never write an invalid document" without dropping sibling fields.
  • Tests: solid unit coverage of serializeBlocksFields (top-level, repeatable component, dynamic zone, no-mutation, non-blocks untouched, null/undefined skip) and parseBlocksFieldValue (valid, round-trip, already-array, malformed JSON, non-array parsed, non-string). isBlocks covered.

Must Fix

None.

Should Fix

  • No test for the download wiring in parsed-localazy-entry-to-create-entry.ts. The helper functions are unit-tested, but the actual integration — isBlocks(attribute) detection in the leaf branch + the malformed-return that must skip only the one field — is the correctness-critical part and is the only piece CI cannot exercise end-to-end (no live host). A small test feeding a { story: "<json string>" } translated entry (valid → parsed & set; malformed → skipped, siblings preserved) would lock this in.

Suggestions (non-blocking)

  • blocks-field-serialization.ts:3 exports BLOCKS_ATTRIBUTE_TYPE = 'blocks' but model-utils.ts:29 isBlocks hardcodes the literal 'blocks'. Minor DRY — isBlocks could reference the constant.
  • Translator UX (product note, David's call): a blocks field reaches translators as one raw-JSON segment ([{"type":"paragraph",...}]). If a translator/MT edits it into invalid JSON, parseBlocksFieldValue silently skips the field on download — the translation just won't appear, with no signal as to why. This is the safe behavior per the "no invalid document" rule and a direct consequence of the approved "one story = one key" decision; flagging only so it's a conscious tradeoff.

Manual Testing Checklist (requires a live Strapi 5 + Localazy — David)

Because the sandbox has no host, these AC-level behaviors need a manual round-trip:

Setup visibility

  • A localized blocks field appears in Content Transfer Setup and can be selected in both directions.

Field location (upload → translate → download)

  • Top-level blocks field round-trips.
  • blocks inside a single (non-repeatable) component.
  • blocks inside a repeatable component (multiple items).
  • blocks inside a dynamic zone.

Content richness survives round-trip

  • Marks: bold / italic / underline / strikethrough / inline code.
  • Links (with nested text children).
  • Headings (all levels), quotes, code blocks.
  • Lists including nested lists.
  • Images.

Identity & partial states

  • Translation identical to source → rendered document is byte-identical and the admin editor opens it without error.
  • Target locale with no translation → field left empty, source locale not corrupted.
  • Malformed/invalid translated JSON → field skipped (not written), other fields in the same entry still sync, no crash.

Create vs update

  • New locale entry (create path) and existing locale entry (update path) both handle the blocks field.

DECISION: PASS_WITH_SUGGESTIONS

Rework Strapi Blocks (rich text) content transfer to meet the original AC:
upload now emits one translatable segment per text leaf (formatting carried
as AST structure, not literal markup in a string) and download rebuilds the
target document by overlaying the translated text onto the source-locale AST.

- Replace whole-AST JSON serialization (blocks-field-serialization) with
  text-leaf projection + source-AST overlay (blocks-to-translatable).
- Wire overlay into parsedLocalazyEntryToCreateEntry (covers create + update);
  no source AST to overlay onto -> skip the field, never write an invalid doc.
- Identical translations round-trip byte-identically; marks, links, headings,
  nested lists, images, quotes and code blocks survive; untranslated leaves
  fall back to source text; nested-in-component/DZ covered by schema-walking.
- Reuse BLOCKS_ATTRIBUTE_TYPE in isBlocks (review suggestion) and add tests for
  the download wiring (review should-fix).

Resolves LOC-4580

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@david-vaclavek

Copy link
Copy Markdown
Contributor Author

Reviewer — round 2: PASS ✅

Reviewed head b05ddcf (round 1 was 286e196). Following David's comment df91b0e4 — which reversed the round-1 "one story = one key / no overlay" decision and directed that the original AC be met (upload produces translatable segments with formatting carried as structure, not literal markup inside a string) — the mechanism was rewritten. This round faithfully implements that directive and both round-1 review notes are addressed. No must-fix, no should-fix.

What I verified (correctness, full round-trip)

  • Upload projection (blocks-to-translatable.tsprojectBlocksFields / projectBlocksValue): deep-clones the entry (source not mutated), walks the schema into components / repeatable components / dynamic zones, and reduces each blocks AST to a text-only skeleton — only text leaves and their nesting survive; block types, headings, list formats, link URLs and marks (bold/italic) are all dropped from the payload. So flattenObject emits one segment per text node, formatting carried as structure. No JSON/markup leaks into the string. ✔
  • Segment selection: the projected skeleton flattens to keys like uid.story[0].children[0].text. pickEntries strips bracket indices (→ uid.story.children.text) and matches via the existing startsWith('${pickPath}.') "include JSON fields" clause, so blocks segments are correctly selected rather than dropped — it reuses the same machinery that already handles nested json fields. ✔
  • Download overlay (overlayBlocksTranslation, wired into parsedLocalazyEntryToCreateEntry's leaf branch): rebuilds the target-locale AST by walking the source AST and swapping in translated text at matching positions. All structure/formatting therefore comes from the source → the result is always a Strapi-valid document. Position alignment is robust because indices are encoded in the flattened keys (not implied by array compaction), and lodash get handles both array and numeric-string-keyed download shapes. ✔
  • Identity round-trip: translation identical to source → byte-identical output (tested). ✔
  • Partial translations: untranslated text leaves fall back to source text (documented tradeoff). ✔
  • Never-write-invalid: overlayBlocksTranslation returns undefined when there is no source AST to overlay onto, and the caller skips only that field (siblings still written). ✔
  • Round-1 notes addressed: isBlocks now reuses the BLOCKS_ATTRIBUTE_TYPE constant; a download-wiring test (parsed-localazy-entry-to-create-entry.blocks.test.ts) now covers the previously-untested overlay routing (create/update + skip-on-no-source). ✔
  • CI 6/6 green (Format, Lint, Typecheck admin/server, Tests server, Code Quality).

Suggestions (non-blocking, optional)

  1. blocks-to-translatable.ts is any-typed throughout. It's consistent with the surrounding untyped modules (model-utils, parsed-localazy-entry-to-create-entry) and doesn't hide a bug, but a small BlocksNode interface for the AST would make projection/overlay easier to reason about.
  2. Structural nodes carrying an empty text leaf (e.g. an image's children: [{ text: '' }]) project to an empty-string segment, which uploads an empty key. Harmless (falls back to source on download), but you may want to drop empty-text leaves from the projected payload to avoid empty segments reaching translators.
  3. Download-wiring test covers only a top-level blocks field. Nested-in-component / nested-in-DZ download overlay is exercised only on the upload side; it reuses the same proven path-resolution as string fields, so risk is low — a nested download test would close the last gap.

Manual verification (needs a live Strapi/Localazy host — no host in sandbox)

  • Blocks field appears in the content-transfer Setup UI (whitelist change).
  • Upload: a blocks field emits one segment per text node (not a single JSON blob); marks/links/headings/nested lists/images/quotes/code blocks are absent from the segment strings.
  • Download into a target locale: marks, links (URL preserved), headings, nested ordered/unordered lists, images (media + alt), quotes and code blocks all render correctly; text is translated.
  • Identity locale round-trips with no visible diff.
  • Empty and partially-translated locales: untranslated leaves fall back to source; no corrupted document is ever written.
  • Blocks nested inside a component, a repeatable component, and a dynamic zone — both create and update paths.

DECISION: PASS

@david-vaclavek
david-vaclavek merged commit 2d86d46 into main Aug 3, 2026
6 checks passed
@david-vaclavek
david-vaclavek deleted the LOC-4580_blocks-content-transfer branch August 3, 2026 10:14
@localazy-bot localazy-bot Bot mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants