‼️ becketto.com
blogshopify app rank tracker
← Back to Blog

How to automate translation for a React app

2026-02-21

Mostly auto translation using i18n

Beckett Oliphant

Beckett Oliphant

@ecombeckett

Shopify app developer working on Affilitrak and other projects.

How to automate translation for a React app

How to automate translation for a React app

The problem:

The "traditional" way of handling translations for web apps is inefficient and adds permanent complexity.

The "traditional" way is using keys and updating the translation in each locale file.
Example of traditional key: {tr("route.section.name")}

The solution:

Use plain English for keys and update translations with a script.

TL;DR

  • Write normal English in tr("...").
  • Run a script → it collects missing strings into toTranslate.json.
  • Paste into AI → get translations.
  • Run merge script → done.

If you just want the code, skip to the end of the article or visit the github repo

UPDATE: There is also a package that does this called Lingui

How it works:

Background from Beckett (skip if you don't care)


I put off translating my app for a long time because the traditional way of handling translations adds a lot of permanent overhead to my normal coding flow. Instead of writing normal text when making components, you would write a key like dashboard.intro.section and then somehow figure out what to write for it. Then have AI translate however many translation files which takes forever.

I wanted to see if there would be a way to translate my app without the permanent added complexity, so I started thinking about it.

The ideal translation workflow would:

  • Allow me to write in natural language and see it in my dev preview
  • Put all the untranslated keys into one toTranslate file easily (ideally with one command)
  • Require no other steps

So I came up with a system and I've been using it for the past six weeks and it has worked really well so I thought I'd share it.

Instructions

NOTE: This assumes a simple tr() helper backed by JSON locale files (e.g., en.json, fr.json)

Use plain English for keys

This makes it so you don't have to think in keys and you can see your text in your dev preview. Plus if you don't translate before deploying to prod, the text just shows up in English which is better than showing as a key. Note that AI often flips out if you ask it about using English as your translation keys but that's not rational. Yes, you'll have to retranslate if you change some text but that's fine, you would want to retranslate it anyways.

So instead of tr("dashboard.intro.section") you would do tr("Welcome to your dashboard")

Script find all the keys, adds them to toTranslate.json (updateTranslations.js)

Script finds all your translation keys with a grep search. I use {tr("")} for my keys so my script just gets all the content between them. There will be some edge cases like multi-lines, you just have to account for that in your script.

Then put all the keys in a single toTranslate.json file. It's a lot faster to have all the keys in one file so AI doesn't have to look through all your locale files individually. Plus then you can only have the untranslated keys in the toTranslate.json file. Less tokens for AI to search through means faster responses and less chance for error.

Translate with AI

Pass the toTranslate file into AI and say "please translate". Or if you want to get fancy with it, you can say:

"Please translate this based on the language, output in one blurb, don't truncate anything."

"But wait! Wouldn't your implementation put the translated keys in the toTranslate.json as well?"

Yes. And we obviously don't want that so you just need to account for it in your script.

Back to "Script find all the keys, adds them to toTranslate.json"

The logic I use to handle it is as follows:

For each key found in the codebase ->  
Check all locale files for key ->  
if it exists and is translated -> do nothing  
if it doesn't exist or is untranslated in any of the files -> add to toTranslate.json  
Finally -> if there's a key in any locale file that isn't in the list of keys found in the codebase, delete  

Important: The "if there's a key in any locale file that isn't in the list of keys found, delete" step is to auto-clean your locale files. If you remove or change a key in your codebase, you can safely delete it in your locale files.

Second script to merge translations into language files

mergeTranslations.js Make a script that goes through and updates the locale files with the new keys. Once that's finished, delete toTranslate.json and call first updateTranslations script again.

Tip: If you want to translate toTranslate in chunks because it's thousands of lines: copy the chunk of keys -> translate -> delete all other content in toTranslate.json and only paste your translated keys -> Run mergeTranslations script

The method described merges the keys you translated and updates toTranslate.json with only the new untranslated keys.

Bonus: Find most of the untranslated text in your app with a script

wrapExistingText.js

-> Find your most common components that display text (Example: <Text> <Button> <Badge>)
-> Script greps all the closing tags for those (```</Text> </Button> </Badge>```), assumes everything between closing tag and the next ">" is text that needs to be translated
-> Wrap all the text to be translated in translation tag ```{tr("")}```
-> Add translation import to top of page and define translation handler variable inside relevant function

Note: Putting the translation handler variable inside the right function is hard to get 100% right with a script but you can easily find places where it messed up by running a type check.

Code:

updateTranslations.js

#!/usr/bin/env node
/**
 * updateTranslations script - Extracts tr() calls and syncs translation files
 *
 * Usage:
 *   npm run update-translations
 *
 * This script:
 * 1. Scans all app files recursively for tr("...") calls
 * 2. Compares with existing en.json (canonical file)
 * 3. Removes orphaned keys from all translation files
 * 4. Adds new keys to all translation files (empty for non-en, identity for en)
 * 5. Generates app/translations/toTranslate.json for AI translation
 */

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

// Get directory paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const appDir = path.join(projectRoot, "app");
const translationsDir = path.join(appDir, "translations");

// Supported locales (add new ones here)
const SUPPORTED_LOCALES = [
  "en", // English
  "de", // German
  "fr", // French
  "es", // Spanish
  "sv", // Swedish
  "pt-br", // Portuguese (Brazil)
  "it", // Italian
  "nl", // Dutch
  "ja", // Japanese
  "ko", // Korean
  "zh-cn", // Chinese (Simplified)
  "zh-tw", // Chinese (Traditional)
  "tr", // Turkish
  "th", // Thai
  "pl", // Polish
  "ar", // Arabic
  "da", // Danish
  "fi", // Finnish
  "id", // Indonesian
  "ms", // Malay
];
const CANONICAL_LOCALE = "en";

/**
 * Extract tr() calls from a file with a simple parser.
 * Only matches string literals for the first argument.
 * Skips keys with newlines or template expressions.
 */
function extractTrCallsFromFile(filePath) {
  const content = fs.readFileSync(filePath, "utf-8");
  const keys = [];
  const trCallRegex = /\btr\s*\(/g;

  let match;
  while ((match = trCallRegex.exec(content)) !== null) {
    let index = match.index + match[0].length;

    // Skip whitespace
    while (index < content.length && /\s/.test(content[index])) {
      index += 1;
    }

    const quote = content[index];
    if (!quote || (quote !== '"' && quote !== "'" && quote !== "`")) {
      continue;
    }

    index += 1;
    let value = "";
    let hasNewline = false;
    let hasTemplateExpression = false;
    let escaped = false;

    while (index < content.length) {
      const char = content[index];

      if (escaped) {
        value += char;
        escaped = false;
        index += 1;
        continue;
      }

      if (char === "\\") {
        escaped = true;
        index += 1;
        continue;
      }

      if (quote === "`" && char === "$" && content[index + 1] === "{") {
        hasTemplateExpression = true;
      }

      if (char === quote) {
        break;
      }

      if (char === "\n") {
        hasNewline = true;
      }

      value += char;
      index += 1;
    }

    if (index >= content.length) {
      continue;
    }

    if (!value || hasNewline || hasTemplateExpression) {
      continue;
    }

    if (!keys.includes(value)) {
      keys.push(value);
    }
  }

  return keys;
}

/**
 * Recursively find all .ts, .tsx, .js, .jsx files in app/
 */
function findSourceFiles(dir) {
  const files = [];

  function scanDirectory(currentDir) {
    const entries = fs.readdirSync(currentDir);

    for (const entry of entries) {
      const fullPath = path.join(currentDir, entry);
      const stat = fs.statSync(fullPath);

      if (stat.isDirectory()) {
        // Skip node_modules, build, etc.
        if (!["node_modules", "build", ".git"].includes(entry)) {
          scanDirectory(fullPath);
        }
      } else if (stat.isFile()) {
        // Only include source files
        if (/\.(ts|tsx|js|jsx)$/.test(entry)) {
          files.push(fullPath);
        }
      }
    }
  }

  scanDirectory(dir);
  return files;
}

/**
 * Extract all unique tr() keys from the app
 */
function extractAllTrKeys() {
  const sourceFiles = findSourceFiles(appDir);
  const allKeys = [];

  console.log(`Scanning ${sourceFiles.length} source files...`);

  for (const filePath of sourceFiles) {
    const keys = extractTrCallsFromFile(filePath);
    if (keys.length > 0) {
      console.log(
        `  ${path.relative(projectRoot, filePath)}: ${keys.join(", ")}`
      );
      for (const key of keys) {
        if (!allKeys.includes(key)) {
          allKeys.push(key);
        }
      }
    }
  }

  return allKeys.sort(); // Keep consistent ordering
}

/**
 * Load existing translation file or return empty object
 */
function loadTranslationFile(locale) {
  const filePath = path.join(translationsDir, `${locale}.json`);

  try {
    const content = fs.readFileSync(filePath, "utf-8");
    return JSON.parse(content);
  } catch (error) {
    console.warn(`Warning: Could not load ${locale}.json, creating new file`);
    return {};
  }
}

/**
 * Save translation file with proper formatting
 */
function saveTranslationFile(locale, translations) {
  const filePath = path.join(translationsDir, `${locale}.json`);

  // Ensure directory exists
  fs.mkdirSync(translationsDir, { recursive: true });

  // Write with consistent formatting (2 spaces, sorted keys)
  const sortedTranslations = {};
  const sortedKeys = Object.keys(translations).sort();

  for (const key of sortedKeys) {
    sortedTranslations[key] = translations[key];
  }

  fs.writeFileSync(
    filePath,
    JSON.stringify(sortedTranslations, null, 2) + "\n"
  );
}

/**
 * Main script logic
 */
function main() {
  console.log("🔍 Extracting translation keys...\n");

  // Step 1: Extract all tr() keys from source code
  const extractedKeys = extractAllTrKeys();
  console.log(`\n✅ Found ${extractedKeys.length} unique tr() keys`);

  // Step 2: Load canonical file (en.json)
  const canonicalTranslations = loadTranslationFile(CANONICAL_LOCALE);
  const existingKeys = Object.keys(canonicalTranslations);

  // Step 3: Calculate diffs
  const removedKeys = existingKeys.filter(
    (key) => !extractedKeys.includes(key)
  );
  const newKeys = extractedKeys.filter((key) => !existingKeys.includes(key));

  console.log(`\n📊 Changes:`);
  console.log(`  - Removed: ${removedKeys.length} keys`);
  console.log(`  - New: ${newKeys.length} keys`);

  if (removedKeys.length > 0) {
    console.log(`\n🗑️  Removed keys: ${removedKeys.join(", ")}`);
  }

  if (newKeys.length > 0) {
    console.log(`\n➕ New keys: ${newKeys.join(", ")}`);
  }

  // Step 4: Update all translation files
  console.log(`\n🔄 Updating translation files...`);

  const toTranslate = {};

  for (const locale of SUPPORTED_LOCALES) {
    const currentTranslations = loadTranslationFile(locale);
    const updatedTranslations = {};

    // Keep existing translations for keys that still exist
    for (const key of extractedKeys) {
      if (existingKeys.includes(key)) {
        // Keep existing translation, but check if it's missing/empty for non-canonical locales
        const existingValue = currentTranslations[key];
        if (locale === CANONICAL_LOCALE) {
          updatedTranslations[key] = existingValue || key; // Identity mapping for canonical
        } else {
          updatedTranslations[key] = existingValue || "";
          // If translation is missing or empty, add to toTranslate
          if (!existingValue || existingValue.trim() === "") {
            if (!toTranslate[locale]) {
              toTranslate[locale] = {};
            }
            toTranslate[locale][key] = "";
          }
        }
      } else {
        // New key
        if (locale === CANONICAL_LOCALE) {
          // For canonical locale, use identity mapping
          updatedTranslations[key] = key;
        } else {
          // For other locales, leave empty (to be translated)
          updatedTranslations[key] = "";

          // Add to toTranslate
          if (!toTranslate[locale]) {
            toTranslate[locale] = {};
          }
          toTranslate[locale][key] = "";
        }
      }
    }

    saveTranslationFile(locale, updatedTranslations);
  }

  // Step 5: Generate toTranslate.json if there are new keys
  if (Object.keys(toTranslate).length > 0) {
    const toTranslatePath = path.join(translationsDir, "toTranslate.json");
    fs.writeFileSync(
      toTranslatePath,
      JSON.stringify(toTranslate, null, 2) + "\n"
    );
  } else {
    console.log(`\n All translation files are up to date!`);
  }
}

// Run the script
main();

mergeTranslations.js

#!/usr/bin/env node
/**
 * mergeTranslations script - Merges translated content from toTranslate.json back into locale files
 *
 * Usage:
 *   npm run merge-translations
 *
 * This script:
 * 1. Reads app/translations/toTranslate.json
 * 2. Merges the translations into the respective locale files
 * 3. Cleans up toTranslate.json
 */

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

// Get directory paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const translationsDir = path.join(projectRoot, "app", "translations");
const toTranslatePath = path.join(translationsDir, "toTranslate.json");

/**
 * Load translation file
 */
function loadTranslationFile(locale) {
  const filePath = path.join(translationsDir, `${locale}.json`);

  try {
    const content = fs.readFileSync(filePath, "utf-8");
    return JSON.parse(content);
  } catch (error) {
    throw new Error(`Could not load ${locale}.json: ${error}`);
  }
}

/**
 * Save translation file with proper formatting
 */
function saveTranslationFile(locale, translations) {
  const filePath = path.join(translationsDir, `${locale}.json`);

  // Write with consistent formatting (2 spaces, sorted keys)
  const sortedTranslations = {};
  const sortedKeys = Object.keys(translations).sort();

  for (const key of sortedKeys) {
    sortedTranslations[key] = translations[key];
  }

  fs.writeFileSync(
    filePath,
    JSON.stringify(sortedTranslations, null, 2) + "\n"
  );
}

/**
 * Main script logic
 */
function main() {
  console.log("🔄 Merging translations...\n");

  // Check if toTranslate.json exists
  if (!fs.existsSync(toTranslatePath)) {
    console.log("❌ No toTranslate.json found. Run updateTranslations first.");
    process.exit(1);
  }

  // Load toTranslate.json
  let toTranslateData;
  try {
    const content = fs.readFileSync(toTranslatePath, "utf-8");
    toTranslateData = JSON.parse(content);
  } catch (error) {
    console.error("❌ Error reading toTranslate.json:", error);
    process.exit(1);
  }

  console.log(
    `📄 Found translations for locales: ${Object.keys(toTranslateData).join(", ")}`
  );

  // Validate format and check for untranslated content
  let hasEmptyTranslations = false;
  const allEmptyTranslations = [];

  for (const [locale, translations] of Object.entries(toTranslateData)) {
    if (typeof translations !== "object" || translations === null) {
      console.error(
        `❌ Invalid format in toTranslate.json for locale '${locale}'`
      );
      process.exit(1);
    }

    // Check for empty translations
    const emptyTranslations = Object.entries(translations).filter(
      ([key, value]) => !value || value.trim() === ""
    );
    if (emptyTranslations.length > 0) {
      hasEmptyTranslations = true;
      allEmptyTranslations.push(
        ...emptyTranslations.map(([key]) => `${locale}: "${key}"`)
      );
    }
  }

  // Exit if there are untranslated items
  if (hasEmptyTranslations) {
    console.error(`❌ toTranslate.json hasn't been fully translated yet!`);
    console.error(`\n🚨 The following translations are still empty:`);
    for (const item of allEmptyTranslations) {
      console.error(`   - ${item}`);
    }
    console.error(
      `\n💡 Please translate all empty strings before running merge.`
    );
    process.exit(1);
  }

  // Merge translations into locale files
  let totalMerged = 0;

  for (const [locale, newTranslations] of Object.entries(toTranslateData)) {
    try {
      const currentTranslations = loadTranslationFile(locale);

      // Merge new translations (only overwrite if new value is not empty)
      let mergedCount = 0;
      for (const [key, value] of Object.entries(newTranslations)) {
        if (value && value.trim() !== "") {
          currentTranslations[key] = value;
          mergedCount++;
        }
      }

      saveTranslationFile(locale, currentTranslations);
      console.log(`✅ ${locale}.json: merged ${mergedCount} translations`);
      totalMerged += mergedCount;
    } catch (error) {
      console.error(`❌ Error merging translations for '${locale}':`, error);
    }
  }

  if (totalMerged > 0) {
    // Delete toTranslate.json (since all translations were applied successfully)
    fs.unlinkSync(toTranslatePath);

    console.log(`\n🎉 Successfully merged ${totalMerged} translations`);
    console.log(`🗑️  Deleted toTranslate.json (all translations applied)`);
  } else {
    console.log(
      `\n⚠️  No translations were merged (this shouldn't happen if validation passed)`
    );
  }
}

// Run the script
main();

wrapExistingText.js

#!/usr/bin/env node

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const appDir = path.join(projectRoot, "app");

const dryRun = process.argv.includes("--dry-run");

if (dryRun) {
  console.log("🏃 DRY RUN MODE: No files will be modified.\n");
}

function findInsertionIndex(content) {
  // Regex explanation:
  // 1. Optional export/default
  // 2. Component defined as function: (async )? function Name
  // 3. Component defined as const: const Name = (async )? ( or function
  //    This checks for '(' or 'function' after the equals to avoid matching regular constants like ALLOWED_DOMAIN
  const componentRegex =
    /(?:export\s+)?(?:default\s+)?(?:(?:async\s+)?function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*=\s*(?:async\s*)?(?:\(|function))/g;

  let match;
  while ((match = componentRegex.exec(content)) !== null) {
    const name = match[1] || match[2];
    // Skip common non-component exports in Remix
    if (
      [
        "loader",
        "action",
        "meta",
        "headers",
        "links",
        "ErrorBoundary",
      ].includes(name)
    )
      continue;

    // For 'const' matches, the match ends before the parameters.
    // For 'function' matches, we need to be careful.

    let idx = match.index + match[0].length;

    // Skip whitespace
    while (idx < content.length && /\s/.test(content[idx])) idx++;

    // Skip Parentheses (arguments) - This handles (props) or ({ prop })
    if (content[idx] === "(") {
      let depth = 1;
      idx++;
      while (idx < content.length && depth > 0) {
        if (content[idx] === "(") depth++;
        else if (content[idx] === ")") depth--;
        idx++;
      }
    }

    // Find next '{' which should be the function body start
    while (idx < content.length) {
      if (content[idx] === "{") {
        return idx + 1;
      }
      idx++;
    }
  }
  return -1;
}

function getAllFiles(dirPath, arrayOfFiles) {
  const files = fs.readdirSync(dirPath);
  arrayOfFiles = arrayOfFiles || [];

  files.forEach(function (file) {
    if (fs.statSync(dirPath + "/" + file).isDirectory()) {
      arrayOfFiles = getAllFiles(dirPath + "/" + file, arrayOfFiles);
    } else {
      if (file.endsWith(".tsx") || file.endsWith(".jsx")) {
        arrayOfFiles.push(path.join(dirPath, "/", file));
      }
    }
  });

  return arrayOfFiles;
}

const files = getAllFiles(appDir);
let modifiedFiles = [];
let errorCount = 0;

// Files/Directories to EXCLUDE for safety
const EXCLUDED_PATHS = [
  "app/root.jsx",
  "app/entry.client.tsx",
  "app/entry.server.jsx",
  "app/routes/auth.login",
  "app/emailTemplates",
  "contexts/I18nContext",
];

files.forEach((file) => {
  try {
    const relativeFilePath = path.relative(projectRoot, file);

    // Check exclusions
    if (
      EXCLUDED_PATHS.some((excluded) => relativeFilePath.includes(excluded))
    ) {
      return;
    }

    let content = fs.readFileSync(file, "utf8");
    let originalContent = content;

    // Helper to safely wrap text in tr()
    const wrapInTr = (text) => {
      let safeText = text.trim();
      safeText = safeText.replace(/"/g, '\\"');
      safeText = safeText.replace(/\n/g, "\\n");
      safeText = safeText.replace(/\r/g, "");
      safeText = safeText.replace(/\s+/g, " "); // Collapse spaces
      return `{tr("${safeText}")}`;
    };

    // 1. Wrap <Text> content
    content = content.replace(
      /(<Text[^>]*>)([^<{]+)(<\/Text>)/g,
      (match, openTag, text, closeTag) => {
        const trimmed = text.trim();
        if (!trimmed) return match;

        if (dryRun)
          console.log(
            `[${relativeFilePath}] Wrapping Text: "${trimmed.substring(0, 50)}..."`
          );
        return `${openTag}${wrapInTr(text)}${closeTag}`;
      }
    );

    // 2. Wrap <Button> content
    content = content.replace(
      /(<Button[^>]*>)([^<{]+)(<\/Button>)/g,
      (match, openTag, text, closeTag) => {
        const trimmed = text.trim();
        if (!trimmed) return match;

        // Safety: If it looks like code, skip it
        if (trimmed.includes("=>") || trimmed.includes("}")) return match;

        if (dryRun)
          console.log(
            `[${relativeFilePath}] Wrapping Button: "${trimmed.substring(0, 50)}..."`
          );
        return `${openTag}${wrapInTr(text)}${closeTag}`;
      }
    );

    // 2b. Wrap <Badge> content (but skip single words like "Yes"/"No")
    content = content.replace(
      /(<Badge[^>]*>)([^<{]+)(<\/Badge>)/g,
      (match, openTag, text, closeTag) => {
        const trimmed = text.trim();
        if (!trimmed) return match;

        // Skip single words or very short content
        if (trimmed.match(/^[A-Za-z]{1,6}$/) || trimmed.length < 4)
          return match;

        // Safety: If it looks like code, skip it
        if (
          trimmed.includes("=>") ||
          trimmed.includes("}") ||
          trimmed.includes("${")
        )
          return match;

        if (dryRun)
          console.log(
            `[${relativeFilePath}] Wrapping Badge: "${trimmed.substring(0, 50)}..."`
          );
        return `${openTag}${wrapInTr(text)}${closeTag}`;
      }
    );

    // 3. Wrap toast messages (simple ones without template literals)
    content = content.replace(
      /app\.toast\.show\s*\(\s*(['"`])([^'"`{$]+)\1/g,
      (match, quote, message) => {
        const trimmed = message.trim();
        if (!trimmed || trimmed.length < 4) return match;

        // Skip if contains template literal syntax or variables
        if (
          message.includes("${") ||
          message.includes("`") ||
          message.includes("||")
        )
          return match;

        if (dryRun)
          console.log(
            `[${relativeFilePath}] Wrapping toast: "${message.substring(0, 50)}..."`
          );
        return `app.toast.show(tr("${message.replace(/"/g, '\\"')}"),`;
      }
    );

    // 4. Wrap simple object message properties (but skip complex ones)
    content = content.replace(
      /(\s+message\s*:\s*)(['"`])([^'"`{$\n]{4,80})\2([,\s])/g,
      (match, prefix, quote, message, suffix) => {
        const trimmed = message.trim();

        // Skip technical/API messages or ones with variables
        if (
          message.includes("${") ||
          message.includes("API") ||
          message.includes("failed") ||
          message.includes("error") ||
          message.includes("not configured") ||
          message.includes("environment") ||
          message.length > 60
        ) {
          return match;
        }

        // Only translate user-facing success messages
        if (
          message.includes("successfully") ||
          message.includes("saved") ||
          message.includes("completed") ||
          message.includes("cleared")
        ) {
          if (dryRun)
            console.log(
              `[${relativeFilePath}] Wrapping message: "${message.substring(0, 50)}..."`
            );
          return `${prefix}tr("${message.replace(/"/g, '\\"')}")${suffix}`;
        }

        return match;
      }
    );

    // 5. Wrap attributes
    const attributes = [
      "ariaLabel",
      "content",
      "label",
      "placeholder",
      "title",
      "helpText",
    ];

    attributes.forEach((attr) => {
      // STRICT regex: value must not contain { or } or newline
      const regex = new RegExp(`\\b${attr}=(['"])([^"{}\n]{1,200})\\1`, "g");

      content = content.replace(regex, (match, quote, value) => {
        if (value.trim() === "") return match;

        // Double check for technical values in 'content' attribute
        if (attr === "content") {
          if (
            value.includes("width=") ||
            value.includes("http-equiv") ||
            value.includes("charset")
          ) {
            return match;
          }
        }

        if (dryRun)
          console.log(
            `[${relativeFilePath}] Wrapping ${attr}: "${value.substring(0, 50)}..."`
          );
        return `${attr}={tr("${value.replace(/"/g, '\\"')}")}`;
      });
    });

    // 6. Wrap simple throw Error messages (but skip technical ones)
    content = content.replace(
      /throw new Error\s*\(\s*(['"`])([^'"`{$\n]{4,80})\1\s*\)/g,
      (match, quote, errorMsg) => {
        // Skip technical error messages
        if (
          errorMsg.includes("API") ||
          errorMsg.includes("not configured") ||
          errorMsg.includes("environment variable") ||
          errorMsg.includes("Failed to") ||
          errorMsg.includes("Invalid") ||
          errorMsg.length > 50 ||
          errorMsg.includes("${") ||
          errorMsg.includes("not found")
        ) {
          return match;
        }

        // Only translate simple user-facing error messages
        if (errorMsg.match(/^[A-Z][a-z\s]{10,}$/)) {
          if (dryRun)
            console.log(
              `[${relativeFilePath}] Wrapping error: "${errorMsg.substring(0, 50)}..."`
            );
          return `throw new Error(tr("${errorMsg}"))`;
        }

        return match;
      }
    );

    // 7. Ensure Import and Hook are present if 'tr' is used
    // Check if we need to add boilerplate even if no new text was wrapped (e.g. broken previous run)
    const hasTrUsage = content.includes("tr(") || content.includes("{tr(");

    if (content !== originalContent || hasTrUsage) {
      // Check for useTr import
      let hasImport = content.includes("useTr");

      if (!hasImport) {
        const fileDir = path.dirname(file);
        const relativePath = path.relative(
          fileDir,
          path.join(appDir, "contexts/I18nContext")
        );
        let importPath = relativePath.startsWith(".")
          ? relativePath
          : "./" + relativePath;
        importPath = importPath.replace(/\\/g, "/");

        content = `import { useTr } from "${importPath}";\n` + content;
      }

      // IMPROVED HOOK INSERTION STRATEGY - 2-Step Approach
      // 1. Find component definition
      // 2. Find next {

      // Check if hook is already there to avoid duplicates
      if (!content.includes("const tr = useTr")) {
        const insertionIndex = findInsertionIndex(content);

        if (insertionIndex !== -1) {
          const before = content.slice(0, insertionIndex);
          const after = content.slice(insertionIndex);

          // Use 4 spaces for indentation to match typical project style
          content = before + "\n    const tr = useTr();" + after;

          if (dryRun) console.log(`[${relativeFilePath}] Inserted hook.`);
        } else {
          if (dryRun)
            console.log(
              `[${relativeFilePath}] No component found for hook insertion.`
            );
        }
      }
    }

    if (content !== originalContent) {
      if (!dryRun) {
        fs.writeFileSync(file, content);
        console.log(`Updated: ${relativeFilePath}`);
      }
      modifiedFiles.push(file);
    }
  } catch (err) {
    console.error(`❌ Error processing ${file}:`, err);
    errorCount++;
  }
});

if (modifiedFiles.length === 0) {
  console.log("No files needed updating.");
} else {
  console.log(
    `\nSuccess! ${dryRun ? "Identified" : "Updated"} ${modifiedFiles.length} files.`
  );
  if (errorCount > 0) console.log(`⚠️  Encountered ${errorCount} errors.`);
}

i18nContext.tsx

import React, { createContext, useContext, ReactNode } from "react";

interface I18nContextType {
  locale: string;
  tr: (key: string, params?: Record<string, string | number>) => string;
}

const I18nContext = createContext<I18nContextType | null>(null);

interface I18nProviderProps {
  locale: string;
  translations: Record<string, string>;
  children: ReactNode;
}

export function I18nProvider({
  locale,
  translations,
  children,
}: I18nProviderProps) {
  const tr = (key: string, params?: Record<string, string | number>): string => {
    // Return translation if it exists and is not empty, otherwise return the key itself
    let translation = translations[key];
    if (!translation || translation.trim() === "") {
      translation = key;
    }

    // Replace placeholders if params are provided
    if (params) {
      Object.entries(params).forEach(([paramKey, paramValue]) => {
        translation = translation.replace(
          new RegExp(`{${paramKey}}`, "g"),
          String(paramValue)
        );
      });
    }

    return translation;
  };

  return (
    <I18nContext.Provider value={{ locale, tr }}>
      {children}
    </I18nContext.Provider>
  );
}

export function useTr() {
  const context = useContext(I18nContext);
  if (!context) {
    // Fallback if context is missing, still supports params for development/testing
    return (key: string, params?: Record<string, string | number>) => {
      let translation = key;
      if (params) {
        Object.entries(params).forEach(([paramKey, paramValue]) => {
          translation = translation.replace(
            new RegExp(`{${paramKey}}`, "g"),
            String(paramValue)
          );
        });
      }
      return translation;
    };
  }
  return context.tr;
}

export function useLocale() {
  const context = useContext(I18nContext);
  if (!context) {
    return "en";
  }
  return context.locale;
}

usageExample.tsx

import { useTr } from "../contexts/I18nContext";
import { Text, Layout } from "@shopify/polaris";

function example() {
    const tr = useTr();

    return (
        <Layout>
            <Text as="p">{tr("Translation example")}</Text>
        </Layout>
    );
}

export default example;