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..264f989 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 { projectBlocksFields } from '../utils/blocks-to-translatable'; import { getCollectionsNames, findSetupModelByCollectionName, @@ -165,8 +166,11 @@ const LocalazyTransferUploadService = ({ strapi }: { strapi: Core.Strapi }) => ( entries = omitDeep(entries, ['locale', 'createdAt', 'updatedAt', 'publishedAt']); entries.forEach((entry) => { + const projectedEntry = projectBlocksFields(entry, strapi.getModel(modelUid), (uid) => + strapi.getModel(uid as any) + ); const flatten = flattenObject({ - [modelUid]: entry, + [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-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__/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/__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-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 69cbc22..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)) { @@ -25,9 +26,13 @@ const isRelation = (attributeObj) => { return attributeObj.type === 'relation' && attributeObj.target !== 'plugin::upload.file'; }; +const isBlocks = (attributeObj) => { + return attributeObj.type === BLOCKS_ATTRIBUTE_TYPE; +}; + 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..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,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 { overlayBlocksTranslation } from './blocks-to-translatable'; /** * Items positioning is done by the `toCreateEntry` function @@ -172,7 +173,15 @@ export const parsedLocalazyEntryToCreateEntry = ( newPrefixBase = `${dzParamKey}.${baseEntryDZIndex}`; newPrefix = `${newPrefixBase}.${objectKey}`; } - set(createEntry, newPrefix, 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; + } + set(createEntry, newPrefix, valueToSet); if (component) { const componentKeyToSet = `${newPrefixBase}.__component`;