From d85274d125bcdf42b1b7a3de5815268733c846d0 Mon Sep 17 00:00:00 2001
From: Mohammad Luthfi Fathur Rahman <luthfi@expensify.com>
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 <luthfi@expensify.com>
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 = '<img src="photo.jpg" alt="A dog" class="photo">';
+    const validHTMLTranslation = '<img src="photo.jpg" alt="Un chien" class="photo">';
+    const invalidHTMLTranslation = '<img src="different.jpg" alt="Un chien" class="photo">';
+
     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 <luthfi@expensify.com>
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 = '<img src="photo.jpg" alt="A dog" class="photo">';
-    const validHTMLTranslation = '<img src="photo.jpg" alt="Un chien" class="photo">';
-    const invalidHTMLTranslation = '<img src="different.jpg" alt="Un chien" class="photo">';
+    const validHTMLTranslation = '[it] <img src="photo.jpg" alt="Un chien" class="photo">';
+    const invalidHTMLTranslation = '[it] <img src="different.jpg" alt="Un chien" class="photo">'; // different src
 
     let translator: ChatGPTTranslator;
 

From 5f73972b37db4f4e2bd1d6ee783021f6bd190654 Mon Sep 17 00:00:00 2001
From: Mohammad Luthfi Fathur Rahman <luthfi@expensify.com>
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 <luthfi@expensify.com>
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 <luthfi@expensify.com>
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 <luthfi@expensify.com>
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 <luthfi@expensify.com>
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 {
