Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
193 changes: 193 additions & 0 deletions packages/utils/src/translation-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,3 +968,196 @@ 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');
});

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?');
});

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);
});
105 changes: 95 additions & 10 deletions packages/utils/src/translation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -316,17 +367,51 @@ export function getTranslateableFields(
export function applyTranslation(
content: BuilderContent,
translation: TranslateableFields,
locale: string
locale: string,
sourceLocaleId?: string
) {
let { blocks, blocksString, state, ...customFields } = content.data!;

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));
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));
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 });
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
});

Expand Down
Loading