From d85274d125bcdf42b1b7a3de5815268733c846d0 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Wed, 25 Jun 2025 14:33:59 -0700 Subject: [PATCH 1/8] new method to validate the html translated --- scripts/utils/Translator/ChatGPTTranslator.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/scripts/utils/Translator/ChatGPTTranslator.ts b/scripts/utils/Translator/ChatGPTTranslator.ts index d0ecf07f2768..98aa6901eb31 100644 --- a/scripts/utils/Translator/ChatGPTTranslator.ts +++ b/scripts/utils/Translator/ChatGPTTranslator.ts @@ -35,7 +35,7 @@ class ChatGPTTranslator extends Translator { userPrompt: text, }); - if (this.validateTemplatePlaceholders(text, result)) { + if (this.validateTemplatePlaceholders(text, result) && this.validateTemplateHTML(text, result)) { if (attempt > 0) { console.log(`🙃 Translation succeeded after ${attempt + 1} attempts`); } @@ -43,7 +43,7 @@ class ChatGPTTranslator extends Translator { return result; } - console.warn(`⚠️ Translation for "${text}" failed placeholder validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`); + console.warn(`⚠️ Translation for "${text}" failed validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`); if (attempt === ChatGPTTranslator.MAX_RETRIES) { console.error(`❌ Final attempt failed placeholder validation. Falling back to original.`); @@ -76,6 +76,47 @@ class ChatGPTTranslator extends Translator { const translatedSpans = extractPlaceholders(translated); return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans); } + + /** + * Validate that the HTML structure is the same before and after translation. + */ + private validateTemplateHTML(original: string, translated: string): boolean { + // Attributes that are allowed to be translated + const TRANSLATABLE_ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']; + + const parseHTMLStructure = (s: string) => { + const tags = Array.from(s.matchAll(/<([^>]+)>/g)); + return tags.map((match) => { + const tagContent = match[1]; + const tagName = tagContent.split(/\s/).at(0)?.toLowerCase(); + + // Extract attributes, excluding translatable ones + const attributes: string[] = []; + const attrMatches = Array.from(tagContent.matchAll(/(\w+)(?:=["']([^"']*)["'])?/g)); + + for (const attrMatch of attrMatches) { + const attrName = attrMatch[1].toLowerCase(); + const attrValue = attrMatch[2] || ''; + + // Only include non-translatable attributes in comparison + if (!TRANSLATABLE_ATTRIBUTES.includes(attrName)) { + attributes.push(`${attrName}="${attrValue}"`); + } + } + + return { + tagName, + attributes: attributes.sort(), + }; + }); + }; + + const originalStructure = parseHTMLStructure(original); + const translatedStructure = parseHTMLStructure(translated); + + // Compare structures (tag names and non-translatable attributes) + return JSON.stringify(originalStructure) === JSON.stringify(translatedStructure); + } } export default ChatGPTTranslator; From f91fe10180d8d1600898f638eb17ba3da5e3f6ea Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Wed, 25 Jun 2025 14:34:10 -0700 Subject: [PATCH 2/8] add test for html validation --- tests/unit/ChatGPTTranslatorTest.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/ChatGPTTranslatorTest.ts b/tests/unit/ChatGPTTranslatorTest.ts index cfbc2671a53c..487915912316 100644 --- a/tests/unit/ChatGPTTranslatorTest.ts +++ b/tests/unit/ChatGPTTranslatorTest.ts @@ -18,6 +18,10 @@ describe('ChatGPTTranslator.performTranslation', () => { const validTranslation = '[it] Hello ${name}!'; const invalidTranslation = '[it] Hello name!'; // missing ${...} + const originalHTML = 'A dog'; + const validHTMLTranslation = 'Un chien'; + const invalidHTMLTranslation = 'Un chien'; + let translator: ChatGPTTranslator; beforeEach(() => { @@ -50,4 +54,16 @@ describe('ChatGPTTranslator.performTranslation', () => { expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(ChatGPTTranslator.MAX_RETRIES + 1); expect(result).toBe(original); }); + + it('retries if translated HTML has incorrect attributes, then succeeds', async () => { + // First attempt returns invalid HTML format, second returns valid + (MockedOpenAIUtils.prototype.promptChatCompletions as jest.Mock).mockResolvedValueOnce(invalidHTMLTranslation).mockResolvedValueOnce(validHTMLTranslation); + + // @ts-expect-error TS2445 + const result = await translator.performTranslation(targetLang, originalHTML); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(2); + expect(result).toBe(validHTMLTranslation); + }); }); From c872e569294ecbd82e1c7c7a17e4c9b530c9ed3e Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Wed, 25 Jun 2025 14:51:45 -0700 Subject: [PATCH 3/8] add the lang to the string --- tests/unit/ChatGPTTranslatorTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/ChatGPTTranslatorTest.ts b/tests/unit/ChatGPTTranslatorTest.ts index 487915912316..42dcc3830256 100644 --- a/tests/unit/ChatGPTTranslatorTest.ts +++ b/tests/unit/ChatGPTTranslatorTest.ts @@ -19,8 +19,8 @@ describe('ChatGPTTranslator.performTranslation', () => { const invalidTranslation = '[it] Hello name!'; // missing ${...} const originalHTML = 'A dog'; - const validHTMLTranslation = 'Un chien'; - const invalidHTMLTranslation = 'Un chien'; + const validHTMLTranslation = '[it] Un chien'; + const invalidHTMLTranslation = '[it] Un chien'; // different src let translator: ChatGPTTranslator; From 5f73972b37db4f4e2bd1d6ee783021f6bd190654 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Thu, 26 Jun 2025 11:08:13 -0700 Subject: [PATCH 4/8] add missing strings --- cspell.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cspell.json b/cspell.json index d338c9913b28..f69062bbdf53 100644 --- a/cspell.json +++ b/cspell.json @@ -98,6 +98,7 @@ "Charleson", "Checkmark", "checkmarked", + "chien", "Chronos", "citi", "clawback", @@ -136,6 +137,7 @@ "deeplinks", "delegators", "delish", + "describedby", "Deutsch", "devportal", "DFOLLY", @@ -295,6 +297,7 @@ "killall", "Kowalski", "Krasoń", + "labelledby", "Lagertha", "laggy", "lastiPhoneLogin", From b85117d9c5dc4c6e87c069aec8b3a41025384192 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Fri, 27 Jun 2025 14:33:48 -0700 Subject: [PATCH 5/8] move the functions to translator class --- scripts/utils/Translator/ChatGPTTranslator.ts | 54 ------------------- scripts/utils/Translator/Translator.ts | 54 +++++++++++++++++++ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/scripts/utils/Translator/ChatGPTTranslator.ts b/scripts/utils/Translator/ChatGPTTranslator.ts index 98aa6901eb31..7535e084c321 100644 --- a/scripts/utils/Translator/ChatGPTTranslator.ts +++ b/scripts/utils/Translator/ChatGPTTranslator.ts @@ -63,60 +63,6 @@ class ChatGPTTranslator extends Translator { // Should never hit this, but fallback just in case return text; } - - /** - * Validate that placeholders are all present and unchanged before and after translation. - */ - private validateTemplatePlaceholders(original: string, translated: string): boolean { - const extractPlaceholders = (s: string) => - Array.from(s.matchAll(/\$\{[^}]*}/g)) - .map((m) => m[0]) - .sort(); - const originalSpans = extractPlaceholders(original); - const translatedSpans = extractPlaceholders(translated); - return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans); - } - - /** - * Validate that the HTML structure is the same before and after translation. - */ - private validateTemplateHTML(original: string, translated: string): boolean { - // Attributes that are allowed to be translated - const TRANSLATABLE_ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']; - - const parseHTMLStructure = (s: string) => { - const tags = Array.from(s.matchAll(/<([^>]+)>/g)); - return tags.map((match) => { - const tagContent = match[1]; - const tagName = tagContent.split(/\s/).at(0)?.toLowerCase(); - - // Extract attributes, excluding translatable ones - const attributes: string[] = []; - const attrMatches = Array.from(tagContent.matchAll(/(\w+)(?:=["']([^"']*)["'])?/g)); - - for (const attrMatch of attrMatches) { - const attrName = attrMatch[1].toLowerCase(); - const attrValue = attrMatch[2] || ''; - - // Only include non-translatable attributes in comparison - if (!TRANSLATABLE_ATTRIBUTES.includes(attrName)) { - attributes.push(`${attrName}="${attrValue}"`); - } - } - - return { - tagName, - attributes: attributes.sort(), - }; - }); - }; - - const originalStructure = parseHTMLStructure(original); - const translatedStructure = parseHTMLStructure(translated); - - // Compare structures (tag names and non-translatable attributes) - return JSON.stringify(originalStructure) === JSON.stringify(translatedStructure); - } } export default ChatGPTTranslator; diff --git a/scripts/utils/Translator/Translator.ts b/scripts/utils/Translator/Translator.ts index ace23f8a6c5e..f0270b0aa1e4 100644 --- a/scripts/utils/Translator/Translator.ts +++ b/scripts/utils/Translator/Translator.ts @@ -33,6 +33,60 @@ abstract class Translator { private trimForLogs(text: string) { return `${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`; } + + /** + * Validate that placeholders are all present and unchanged before and after translation. + */ + public validateTemplatePlaceholders(original: string, translated: string): boolean { + const extractPlaceholders = (s: string) => + Array.from(s.matchAll(/\$\{[^}]*}/g)) + .map((m) => m[0]) + .sort(); + const originalSpans = extractPlaceholders(original); + const translatedSpans = extractPlaceholders(translated); + return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans); + } + + /** + * Validate that the HTML structure is the same before and after translation. + */ + public validateTemplateHTML(original: string, translated: string): boolean { + // Attributes that are allowed to be translated + const TRANSLATABLE_ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']; + + const parseHTMLStructure = (s: string) => { + const tags = Array.from(s.matchAll(/<([^>]+)>/g)); + return tags.map((match) => { + const tagContent = match[1]; + const tagName = tagContent.split(/\s/).at(0)?.toLowerCase(); + + // Extract attributes, excluding translatable ones + const attributes: string[] = []; + const attrMatches = Array.from(tagContent.matchAll(/(\w+)(?:=["']([^"']*)["'])?/g)); + + for (const attrMatch of attrMatches) { + const attrName = attrMatch[1].toLowerCase(); + const attrValue = attrMatch[2] || ''; + + // Only include non-translatable attributes in comparison + if (!TRANSLATABLE_ATTRIBUTES.includes(attrName)) { + attributes.push(`${attrName}="${attrValue}"`); + } + } + + return { + tagName, + attributes: attributes.sort(), + }; + }); + }; + + const originalStructure = parseHTMLStructure(original); + const translatedStructure = parseHTMLStructure(translated); + + // Compare structures (tag names and non-translatable attributes) + return JSON.stringify(originalStructure) === JSON.stringify(translatedStructure); + } } export default Translator; From 3e83c13186b7658be8380e93806095a3944afdb4 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Fri, 27 Jun 2025 14:54:45 -0700 Subject: [PATCH 6/8] use htmlparser instead the regex --- scripts/utils/Translator/Translator.ts | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/scripts/utils/Translator/Translator.ts b/scripts/utils/Translator/Translator.ts index f0270b0aa1e4..f1ab15f06643 100644 --- a/scripts/utils/Translator/Translator.ts +++ b/scripts/utils/Translator/Translator.ts @@ -1,3 +1,4 @@ +import {DomUtils, parseDocument} from 'htmlparser2'; import type {TranslationTargetLocale} from '@src/CONST/LOCALES'; /** @@ -55,23 +56,21 @@ abstract class Translator { const TRANSLATABLE_ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']; const parseHTMLStructure = (s: string) => { - const tags = Array.from(s.matchAll(/<([^>]+)>/g)); - return tags.map((match) => { - const tagContent = match[1]; - const tagName = tagContent.split(/\s/).at(0)?.toLowerCase(); + const doc = parseDocument(s); + const elements = DomUtils.getElementsByTagName(() => true, doc, true); + + return elements.map((element) => { + const tagName = element.name.toLowerCase(); // Extract attributes, excluding translatable ones const attributes: string[] = []; - const attrMatches = Array.from(tagContent.matchAll(/(\w+)(?:=["']([^"']*)["'])?/g)); - - for (const attrMatch of attrMatches) { - const attrName = attrMatch[1].toLowerCase(); - const attrValue = attrMatch[2] || ''; - - // Only include non-translatable attributes in comparison - if (!TRANSLATABLE_ATTRIBUTES.includes(attrName)) { - attributes.push(`${attrName}="${attrValue}"`); - } + if (element.attribs) { + Object.entries(element.attribs).forEach(([attrName, attrValue]) => { + const normalizedAttrName = attrName.toLowerCase(); + if (!TRANSLATABLE_ATTRIBUTES.includes(normalizedAttrName)) { + attributes.push(`${normalizedAttrName}="${attrValue ?? ''}"`); + } + }) } return { From 791192bf35ba4086b7096ebe29ec87d8e2b892ef Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Fri, 27 Jun 2025 14:59:47 -0700 Subject: [PATCH 7/8] fix prettier --- scripts/utils/Translator/Translator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/utils/Translator/Translator.ts b/scripts/utils/Translator/Translator.ts index f1ab15f06643..362560805288 100644 --- a/scripts/utils/Translator/Translator.ts +++ b/scripts/utils/Translator/Translator.ts @@ -70,7 +70,7 @@ abstract class Translator { if (!TRANSLATABLE_ATTRIBUTES.includes(normalizedAttrName)) { attributes.push(`${normalizedAttrName}="${attrValue ?? ''}"`); } - }) + }); } return { From 6a24488bab2bdff5fc06878a29bf1b7aa0820367 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Fri, 4 Jul 2025 00:55:40 +0700 Subject: [PATCH 8/8] use Set and plain for loop --- scripts/utils/Translator/Translator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/utils/Translator/Translator.ts b/scripts/utils/Translator/Translator.ts index 362560805288..ff2f6263882d 100644 --- a/scripts/utils/Translator/Translator.ts +++ b/scripts/utils/Translator/Translator.ts @@ -53,7 +53,7 @@ abstract class Translator { */ public validateTemplateHTML(original: string, translated: string): boolean { // Attributes that are allowed to be translated - const TRANSLATABLE_ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']; + const TRANSLATABLE_ATTRIBUTES = new Set(['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']); const parseHTMLStructure = (s: string) => { const doc = parseDocument(s); @@ -65,12 +65,12 @@ abstract class Translator { // Extract attributes, excluding translatable ones const attributes: string[] = []; if (element.attribs) { - Object.entries(element.attribs).forEach(([attrName, attrValue]) => { + for (const [attrName, attrValue] of Object.entries(element.attribs ?? {})) { const normalizedAttrName = attrName.toLowerCase(); - if (!TRANSLATABLE_ATTRIBUTES.includes(normalizedAttrName)) { + if (!TRANSLATABLE_ATTRIBUTES.has(normalizedAttrName)) { attributes.push(`${normalizedAttrName}="${attrValue ?? ''}"`); } - }); + } } return {