From 3fe5e6dfdb16ba6fc7278ac982c14351b0cdb8d2 Mon Sep 17 00:00:00 2001 From: Aishwarya Parab Date: Tue, 9 Jun 2026 14:10:28 +0530 Subject: [PATCH 1/2] fix nested fields being skipped by Smartling --- .../translation-helpers.test.ts.snap | 15 +-- .../utils/src/translation-helpers.test.ts | 119 ++++++++++++++++++ packages/utils/src/translation-helpers.ts | 89 +++++++++++-- 3 files changed, 205 insertions(+), 18 deletions(-) diff --git a/packages/utils/src/__snapshots__/translation-helpers.test.ts.snap b/packages/utils/src/__snapshots__/translation-helpers.test.ts.snap index 9cce4fe1ddd..3341da8711e 100644 --- a/packages/utils/src/__snapshots__/translation-helpers.test.ts.snap +++ b/packages/utils/src/__snapshots__/translation-helpers.test.ts.snap @@ -393,16 +393,13 @@ Object { "instructions": "Button with plain text", "value": "Cute Baby", }, - "metadata.seo": Object { + "metadata.seo#menuItems#0#menuName": Object { "instructions": "Visit https://builder.io/fiddle/... for more details", - "value": Object { - "menuItems": Array [ - Object { - "menuName": "en menu name", - }, - ], - "name": "en name in subfield", - }, + "value": "en menu name", + }, + "metadata.seo#name": Object { + "instructions": "Visit https://builder.io/fiddle/... for more details", + "value": "en name in subfield", }, "metadata.title": Object { "instructions": "Visit https://builder.io/fiddle/... for more details", diff --git a/packages/utils/src/translation-helpers.test.ts b/packages/utils/src/translation-helpers.test.ts index f660659885a..b075f7691a2 100644 --- a/packages/utils/src/translation-helpers.test.ts +++ b/packages/utils/src/translation-helpers.test.ts @@ -968,3 +968,122 @@ test('applyTranslation for symbol with localized array containing nested localiz expect(symbolData.uspList.Default[0].headline.Default).toBe('Accept card payments'); expect(symbolData.uspList.Default[1].headline.Default).toBe('Organise your items'); }); + +// --------------------------------------------------------------------------- +// Customer bug: nested fields inside a LocalizedValue array (custom metadata) +// --------------------------------------------------------------------------- +// Scenario: a data model has a `faqs` Object field (cannot be localized) that +// contains an `items` List field. The `items` field itself has Localize enabled, +// so Builder stores the whole array as one LocalizedValue. Each item has +// `question` and `answer` plain-string sub-fields. +// Before the fix: getTranslateableFields produced one entry with an array value +// (not a string) which Smartling could not translate. After the fix: individual +// string entries are produced for each nested field. +// --------------------------------------------------------------------------- + +test('getTranslateableFields extracts nested string fields from a LocalizedValue array (FAQ scenario)', () => { + const content: BuilderContent = { + data: { + title: { + '@type': localizedType, + 'en-US': 'Excel Expert', + Default: 'Excel Expert', + }, + faqs: { + // `items` is a LocalizedValue whose payload is an array of plain objects. + // This is what Builder stores when you enable Localize on an array-type field. + items: { + '@type': localizedType, + Default: [ + { + question: 'What does an Excel expert actually do?', + answer: 'An Excel expert is a data analyst who leverages advanced features of Microsoft Excel.', + }, + { + question: 'What tools does an Excel expert use?', + answer: 'Excel experts use pivot tables, VLOOKUP, macros, and VBA scripting.', + }, + ], + }, + }, + }, + }; + + const result = getTranslateableFields(content, 'en-US', ''); + + // Top-level localized string field still works + expect(result['metadata.title']).toEqual({ value: 'Excel Expert', instructions: '' }); + + // Each nested string field inside the LocalizedValue array must be a separate entry + expect(result['metadata.faqs#items#0#question']).toEqual({ + value: 'What does an Excel expert actually do?', + instructions: '', + }); + expect(result['metadata.faqs#items#0#answer']).toEqual({ + value: 'An Excel expert is a data analyst who leverages advanced features of Microsoft Excel.', + instructions: '', + }); + expect(result['metadata.faqs#items#1#question']).toEqual({ + value: 'What tools does an Excel expert use?', + instructions: '', + }); + expect(result['metadata.faqs#items#1#answer']).toEqual({ + value: 'Excel experts use pivot tables, VLOOKUP, macros, and VBA scripting.', + instructions: '', + }); + + // The broken old key (whole array as value) must NOT exist + expect(result['metadata.faqs#items']).toBeUndefined(); +}); + +test('applyTranslation writes translated strings back into the correct locale slot for LocalizedValue array (FAQ scenario)', () => { + const content: BuilderContent = { + data: { + title: { + '@type': localizedType, + 'en-US': 'Excel Expert', + Default: 'Excel Expert', + }, + faqs: { + items: { + '@type': localizedType, + Default: [ + { + question: 'What does an Excel expert actually do?', + answer: 'An Excel expert is a data analyst.', + }, + { + question: 'What tools does an Excel expert use?', + answer: 'Excel experts use pivot tables and VLOOKUP.', + }, + ], + }, + }, + }, + }; + + const germanTranslations = { + 'metadata.title': { value: 'Excel-Experte' }, + 'metadata.faqs#items#0#question': { value: 'Was macht ein Excel-Experte eigentlich?' }, + 'metadata.faqs#items#0#answer': { value: 'Ein Excel-Experte ist ein Datenanalyst.' }, + 'metadata.faqs#items#1#question': { value: 'Welche Tools verwendet ein Excel-Experte?' }, + 'metadata.faqs#items#1#answer': { value: 'Excel-Experten verwenden Pivot-Tabellen und VLOOKUP.' }, + }; + + const result = applyTranslation(content, germanTranslations, 'de-DE'); + const data = result.data!; + + // Direct string LocalizedValue translation still works + expect((data.title as any)['de-DE']).toBe('Excel-Experte'); + + // The translated locale array must be set correctly + const localizedItems = (data.faqs as any).items; + expect(localizedItems['de-DE'][0].question).toBe('Was macht ein Excel-Experte eigentlich?'); + expect(localizedItems['de-DE'][0].answer).toBe('Ein Excel-Experte ist ein Datenanalyst.'); + expect(localizedItems['de-DE'][1].question).toBe('Welche Tools verwendet ein Excel-Experte?'); + expect(localizedItems['de-DE'][1].answer).toBe('Excel-Experten verwenden Pivot-Tabellen und VLOOKUP.'); + + // Default values must be preserved untouched + expect(localizedItems.Default[0].question).toBe('What does an Excel expert actually do?'); + expect(localizedItems.Default[1].question).toBe('What tools does an Excel expert use?'); +}); diff --git a/packages/utils/src/translation-helpers.ts b/packages/utils/src/translation-helpers.ts index e921d54af47..9de6add3fdb 100644 --- a/packages/utils/src/translation-helpers.ts +++ b/packages/utils/src/translation-helpers.ts @@ -195,6 +195,41 @@ function resolveTranslation({ } } +// Recursively walks an already-extracted LocalizedValue payload (array or object) +// and records individual string leaves as separate translation entries. +function extractNestedStrings( + value: any, + basePath: string, + results: TranslateableFields, + instructions: string, + sourceLocaleId: string +) { + if (typeof value === 'string') { + if (value) { + results[basePath] = { value, instructions }; + } + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + extractNestedStrings(item, `${basePath}#${index}`, results, instructions, sourceLocaleId); + }); + } else if (typeof value === 'object' && value !== null) { + if (value['@type'] === localizedType) { + // Nested LocalizedValue inside an outer LocalizedValue's payload + const nested = value[sourceLocaleId] || value.Default; + const nestedInstructions = value.meta?.instructions || instructions; + if (typeof nested === 'string' && nested) { + results[basePath] = { value: nested, instructions: nestedInstructions }; + } else if (nested !== null && nested !== undefined) { + extractNestedStrings(nested, basePath, results, nestedInstructions, sourceLocaleId); + } + } else { + Object.entries(value).forEach(([key, v]) => { + extractNestedStrings(v, `${basePath}#${key}`, results, instructions, sourceLocaleId); + }); + } + } +} + export function getTranslateableFields( content: BuilderContent, sourceLocaleId: string, @@ -205,14 +240,30 @@ export function getTranslateableFields( let { blocks, blocksString, state, ...customFields } = content.data!; // metadata [content's localized custom fields] - traverse(customFields).forEach(function (el) { - if (this.key && el && el['@type'] === localizedType) { - results[`metadata.${this.path.join('#')}`] = { - instructions: el.meta?.instructions || defaultInstructions, - value: el[sourceLocaleId] || el.Default, - }; + // Uses a custom recursive walk instead of traverse so we can stop at LocalizedValue + // boundaries and avoid generating duplicate / malformed path keys for nodes that live + // inside the LocalizedValue's internal Default / locale-keyed arrays. + function extractCustomField(value: any, path: string) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + value.forEach((item, index) => extractCustomField(item, `${path}#${index}`)); + return; } - }); + if (value['@type'] === localizedType) { + const instructions = value.meta?.instructions || defaultInstructions; + const extractedValue = value[sourceLocaleId] || value.Default; + if (typeof extractedValue === 'string') { + results[path] = { value: extractedValue, instructions }; + } else if (extractedValue !== null && extractedValue !== undefined) { + // Value is an array or object — extract individual string leaves so each + // one becomes its own Smartling translation unit. + extractNestedStrings(extractedValue, path, results, instructions, sourceLocaleId); + } + return; // Do not recurse into the LocalizedValue's internal structure. + } + Object.entries(value).forEach(([key, v]) => extractCustomField(v, `${path}#${key}`)); + } + Object.entries(customFields).forEach(([key, value]) => extractCustomField(value, `metadata.${key}`)); if (blocksString && typeof blocks === 'undefined') { blocks = JSON.parse(blocksString); @@ -322,11 +373,31 @@ export function applyTranslation( traverse(customFields).forEach(function (el) { const path = this.path?.join('#'); - if (translation[`metadata.${path}`]) { + const metaKey = `metadata.${path}`; + if (translation[metaKey]) { + // Direct translation — the node itself is a LocalizedValue with a string value. this.update({ ...el, - [locale]: unescapeStringOrObject(translation[`metadata.${path}`].value), + [locale]: unescapeStringOrObject(translation[metaKey].value), }); + } else if (el && typeof el === 'object' && el['@type'] === localizedType) { + // The LocalizedValue's payload was an array/object. Individual string leaves + // were extracted with compound keys like `metadata.faqs#items#0#question`. + // Collect all such keys, clone Default as the base, patch each leaf, then + // store the result under the target locale. + const prefix = `${metaKey}#`; + const compoundKeys = Object.keys(translation).filter(k => k.startsWith(prefix)); + if (compoundKeys.length > 0 && el.Default !== null && el.Default !== undefined) { + const localeValue = JSON.parse(JSON.stringify(el.Default)); + compoundKeys.forEach(key => { + const nestedPath = key.slice(prefix.length); + const segments = nestedPath + .split('#') + .map((s: string) => (/^\d+$/.test(s) ? parseInt(s, 10) : s)); + set(localeValue, segments, unescapeStringOrObject(translation[key].value)); + }); + this.update({ ...el, [locale]: localeValue }); + } } }); From d68fc77b2345d606a72a742e80915e7cabb72a4e Mon Sep 17 00:00:00 2001 From: Aishwarya Parab Date: Tue, 9 Jun 2026 17:40:45 +0530 Subject: [PATCH 2/2] support for other localized fields --- .../utils/src/translation-helpers.test.ts | 98 ++++++++++++++++--- packages/utils/src/translation-helpers.ts | 22 ++++- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/packages/utils/src/translation-helpers.test.ts b/packages/utils/src/translation-helpers.test.ts index b075f7691a2..084eca46fd9 100644 --- a/packages/utils/src/translation-helpers.test.ts +++ b/packages/utils/src/translation-helpers.test.ts @@ -969,18 +969,6 @@ test('applyTranslation for symbol with localized array containing nested localiz expect(symbolData.uspList.Default[1].headline.Default).toBe('Organise your items'); }); -// --------------------------------------------------------------------------- -// Customer bug: nested fields inside a LocalizedValue array (custom metadata) -// --------------------------------------------------------------------------- -// Scenario: a data model has a `faqs` Object field (cannot be localized) that -// contains an `items` List field. The `items` field itself has Localize enabled, -// so Builder stores the whole array as one LocalizedValue. Each item has -// `question` and `answer` plain-string sub-fields. -// Before the fix: getTranslateableFields produced one entry with an array value -// (not a string) which Smartling could not translate. After the fix: individual -// string entries are produced for each nested field. -// --------------------------------------------------------------------------- - test('getTranslateableFields extracts nested string fields from a LocalizedValue array (FAQ scenario)', () => { const content: BuilderContent = { data: { @@ -1087,3 +1075,89 @@ test('applyTranslation writes translated strings back into the correct locale sl expect(localizedItems.Default[0].question).toBe('What does an Excel expert actually do?'); expect(localizedItems.Default[1].question).toBe('What tools does an Excel expert use?'); }); + +test('applyTranslation preserves nested LocalizedValue structure when sub-fields are themselves LocalizedValues (double-localized scenario)', () => { + // Scenario: `items` is a LocalizedValue whose Default array contains items where + // `question` and `answer` are ALSO individually LocalizedValues. + // The fix must NOT replace those nested LocalizedValue objects with plain strings. + const content: BuilderContent = { + data: { + faqs: { + items: { + '@type': localizedType, + Default: [ + { + question: { '@type': localizedType, Default: 'Q1', 'en-US': 'Q1' }, + answer: { '@type': localizedType, Default: 'A1', 'en-US': 'A1' }, + }, + ], + }, + }, + }, + }; + + const germanTranslations = { + 'metadata.faqs#items#0#question': { value: 'German Q1' }, + 'metadata.faqs#items#0#answer': { value: 'German A1' }, + }; + + const result = applyTranslation(content, germanTranslations, 'de-DE'); + const localizedItems = (result.data!.faqs as any).items; + + // The locale-specific copy must preserve the nested LocalizedValue structure + expect(localizedItems['de-DE'][0].question['@type']).toBe(localizedType); + expect(localizedItems['de-DE'][0].question['de-DE']).toBe('German Q1'); + expect(localizedItems['de-DE'][0].question.Default).toBe('Q1'); + + expect(localizedItems['de-DE'][0].answer['@type']).toBe(localizedType); + expect(localizedItems['de-DE'][0].answer['de-DE']).toBe('German A1'); + expect(localizedItems['de-DE'][0].answer.Default).toBe('A1'); + + // Default array must be completely untouched + expect(localizedItems.Default[0].question['@type']).toBe(localizedType); + expect(localizedItems.Default[0].question.Default).toBe('Q1'); + expect(localizedItems.Default[0].question['de-DE']).toBeUndefined(); +}); + +test('applyTranslation uses sourceLocaleId as the base when it differs from Default', () => { + // When sourceLocaleId array has more items / extra fields than Default, + // the translated locale must be based on the sourceLocaleId structure, + // not the (potentially stale) Default. + const content: BuilderContent = { + data: { + faqs: { + items: { + '@type': localizedType, + Default: [ + { question: 'Default Q1', answer: 'Default A1' }, + ], + 'en-US': [ + { question: 'English Q1', answer: 'English A1' }, + { question: 'English Q2', answer: 'English A2' }, // extra item only in en-US + ], + }, + }, + }, + }; + + const germanTranslations = { + 'metadata.faqs#items#0#question': { value: 'German Q1' }, + 'metadata.faqs#items#0#answer': { value: 'German A1' }, + 'metadata.faqs#items#1#question': { value: 'German Q2' }, + 'metadata.faqs#items#1#answer': { value: 'German A2' }, + }; + + const result = applyTranslation(content, germanTranslations, 'de-DE', 'en-US'); + const localizedItems = (result.data!.faqs as any).items; + + // Both items must be present (sourced from en-US, not the 1-item Default) + expect(localizedItems['de-DE']).toHaveLength(2); + expect(localizedItems['de-DE'][0].question).toBe('German Q1'); + expect(localizedItems['de-DE'][0].answer).toBe('German A1'); + expect(localizedItems['de-DE'][1].question).toBe('German Q2'); + expect(localizedItems['de-DE'][1].answer).toBe('German A2'); + + // Default and en-US must be untouched + expect(localizedItems.Default).toHaveLength(1); + expect(localizedItems['en-US']).toHaveLength(2); +}); diff --git a/packages/utils/src/translation-helpers.ts b/packages/utils/src/translation-helpers.ts index 9de6add3fdb..c9447624028 100644 --- a/packages/utils/src/translation-helpers.ts +++ b/packages/utils/src/translation-helpers.ts @@ -367,7 +367,8 @@ export function getTranslateableFields( export function applyTranslation( content: BuilderContent, translation: TranslateableFields, - locale: string + locale: string, + sourceLocaleId?: string ) { let { blocks, blocksString, state, ...customFields } = content.data!; @@ -387,14 +388,27 @@ export function applyTranslation( // store the result under the target locale. const prefix = `${metaKey}#`; const compoundKeys = Object.keys(translation).filter(k => k.startsWith(prefix)); - if (compoundKeys.length > 0 && el.Default !== null && el.Default !== undefined) { - const localeValue = JSON.parse(JSON.stringify(el.Default)); + const sourceValue = (sourceLocaleId && el[sourceLocaleId] != null) + ? el[sourceLocaleId] + : el.Default; + if (compoundKeys.length > 0 && sourceValue !== null && sourceValue !== undefined) { + const localeValue = JSON.parse(JSON.stringify(sourceValue)); compoundKeys.forEach(key => { const nestedPath = key.slice(prefix.length); const segments = nestedPath .split('#') .map((s: string) => (/^\d+$/.test(s) ? parseInt(s, 10) : s)); - set(localeValue, segments, unescapeStringOrObject(translation[key].value)); + const existingValue = get(localeValue, segments); + if (existingValue && typeof existingValue === 'object' && existingValue['@type'] === localizedType) { + // Nested LocalizedValue — preserve its structure and add the locale key + set(localeValue, segments, { + ...existingValue, + [locale]: unescapeStringOrObject(translation[key].value), + }); + } else { + // Plain string or primitive — replace directly + set(localeValue, segments, unescapeStringOrObject(translation[key].value)); + } }); this.update({ ...el, [locale]: localeValue }); }