Skip to content
Open
Changes from 3 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
100 changes: 75 additions & 25 deletions packages/utils/src/translation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ function recordValue({
}
}

function extractStringLeaves(
value: any,
path: string,
results: TranslateableFields,
instructions: string
) {
if (typeof value === 'string') {
results[path] = { value, instructions };
} else if (value?.['@type'] === localizedType) {
extractStringLeaves(value.Default, path, results, instructions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested LocalizedValues always use Default, ignoring source locale

Low Severity

extractStringLeaves doesn't accept a sourceLocaleId parameter. When it encounters a nested LocalizedValue inside a list or object, it always extracts value.Default (line 106) instead of preferring value[sourceLocaleId]. This is inconsistent with recordValue (used for Symbols), which correctly checks sourceLocaleId first. If nested LocalizedValues have different text for the source locale vs Default, the wrong source text gets sent for translation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 07fdf6b. Configure here.

} else if (Array.isArray(value)) {
value.forEach((item, i) => extractStringLeaves(item, `${path}[${i}]`, results, instructions));
} else if (typeof value === 'object' && value !== null) {
Object.entries(value).forEach(([k, v]) =>
extractStringLeaves(v, `${path}.${k}`, results, instructions)
);
}
}

function resolveTranslation({
data,
basePath,
Expand Down Expand Up @@ -247,12 +266,16 @@ export function getTranslateableFields(
.filter(input => get(el.component?.options || {}, `${input}.@type`) === localizedType)
.forEach(inputKey => {
const inputValue = get(el.component?.options || {}, inputKey);
const valueToBeTranslated = inputValue?.[sourceLocaleId] || inputValue?.Default;
if (valueToBeTranslated) {
results[`blocks.${el.id}#${inputKey}`] = {
instructions: el.meta?.instructions || defaultInstructions,
value: valueToBeTranslated,
};
const valueToBeTranslated = inputValue?.[sourceLocaleId] ?? inputValue?.Default;
if (valueToBeTranslated === undefined || valueToBeTranslated === null) return;

const basePath = `blocks.${el.id}#${inputKey}`;
const instructions = el.meta?.instructions || defaultInstructions;

if (typeof valueToBeTranslated === 'string') {
results[basePath] = { instructions, value: valueToBeTranslated };
} else {
extractStringLeaves(valueToBeTranslated, basePath, results, instructions);
}
});
}
Expand All @@ -271,9 +294,10 @@ export function getTranslateableFields(
if (el && el.id && el.component?.name === 'Core:Button' && !isExcluded) {
const componentText = el.component.options?.text;
if (componentText) {
const textValue = typeof componentText === 'string'
? componentText
: componentText?.[sourceLocaleId] || componentText?.Default;
const textValue =
typeof componentText === 'string'
? componentText
: componentText?.[sourceLocaleId] || componentText?.Default;
if (textValue) {
results[`blocks.${el.id}#text`] = {
value: textValue,
Expand All @@ -283,7 +307,7 @@ export function getTranslateableFields(
}
}

if (el && el.id && el.component?.name === 'Symbol'&& !isExcluded) {
if (el && el.id && el.component?.name === 'Symbol' && !isExcluded) {
const symbolInputs = Object.entries(el.component?.options?.symbol?.data) || [];
if (symbolInputs.length) {
const basePath = `blocks.${el.id}.symbolInput`;
Expand Down Expand Up @@ -473,7 +497,7 @@ export function applyTranslation(
},
},
};

this.update(updatedElement);
}

Expand All @@ -482,27 +506,53 @@ export function applyTranslation(
// there's a localized input
const keys = el.meta?.localizedTextInputs as string[];
let options = el.component.options;
let didUpdate = false;

keys.forEach(key => {
if (translation[`blocks.${el.id}#${key}`]) {
const exactKey = `blocks.${el.id}#${key}`;
if (translation[exactKey]) {
// String LocalizedValue — current behavior
set(options, key, {
...(get(options, key) || {}),
[locale]: unescapeStringOrObject(translation[`blocks.${el.id}#${key}`].value),
});

this.update({
...el,
meta: {
...el.meta,
translated: true,
},
component: {
...el.component,
options,
},
[locale]: unescapeStringOrObject(translation[exactKey].value),
});
didUpdate = true;
} else {
// List/object LocalizedValue — reconstruct locale value from leaf paths
const prefix = `${exactKey}[`;
const prefixDot = `${exactKey}.`;
const subEntries = Object.entries(translation).filter(
([k]) => k.startsWith(prefix) || k.startsWith(prefixDot)
);
if (subEntries.length) {
const inputValue = get(options, key);
const localeValue = JSON.parse(JSON.stringify(inputValue?.Default ?? []));
subEntries.forEach(([k, entry]) => {
const subPath = k.slice(exactKey.length);
set(localeValue, subPath, unescapeStringOrObject(entry.value));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leading dot in subPath corrupts object translation placement

Medium Severity

When extractStringLeaves processes object properties, it builds paths like basePath.key. During reconstruction, subPath = k.slice(exactKey.length) produces a string starting with . (e.g., .key1). Lodash's set interprets a leading dot as path ['', 'key1'], so it creates localeValue['']['key1'] instead of localeValue['key1']. This silently places translated values under a spurious empty-string key, corrupting the output for object-type LocalizedValues in custom components. Array-type values (paths starting with [) are unaffected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 07fdf6b. Configure here.

});
set(options, key, {
...inputValue,
[locale]: localeValue,
});
didUpdate = true;
}
}
});

if (didUpdate) {
this.update({
...el,
meta: {
...el.meta,
translated: true,
},
component: {
...el.component,
options,
},
});
}
}
});
content.data = {
Expand Down
Loading