From 286e196e6d539c48e7c5fca005b144ce1ea79acb Mon Sep 17 00:00:00 2001 From: Agent System Date: Fri, 31 Jul 2026 13:13:58 +0000 Subject: [PATCH 1/2] feat: support Strapi Blocks (rich text) fields in content transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../models/supported-content-type-fields.ts | 3 +- .../localazy-transfer-upload-service.ts | 6 +- .../blocks-field-serialization.test.ts | 119 ++++++++++++++++++ .../src/utils/__tests__/model-utils.test.ts | 22 +++- .../src/utils/blocks-field-serialization.ts | 68 ++++++++++ server/src/utils/model-utils.ts | 6 +- .../parsed-localazy-entry-to-create-entry.ts | 12 +- 7 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 server/src/utils/__tests__/blocks-field-serialization.test.ts create mode 100644 server/src/utils/blocks-field-serialization.ts diff --git a/admin/src/modules/@common/models/supported-content-type-fields.ts b/admin/src/modules/@common/models/supported-content-type-fields.ts index e591fe1..0558097 100644 --- a/admin/src/modules/@common/models/supported-content-type-fields.ts +++ b/admin/src/modules/@common/models/supported-content-type-fields.ts @@ -1,4 +1,4 @@ -export type SupportedContentTypeField = 'string' | 'text' | 'richtext' | 'email' | 'json'; +export type SupportedContentTypeField = 'string' | 'text' | 'richtext' | 'email' | 'json' | 'blocks'; export const SUPPORTED_CONTENT_TYPE_FIELDS: SupportedContentTypeField[] = [ 'string', @@ -6,4 +6,5 @@ export const SUPPORTED_CONTENT_TYPE_FIELDS: SupportedContentTypeField[] = [ 'richtext', 'email', 'json', + 'blocks', ]; diff --git a/server/src/services/localazy-transfer-upload-service.ts b/server/src/services/localazy-transfer-upload-service.ts index f45d868..b00a2ec 100644 --- a/server/src/services/localazy-transfer-upload-service.ts +++ b/server/src/services/localazy-transfer-upload-service.ts @@ -1,5 +1,6 @@ import type { Core } from '@strapi/strapi'; import { flattenObject } from '../utils/flatten-object'; +import { serializeBlocksFields } from '../utils/blocks-field-serialization'; import { getCollectionsNames, findSetupModelByCollectionName, @@ -165,8 +166,11 @@ const LocalazyTransferUploadService = ({ strapi }: { strapi: Core.Strapi }) => ( entries = omitDeep(entries, ['locale', 'createdAt', 'updatedAt', 'publishedAt']); entries.forEach((entry) => { + const serializedEntry = serializeBlocksFields(entry, strapi.getModel(modelUid), (uid) => + strapi.getModel(uid as any) + ); const flatten = flattenObject({ - [modelUid]: entry, + [modelUid]: serializedEntry, }); // get only enabled fields; "__component" will be filtered out inside of the function const pickedFlatten = pickEntries(flatten, pickPathsWithUid); diff --git a/server/src/utils/__tests__/blocks-field-serialization.test.ts b/server/src/utils/__tests__/blocks-field-serialization.test.ts new file mode 100644 index 0000000..ef03686 --- /dev/null +++ b/server/src/utils/__tests__/blocks-field-serialization.test.ts @@ -0,0 +1,119 @@ +import { serializeBlocksFields, parseBlocksFieldValue } from '../blocks-field-serialization'; + +const storyAst = [ + { + type: 'heading', + level: 2, + children: [{ type: 'text', text: 'Title', bold: true }], + }, + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Hello ' }, + { type: 'link', url: 'https://localazy.com', children: [{ type: 'text', text: 'world' }] }, + ], + }, +]; + +describe('blocks-field-serialization', () => { + describe('serializeBlocksFields', () => { + it('serializes a top-level blocks field into a single JSON string', () => { + const model = { attributes: { title: { type: 'string' }, story: { type: 'blocks' } } }; + const entry = { title: 'Post', story: storyAst }; + + const result = serializeBlocksFields(entry, model, () => undefined); + + expect(result.title).toBe('Post'); + expect(typeof result.story).toBe('string'); + expect(JSON.parse(result.story)).toEqual(storyAst); + }); + + it('does not mutate the source entry', () => { + const model = { attributes: { story: { type: 'blocks' } } }; + const entry = { story: storyAst }; + + serializeBlocksFields(entry, model, () => undefined); + + expect(Array.isArray(entry.story)).toBe(true); + }); + + it('leaves non-blocks fields untouched', () => { + const model = { + attributes: { title: { type: 'string' }, body: { type: 'json' }, count: { type: 'integer' } }, + }; + const entry = { title: 'Post', body: { a: 1 }, count: 3 }; + + const result = serializeBlocksFields(entry, model, () => undefined); + + expect(result).toEqual(entry); + }); + + it('skips null and undefined blocks values', () => { + const model = { attributes: { story: { type: 'blocks' } } }; + + expect(serializeBlocksFields({ story: null }, model, () => undefined)).toEqual({ story: null }); + expect(serializeBlocksFields({}, model, () => undefined)).toEqual({}); + }); + + it('serializes blocks nested inside a repeatable component', () => { + const getModel = (uid: string) => + uid === 'basic.section' ? { attributes: { body: { type: 'blocks' } } } : undefined; + const model = { + attributes: { sections: { type: 'component', component: 'basic.section', repeatable: true } }, + }; + const entry = { + sections: [ + { id: 1, body: storyAst }, + { id: 2, body: storyAst }, + ], + }; + + const result = serializeBlocksFields(entry, model, getModel); + + expect(typeof result.sections[0].body).toBe('string'); + expect(JSON.parse(result.sections[1].body)).toEqual(storyAst); + }); + + it('serializes blocks nested inside a dynamic zone', () => { + const getModel = (uid: string) => + uid === 'blocks.rich' ? { attributes: { content: { type: 'blocks' } } } : undefined; + const model = { attributes: { body: { type: 'dynamiczone', components: ['blocks.rich'] } } }; + const entry = { body: [{ id: 7, __component: 'blocks.rich', content: storyAst }] }; + + const result = serializeBlocksFields(entry, model, getModel); + + expect(typeof result.body[0].content).toBe('string'); + expect(JSON.parse(result.body[0].content)).toEqual(storyAst); + }); + }); + + describe('parseBlocksFieldValue', () => { + it('parses a serialized blocks string back into the AST', () => { + expect(parseBlocksFieldValue(JSON.stringify(storyAst))).toEqual(storyAst); + }); + + it('round-trips identically', () => { + const serialized = JSON.stringify(storyAst); + expect(parseBlocksFieldValue(serialized)).toEqual(storyAst); + }); + + it('passes through an already-parsed array', () => { + expect(parseBlocksFieldValue(storyAst)).toEqual(storyAst); + }); + + it('returns undefined for malformed JSON', () => { + expect(parseBlocksFieldValue('{not json')).toBeUndefined(); + }); + + it('returns undefined when the parsed value is not a blocks array', () => { + expect(parseBlocksFieldValue(JSON.stringify({ type: 'paragraph' }))).toBeUndefined(); + expect(parseBlocksFieldValue('42')).toBeUndefined(); + }); + + it('returns undefined for non-string, non-array input', () => { + expect(parseBlocksFieldValue(null)).toBeUndefined(); + expect(parseBlocksFieldValue(undefined)).toBeUndefined(); + expect(parseBlocksFieldValue(5)).toBeUndefined(); + }); + }); +}); diff --git a/server/src/utils/__tests__/model-utils.test.ts b/server/src/utils/__tests__/model-utils.test.ts index bd5bedb..7a8d32b 100644 --- a/server/src/utils/__tests__/model-utils.test.ts +++ b/server/src/utils/__tests__/model-utils.test.ts @@ -1,4 +1,12 @@ -import { getAttribute, isComponent, isDynamicZone, isRepeatable, isRelation, findModel } from '../model-utils'; +import { + getAttribute, + isComponent, + isDynamicZone, + isRepeatable, + isRelation, + isBlocks, + findModel, +} from '../model-utils'; describe('model-utils', () => { describe('getAttribute', () => { @@ -84,6 +92,18 @@ describe('model-utils', () => { }); }); + describe('isBlocks', () => { + it('should return true for blocks attributes', () => { + expect(isBlocks({ type: 'blocks' })).toBe(true); + }); + + it('should return false for non-blocks attributes', () => { + expect(isBlocks({ type: 'string' })).toBe(false); + expect(isBlocks({ type: 'richtext' })).toBe(false); + expect(isBlocks({ type: 'json' })).toBe(false); + }); + }); + describe('findModel', () => { const models = [ { uid: 'api::article.article', attributes: {} }, diff --git a/server/src/utils/blocks-field-serialization.ts b/server/src/utils/blocks-field-serialization.ts new file mode 100644 index 0000000..031920a --- /dev/null +++ b/server/src/utils/blocks-field-serialization.ts @@ -0,0 +1,68 @@ +import { cloneDeep, isPlainObject } from 'lodash-es'; + +export const BLOCKS_ATTRIBUTE_TYPE = 'blocks'; + +type GetModel = (uid: string) => { attributes?: Record } | undefined; + +/** + * A Blocks value is an AST, not translatable text. We serialize the whole field into a single + * string so the transfer pipeline treats one blocks field as exactly one key ("one story = one + * key") instead of exploding it into per-node leaves. Nested blocks (inside components / dynamic + * zones) are handled by walking the schema. The source entry is not mutated. + */ +export const serializeBlocksFields = (entry: any, model: any, getModel: GetModel): any => { + const cloned = cloneDeep(entry); + walkAndSerialize(cloned, model, getModel); + return cloned; +}; + +const walkAndSerialize = (value: any, model: any, getModel: GetModel): void => { + if (!value || !model?.attributes) { + return; + } + + for (const [attributeName, attribute] of Object.entries(model.attributes)) { + const fieldValue = value[attributeName]; + if (fieldValue === undefined || fieldValue === null) { + continue; + } + + if (attribute.type === BLOCKS_ATTRIBUTE_TYPE) { + value[attributeName] = JSON.stringify(fieldValue); + } else if (attribute.type === 'component') { + const componentModel = getModel(attribute.component); + if (attribute.repeatable && Array.isArray(fieldValue)) { + fieldValue.forEach((item) => walkAndSerialize(item, componentModel, getModel)); + } else if (isPlainObject(fieldValue)) { + walkAndSerialize(fieldValue, componentModel, getModel); + } + } else if (attribute.type === 'dynamiczone' && Array.isArray(fieldValue)) { + fieldValue.forEach((item) => { + if (item?.__component) { + walkAndSerialize(item, getModel(item.__component), getModel); + } + }); + } + } +}; + +/** + * Inverse of the upload serialization: turn a downloaded blocks key back into the AST that gets + * stored (and therefore rendered) as-is. Returns undefined when the payload is not a valid blocks + * AST (always a top-level array) so callers can skip the write instead of corrupting the field. + */ +export const parseBlocksFieldValue = (value: any): any[] | undefined => { + if (Array.isArray(value)) { + return value; + } + if (typeof value !== 'string') { + return undefined; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; diff --git a/server/src/utils/model-utils.ts b/server/src/utils/model-utils.ts index 69cbc22..66d5b87 100644 --- a/server/src/utils/model-utils.ts +++ b/server/src/utils/model-utils.ts @@ -25,9 +25,13 @@ const isRelation = (attributeObj) => { return attributeObj.type === 'relation' && attributeObj.target !== 'plugin::upload.file'; }; +const isBlocks = (attributeObj) => { + return attributeObj.type === 'blocks'; +}; + const findModel = (models, uid) => { const model = models.find((model) => model.uid === uid); return model; }; -export { getAttribute, isComponent, isDynamicZone, isRepeatable, isRelation, findModel }; +export { getAttribute, isComponent, isDynamicZone, isRepeatable, isRelation, isBlocks, findModel }; diff --git a/server/src/utils/parsed-localazy-entry-to-create-entry.ts b/server/src/utils/parsed-localazy-entry-to-create-entry.ts index 65cf660..e6db82c 100644 --- a/server/src/utils/parsed-localazy-entry-to-create-entry.ts +++ b/server/src/utils/parsed-localazy-entry-to-create-entry.ts @@ -1,6 +1,7 @@ import { set, get } from 'lodash-es'; import { resetArrayKeysDeep } from './reset-array-keys-deep'; -import { getAttribute, isComponent, isDynamicZone, isRepeatable, findModel } from './model-utils'; +import { getAttribute, isComponent, isDynamicZone, isRepeatable, isBlocks, findModel } from './model-utils'; +import { parseBlocksFieldValue } from './blocks-field-serialization'; /** * Items positioning is done by the `toCreateEntry` function @@ -172,7 +173,14 @@ export const parsedLocalazyEntryToCreateEntry = ( newPrefixBase = `${dzParamKey}.${baseEntryDZIndex}`; newPrefix = `${newPrefixBase}.${objectKey}`; } - set(createEntry, newPrefix, value); + // Blocks fields travel as a single serialized string ("one story = one key"); rebuild the + // AST and store it verbatim (what's stored is what's rendered). Skip malformed payloads + // rather than write an invalid document. + const valueToSet = isBlocks(attribute) ? parseBlocksFieldValue(value) : value; + if (valueToSet === undefined) { + return; + } + set(createEntry, newPrefix, valueToSet); if (component) { const componentKeyToSet = `${newPrefixBase}.__component`; From b05ddcf5c5b38b6a80fa24dc2f0c99c188421b5b Mon Sep 17 00:00:00 2001 From: Agent System Date: Fri, 31 Jul 2026 13:43:31 +0000 Subject: [PATCH 2/2] feat: blocks transfer as per-node text segments with structure overlay 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 --- .../localazy-transfer-upload-service.ts | 6 +- .../blocks-field-serialization.test.ts | 119 --------- .../__tests__/blocks-to-translatable.test.ts | 231 ++++++++++++++++++ ...alazy-entry-to-create-entry.blocks.test.ts | 72 ++++++ .../src/utils/blocks-field-serialization.ts | 68 ------ server/src/utils/blocks-to-translatable.ts | 117 +++++++++ server/src/utils/model-utils.ts | 3 +- .../parsed-localazy-entry-to-create-entry.ts | 11 +- 8 files changed, 431 insertions(+), 196 deletions(-) delete mode 100644 server/src/utils/__tests__/blocks-field-serialization.test.ts create mode 100644 server/src/utils/__tests__/blocks-to-translatable.test.ts create mode 100644 server/src/utils/__tests__/parsed-localazy-entry-to-create-entry.blocks.test.ts delete mode 100644 server/src/utils/blocks-field-serialization.ts create mode 100644 server/src/utils/blocks-to-translatable.ts diff --git a/server/src/services/localazy-transfer-upload-service.ts b/server/src/services/localazy-transfer-upload-service.ts index b00a2ec..264f989 100644 --- a/server/src/services/localazy-transfer-upload-service.ts +++ b/server/src/services/localazy-transfer-upload-service.ts @@ -1,6 +1,6 @@ import type { Core } from '@strapi/strapi'; import { flattenObject } from '../utils/flatten-object'; -import { serializeBlocksFields } from '../utils/blocks-field-serialization'; +import { projectBlocksFields } from '../utils/blocks-to-translatable'; import { getCollectionsNames, findSetupModelByCollectionName, @@ -166,11 +166,11 @@ const LocalazyTransferUploadService = ({ strapi }: { strapi: Core.Strapi }) => ( entries = omitDeep(entries, ['locale', 'createdAt', 'updatedAt', 'publishedAt']); entries.forEach((entry) => { - const serializedEntry = serializeBlocksFields(entry, strapi.getModel(modelUid), (uid) => + const projectedEntry = projectBlocksFields(entry, strapi.getModel(modelUid), (uid) => strapi.getModel(uid as any) ); const flatten = flattenObject({ - [modelUid]: serializedEntry, + [modelUid]: projectedEntry, }); // get only enabled fields; "__component" will be filtered out inside of the function const pickedFlatten = pickEntries(flatten, pickPathsWithUid); diff --git a/server/src/utils/__tests__/blocks-field-serialization.test.ts b/server/src/utils/__tests__/blocks-field-serialization.test.ts deleted file mode 100644 index ef03686..0000000 --- a/server/src/utils/__tests__/blocks-field-serialization.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { serializeBlocksFields, parseBlocksFieldValue } from '../blocks-field-serialization'; - -const storyAst = [ - { - type: 'heading', - level: 2, - children: [{ type: 'text', text: 'Title', bold: true }], - }, - { - type: 'paragraph', - children: [ - { type: 'text', text: 'Hello ' }, - { type: 'link', url: 'https://localazy.com', children: [{ type: 'text', text: 'world' }] }, - ], - }, -]; - -describe('blocks-field-serialization', () => { - describe('serializeBlocksFields', () => { - it('serializes a top-level blocks field into a single JSON string', () => { - const model = { attributes: { title: { type: 'string' }, story: { type: 'blocks' } } }; - const entry = { title: 'Post', story: storyAst }; - - const result = serializeBlocksFields(entry, model, () => undefined); - - expect(result.title).toBe('Post'); - expect(typeof result.story).toBe('string'); - expect(JSON.parse(result.story)).toEqual(storyAst); - }); - - it('does not mutate the source entry', () => { - const model = { attributes: { story: { type: 'blocks' } } }; - const entry = { story: storyAst }; - - serializeBlocksFields(entry, model, () => undefined); - - expect(Array.isArray(entry.story)).toBe(true); - }); - - it('leaves non-blocks fields untouched', () => { - const model = { - attributes: { title: { type: 'string' }, body: { type: 'json' }, count: { type: 'integer' } }, - }; - const entry = { title: 'Post', body: { a: 1 }, count: 3 }; - - const result = serializeBlocksFields(entry, model, () => undefined); - - expect(result).toEqual(entry); - }); - - it('skips null and undefined blocks values', () => { - const model = { attributes: { story: { type: 'blocks' } } }; - - expect(serializeBlocksFields({ story: null }, model, () => undefined)).toEqual({ story: null }); - expect(serializeBlocksFields({}, model, () => undefined)).toEqual({}); - }); - - it('serializes blocks nested inside a repeatable component', () => { - const getModel = (uid: string) => - uid === 'basic.section' ? { attributes: { body: { type: 'blocks' } } } : undefined; - const model = { - attributes: { sections: { type: 'component', component: 'basic.section', repeatable: true } }, - }; - const entry = { - sections: [ - { id: 1, body: storyAst }, - { id: 2, body: storyAst }, - ], - }; - - const result = serializeBlocksFields(entry, model, getModel); - - expect(typeof result.sections[0].body).toBe('string'); - expect(JSON.parse(result.sections[1].body)).toEqual(storyAst); - }); - - it('serializes blocks nested inside a dynamic zone', () => { - const getModel = (uid: string) => - uid === 'blocks.rich' ? { attributes: { content: { type: 'blocks' } } } : undefined; - const model = { attributes: { body: { type: 'dynamiczone', components: ['blocks.rich'] } } }; - const entry = { body: [{ id: 7, __component: 'blocks.rich', content: storyAst }] }; - - const result = serializeBlocksFields(entry, model, getModel); - - expect(typeof result.body[0].content).toBe('string'); - expect(JSON.parse(result.body[0].content)).toEqual(storyAst); - }); - }); - - describe('parseBlocksFieldValue', () => { - it('parses a serialized blocks string back into the AST', () => { - expect(parseBlocksFieldValue(JSON.stringify(storyAst))).toEqual(storyAst); - }); - - it('round-trips identically', () => { - const serialized = JSON.stringify(storyAst); - expect(parseBlocksFieldValue(serialized)).toEqual(storyAst); - }); - - it('passes through an already-parsed array', () => { - expect(parseBlocksFieldValue(storyAst)).toEqual(storyAst); - }); - - it('returns undefined for malformed JSON', () => { - expect(parseBlocksFieldValue('{not json')).toBeUndefined(); - }); - - it('returns undefined when the parsed value is not a blocks array', () => { - expect(parseBlocksFieldValue(JSON.stringify({ type: 'paragraph' }))).toBeUndefined(); - expect(parseBlocksFieldValue('42')).toBeUndefined(); - }); - - it('returns undefined for non-string, non-array input', () => { - expect(parseBlocksFieldValue(null)).toBeUndefined(); - expect(parseBlocksFieldValue(undefined)).toBeUndefined(); - expect(parseBlocksFieldValue(5)).toBeUndefined(); - }); - }); -}); diff --git a/server/src/utils/__tests__/blocks-to-translatable.test.ts b/server/src/utils/__tests__/blocks-to-translatable.test.ts new file mode 100644 index 0000000..39b0b13 --- /dev/null +++ b/server/src/utils/__tests__/blocks-to-translatable.test.ts @@ -0,0 +1,231 @@ +import { cloneDeep } from 'lodash-es'; +import { projectBlocksFields, projectBlocksValue, overlayBlocksTranslation } from '../blocks-to-translatable'; + +// A source AST that exercises every structural case the AC calls out: +// marks, links, headings, nested lists, images, quotes and code blocks. +const sourceAst = [ + { + type: 'heading', + level: 2, + children: [{ text: 'Title', bold: true }], + }, + { + type: 'paragraph', + children: [ + { text: 'Hello ' }, + { text: 'brave', italic: true }, + { type: 'link', url: 'https://localazy.com', children: [{ text: 'world' }] }, + ], + }, + { + type: 'list', + format: 'unordered', + children: [ + { type: 'list-item', children: [{ text: 'first' }] }, + { + type: 'list-item', + children: [ + { text: 'second' }, + { + type: 'list', + format: 'ordered', + children: [{ type: 'list-item', children: [{ text: 'nested' }] }], + }, + ], + }, + ], + }, + { + type: 'image', + image: { url: 'https://cdn.example/pic.png', alternativeText: 'pic', documentId: 'abc' }, + children: [{ text: '' }], + }, + { + type: 'quote', + children: [{ text: 'quoted' }], + }, + { + type: 'code', + children: [{ text: 'const a = 1;' }], + }, +]; + +// Turn a projected skeleton into a "translated" one by mapping every text leaf through `fn`. +const translateSkeleton = (nodes: any, fn: (text: string) => string): any => + nodes.map((node: any) => { + const next: any = {}; + if (typeof node.text === 'string') { + next.text = fn(node.text); + } + if (Array.isArray(node.children)) { + next.children = translateSkeleton(node.children, fn); + } + return next; + }); + +const collectTexts = (nodes: any[]): string[] => + nodes.flatMap((node) => [ + ...(typeof node.text === 'string' ? [node.text] : []), + ...(Array.isArray(node.children) ? collectTexts(node.children) : []), + ]); + +describe('blocks-to-translatable', () => { + describe('projectBlocksValue', () => { + it('keeps only text leaves and their nesting, dropping all structure and formatting', () => { + const skeleton = projectBlocksValue(sourceAst); + const serialized = JSON.stringify(skeleton); + + // structure / formatting must NOT leak into the translatable payload + expect(serialized).not.toContain('bold'); + expect(serialized).not.toContain('italic'); + expect(serialized).not.toContain('heading'); + expect(serialized).not.toContain('url'); + expect(serialized).not.toContain('localazy.com'); + expect(serialized).not.toContain('level'); + + // every translatable text leaf survives, in order + expect(collectTexts(skeleton)).toEqual([ + 'Title', + 'Hello ', + 'brave', + 'world', + 'first', + 'second', + 'nested', + '', + 'quoted', + 'const a = 1;', + ]); + }); + + it('projects a structural node with no text into an empty node (no segment)', () => { + const skeleton = projectBlocksValue([{ type: 'thematic-break' }]); + expect(skeleton).toEqual([{}]); + }); + + it('passes through non-array input untouched', () => { + expect(projectBlocksValue(null)).toBeNull(); + }); + }); + + describe('projectBlocksFields', () => { + it('projects a top-level blocks field and leaves other fields untouched', () => { + const model = { attributes: { title: { type: 'string' }, story: { type: 'blocks' } } }; + const entry = { title: 'Post', story: sourceAst }; + + const result = projectBlocksFields(entry, model, () => undefined); + + expect(result.title).toBe('Post'); + expect(JSON.stringify(result.story)).not.toContain('bold'); + expect(collectTexts(result.story)).toContain('Title'); + }); + + it('does not mutate the source entry', () => { + const model = { attributes: { story: { type: 'blocks' } } }; + const entry = { story: sourceAst }; + const before = cloneDeep(entry); + + projectBlocksFields(entry, model, () => undefined); + + expect(entry).toEqual(before); + }); + + it('skips null and undefined blocks values', () => { + const model = { attributes: { story: { type: 'blocks' } } }; + expect(projectBlocksFields({ story: null }, model, () => undefined)).toEqual({ story: null }); + expect(projectBlocksFields({}, model, () => undefined)).toEqual({}); + }); + + it('projects blocks nested inside a repeatable component', () => { + const getModel = (uid: string) => + uid === 'basic.section' ? { attributes: { body: { type: 'blocks' } } } : undefined; + const model = { + attributes: { sections: { type: 'component', component: 'basic.section', repeatable: true } }, + }; + const entry = { sections: [{ id: 1, body: sourceAst }] }; + + const result = projectBlocksFields(entry, model, getModel); + + expect(JSON.stringify(result.sections[0].body)).not.toContain('bold'); + expect(collectTexts(result.sections[0].body)).toContain('Title'); + }); + + it('projects blocks nested inside a dynamic zone', () => { + const getModel = (uid: string) => + uid === 'blocks.rich' ? { attributes: { content: { type: 'blocks' } } } : undefined; + const model = { attributes: { body: { type: 'dynamiczone', components: ['blocks.rich'] } } }; + const entry = { body: [{ id: 7, __component: 'blocks.rich', content: sourceAst }] }; + + const result = projectBlocksFields(entry, model, getModel); + + expect(JSON.stringify(result.body[0].content)).not.toContain('url'); + expect(collectTexts(result.body[0].content)).toContain('world'); + }); + }); + + describe('overlayBlocksTranslation', () => { + it('round-trips byte-identically when the translation equals the source', () => { + const skeleton = projectBlocksValue(sourceAst); + const rebuilt = overlayBlocksTranslation(sourceAst, skeleton); + expect(rebuilt).toEqual(sourceAst); + }); + + it('injects translated text while preserving marks, links, headings, lists, images, quotes and code', () => { + const skeleton = projectBlocksValue(sourceAst); + const translated = translateSkeleton(skeleton, (t) => (t === '' ? '' : `[${t}]`)); + + const rebuilt = overlayBlocksTranslation(sourceAst, translated)!; + + // structure preserved + expect(rebuilt[0]).toMatchObject({ type: 'heading', level: 2 }); + expect(rebuilt[0].children[0]).toEqual({ text: '[Title]', bold: true }); + expect(rebuilt[1].children[1]).toEqual({ text: '[brave]', italic: true }); + expect(rebuilt[1].children[2]).toMatchObject({ type: 'link', url: 'https://localazy.com' }); + expect(rebuilt[1].children[2].children[0]).toEqual({ text: '[world]' }); + // nested list text translated, nesting preserved + expect(rebuilt[2].children[1].children[1].children[0].children[0]).toEqual({ text: '[nested]' }); + // quote + code translated + expect(rebuilt[4].children[0]).toEqual({ text: '[quoted]' }); + expect(rebuilt[5].children[0]).toEqual({ text: '[const a = 1;]' }); + }); + + it('carries the source image node (media + empty text leaf) through untouched', () => { + const skeleton = projectBlocksValue(sourceAst); + const rebuilt = overlayBlocksTranslation(sourceAst, skeleton)!; + + expect(rebuilt[3]).toEqual(sourceAst[3]); + }); + + it('falls back to source text for text leaves with no translation', () => { + // only the first block is translated; the rest of the skeleton is empty + const partial = { '0': { children: [{ text: 'Titre' }] } }; + + const rebuilt = overlayBlocksTranslation(sourceAst, partial)!; + + expect(rebuilt[0].children[0]).toEqual({ text: 'Titre', bold: true }); + // untranslated paragraph keeps the source text + expect(rebuilt[1].children[0]).toEqual({ text: 'Hello ' }); + }); + + it('accepts a skeleton keyed by numeric string positions (the download shape)', () => { + const translated = { + '0': { children: [{ text: 'Titre' }] }, + '1': { + children: [{ text: 'Bonjour ' }, { text: 'courageux' }, { children: [{ text: 'monde' }] }], + }, + }; + + const rebuilt = overlayBlocksTranslation(sourceAst, translated)!; + + expect(rebuilt[0].children[0]).toEqual({ text: 'Titre', bold: true }); + expect(rebuilt[1].children[1]).toEqual({ text: 'courageux', italic: true }); + expect(rebuilt[1].children[2].children[0]).toEqual({ text: 'monde' }); + }); + + it('returns undefined when there is no source AST to overlay onto', () => { + expect(overlayBlocksTranslation(undefined, { '0': { text: 'x' } })).toBeUndefined(); + expect(overlayBlocksTranslation('not-an-array', {})).toBeUndefined(); + expect(overlayBlocksTranslation(null, {})).toBeUndefined(); + }); + }); +}); diff --git a/server/src/utils/__tests__/parsed-localazy-entry-to-create-entry.blocks.test.ts b/server/src/utils/__tests__/parsed-localazy-entry-to-create-entry.blocks.test.ts new file mode 100644 index 0000000..158193d --- /dev/null +++ b/server/src/utils/__tests__/parsed-localazy-entry-to-create-entry.blocks.test.ts @@ -0,0 +1,72 @@ +import { parsedLocalazyEntryToCreateEntry } from '../parsed-localazy-entry-to-create-entry'; + +const uid = 'api::article.article'; + +const sourceStory = [ + { type: 'heading', level: 2, children: [{ text: 'Title', bold: true }] }, + { + type: 'paragraph', + children: [{ text: 'Hello ' }, { type: 'link', url: 'https://localazy.com', children: [{ text: 'world' }] }], + }, +]; + +const models = [ + { + uid, + attributes: { + title: { type: 'string' }, + story: { type: 'blocks' }, + }, + }, +]; + +describe('parsedLocalazyEntryToCreateEntry — blocks download wiring', () => { + it('overlays the translated text skeleton onto the source AST, keeping structure and formatting', () => { + // Shape produced by the download JSON accumulation: numeric-string positions, text-only leaves. + const translatedModel = { + title: 'Titre', + story: { + '0': { children: [{ text: 'Titre du bloc' }] }, + '1': { + children: [{ text: 'Bonjour ' }, { children: [{ text: 'monde' }] }], + }, + }, + }; + const baseEntry = { title: 'Post', story: sourceStory }; + + const { createEntry } = parsedLocalazyEntryToCreateEntry(models, translatedModel, baseEntry, uid, 'fr'); + + expect(createEntry.title).toBe('Titre'); + expect(createEntry.story).toEqual([ + { type: 'heading', level: 2, children: [{ text: 'Titre du bloc', bold: true }] }, + { + type: 'paragraph', + children: [{ text: 'Bonjour ' }, { type: 'link', url: 'https://localazy.com', children: [{ text: 'monde' }] }], + }, + ]); + expect(createEntry.locale).toBe('fr'); + }); + + it('round-trips byte-identically when the translation equals the source', () => { + const translatedModel = { + story: { + '0': { children: [{ text: 'Title' }] }, + '1': { children: [{ text: 'Hello ' }, { children: [{ text: 'world' }] }] }, + }, + }; + const baseEntry = { story: sourceStory }; + + const { createEntry } = parsedLocalazyEntryToCreateEntry(models, translatedModel, baseEntry, uid); + + expect(createEntry.story).toEqual(sourceStory); + }); + + it('skips a blocks field with no source AST rather than writing an invalid document', () => { + const translatedModel = { story: { '0': { children: [{ text: 'Titre' }] } } }; + const baseEntry = { title: 'Post' }; // no source story + + const { createEntry } = parsedLocalazyEntryToCreateEntry(models, translatedModel, baseEntry, uid); + + expect(createEntry.story).toBeUndefined(); + }); +}); diff --git a/server/src/utils/blocks-field-serialization.ts b/server/src/utils/blocks-field-serialization.ts deleted file mode 100644 index 031920a..0000000 --- a/server/src/utils/blocks-field-serialization.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { cloneDeep, isPlainObject } from 'lodash-es'; - -export const BLOCKS_ATTRIBUTE_TYPE = 'blocks'; - -type GetModel = (uid: string) => { attributes?: Record } | undefined; - -/** - * A Blocks value is an AST, not translatable text. We serialize the whole field into a single - * string so the transfer pipeline treats one blocks field as exactly one key ("one story = one - * key") instead of exploding it into per-node leaves. Nested blocks (inside components / dynamic - * zones) are handled by walking the schema. The source entry is not mutated. - */ -export const serializeBlocksFields = (entry: any, model: any, getModel: GetModel): any => { - const cloned = cloneDeep(entry); - walkAndSerialize(cloned, model, getModel); - return cloned; -}; - -const walkAndSerialize = (value: any, model: any, getModel: GetModel): void => { - if (!value || !model?.attributes) { - return; - } - - for (const [attributeName, attribute] of Object.entries(model.attributes)) { - const fieldValue = value[attributeName]; - if (fieldValue === undefined || fieldValue === null) { - continue; - } - - if (attribute.type === BLOCKS_ATTRIBUTE_TYPE) { - value[attributeName] = JSON.stringify(fieldValue); - } else if (attribute.type === 'component') { - const componentModel = getModel(attribute.component); - if (attribute.repeatable && Array.isArray(fieldValue)) { - fieldValue.forEach((item) => walkAndSerialize(item, componentModel, getModel)); - } else if (isPlainObject(fieldValue)) { - walkAndSerialize(fieldValue, componentModel, getModel); - } - } else if (attribute.type === 'dynamiczone' && Array.isArray(fieldValue)) { - fieldValue.forEach((item) => { - if (item?.__component) { - walkAndSerialize(item, getModel(item.__component), getModel); - } - }); - } - } -}; - -/** - * Inverse of the upload serialization: turn a downloaded blocks key back into the AST that gets - * stored (and therefore rendered) as-is. Returns undefined when the payload is not a valid blocks - * AST (always a top-level array) so callers can skip the write instead of corrupting the field. - */ -export const parseBlocksFieldValue = (value: any): any[] | undefined => { - if (Array.isArray(value)) { - return value; - } - if (typeof value !== 'string') { - return undefined; - } - - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : undefined; - } catch { - return undefined; - } -}; diff --git a/server/src/utils/blocks-to-translatable.ts b/server/src/utils/blocks-to-translatable.ts new file mode 100644 index 0000000..b7925a3 --- /dev/null +++ b/server/src/utils/blocks-to-translatable.ts @@ -0,0 +1,117 @@ +import { cloneDeep, get, isPlainObject } from 'lodash-es'; + +export const BLOCKS_ATTRIBUTE_TYPE = 'blocks'; + +type GetModel = (uid: string) => { attributes?: Record } | undefined; + +/** + * Upload side. A Blocks value is a rich-text AST: text lives in `text` leaves, everything else + * (block types, headings, lists, links, marks, images, quotes, code blocks) is structure. We + * project each blocks field down to a skeleton that keeps only the translatable text leaves in + * their original positions, so the transfer pipeline emits ONE segment per text node with the + * formatting carried as structure — never as literal markup inside the string. The dropped + * structure is restored from the source AST on download (see `overlayBlocksTranslation`). Blocks + * nested inside components / dynamic zones are reached by walking the schema. The source entry is + * not mutated. + */ +export const projectBlocksFields = (entry: any, model: any, getModel: GetModel): any => { + const cloned = cloneDeep(entry); + walkAndProject(cloned, model, getModel); + return cloned; +}; + +const walkAndProject = (value: any, model: any, getModel: GetModel): void => { + if (!value || !model?.attributes) { + return; + } + + for (const [attributeName, attribute] of Object.entries(model.attributes)) { + const fieldValue = value[attributeName]; + if (fieldValue === undefined || fieldValue === null) { + continue; + } + + if (attribute.type === BLOCKS_ATTRIBUTE_TYPE) { + value[attributeName] = projectBlocksValue(fieldValue); + } else if (attribute.type === 'component') { + const componentModel = getModel(attribute.component); + if (attribute.repeatable && Array.isArray(fieldValue)) { + fieldValue.forEach((item) => walkAndProject(item, componentModel, getModel)); + } else if (isPlainObject(fieldValue)) { + walkAndProject(fieldValue, componentModel, getModel); + } + } else if (attribute.type === 'dynamiczone' && Array.isArray(fieldValue)) { + fieldValue.forEach((item) => { + if (item?.__component) { + walkAndProject(item, getModel(item.__component), getModel); + } + }); + } + } +}; + +/** + * Reduce a blocks AST to a text-only skeleton: every node keeps only its `text` leaf and/or its + * (recursively projected) `children`. Structural nodes without text (e.g. images, dividers) become + * empty and contribute no segment. Positions are preserved so download can overlay by path. + */ +export const projectBlocksValue = (nodes: any): any => { + if (!Array.isArray(nodes)) { + return nodes; + } + return nodes.map(projectNode); +}; + +const projectNode = (node: any): any => { + if (!isPlainObject(node)) { + return {}; + } + const projected: Record = {}; + if (typeof node.text === 'string') { + projected.text = node.text; + } + if (Array.isArray(node.children)) { + projected.children = node.children.map(projectNode); + } + return projected; +}; + +/** + * Download side. Rebuild a full blocks AST for the target locale by cloning the source-locale AST + * and overlaying the translated text onto each text leaf at the matching position. All structure + * and formatting come from the source, so the result is always a document Strapi accepts, a + * translation identical to the source round-trips byte-identically, and text nodes left + * untranslated fall back to the source text. `translated` is the per-node text skeleton + * reconstructed from the downloaded keys (an array, or an object keyed by numeric position). + * + * Returns `undefined` when there is no source AST to overlay onto (nothing safe to write), so the + * caller can skip the field instead of corrupting it. + */ +export const overlayBlocksTranslation = (sourceAst: any, translated: any): any[] | undefined => { + if (!Array.isArray(sourceAst)) { + return undefined; + } + return sourceAst.map((node, index) => overlayNode(node, get(translated, [String(index)]))); +}; + +const overlayNode = (node: any, translatedNode: any): any => { + if (!isPlainObject(node)) { + return cloneDeep(node); + } + + if (typeof node.text === 'string') { + const translatedText = isPlainObject(translatedNode) ? (translatedNode as any).text : undefined; + return typeof translatedText === 'string' ? { ...node, text: translatedText } : cloneDeep(node); + } + + if (Array.isArray(node.children)) { + return { + ...node, + children: node.children.map((child: any, index: number) => + overlayNode(child, translatedNode ? get(translatedNode, ['children', String(index)]) : undefined) + ), + }; + } + + return cloneDeep(node); +}; diff --git a/server/src/utils/model-utils.ts b/server/src/utils/model-utils.ts index 66d5b87..7059104 100644 --- a/server/src/utils/model-utils.ts +++ b/server/src/utils/model-utils.ts @@ -1,4 +1,5 @@ import { isEmpty } from 'lodash-es'; +import { BLOCKS_ATTRIBUTE_TYPE } from './blocks-to-translatable'; const getAttribute = (model, attribute) => { if (isEmpty(model)) { @@ -26,7 +27,7 @@ const isRelation = (attributeObj) => { }; const isBlocks = (attributeObj) => { - return attributeObj.type === 'blocks'; + return attributeObj.type === BLOCKS_ATTRIBUTE_TYPE; }; const findModel = (models, uid) => { diff --git a/server/src/utils/parsed-localazy-entry-to-create-entry.ts b/server/src/utils/parsed-localazy-entry-to-create-entry.ts index e6db82c..0e8bc39 100644 --- a/server/src/utils/parsed-localazy-entry-to-create-entry.ts +++ b/server/src/utils/parsed-localazy-entry-to-create-entry.ts @@ -1,7 +1,7 @@ import { set, get } from 'lodash-es'; import { resetArrayKeysDeep } from './reset-array-keys-deep'; import { getAttribute, isComponent, isDynamicZone, isRepeatable, isBlocks, findModel } from './model-utils'; -import { parseBlocksFieldValue } from './blocks-field-serialization'; +import { overlayBlocksTranslation } from './blocks-to-translatable'; /** * Items positioning is done by the `toCreateEntry` function @@ -173,10 +173,11 @@ export const parsedLocalazyEntryToCreateEntry = ( newPrefixBase = `${dzParamKey}.${baseEntryDZIndex}`; newPrefix = `${newPrefixBase}.${objectKey}`; } - // Blocks fields travel as a single serialized string ("one story = one key"); rebuild the - // AST and store it verbatim (what's stored is what's rendered). Skip malformed payloads - // rather than write an invalid document. - const valueToSet = isBlocks(attribute) ? parseBlocksFieldValue(value) : value; + // A blocks field arrives as a text-only skeleton (one segment per text node). Rebuild the + // full AST by overlaying the translated text onto the source-locale AST, so structure and + // formatting are restored from the source. Skip when there is no source AST to overlay + // onto rather than write an invalid document. + const valueToSet = isBlocks(attribute) ? overlayBlocksTranslation(get(baseEntry, newPrefix), value) : value; if (valueToSet === undefined) { return; }