还在手动翻译国际化词条?AST解析+AI翻译实现一键替换

国际化项目,经常要随迭代增加翻译词条,如果手动翻译,很容易造成翻译重复或遗漏,而且词条的替换操作体验也极差,这类机械性工作完全可以交给AI完成。

前置准备

首先需要一个AI服务器,推荐ollama,部署简单。

其次需要一个可视化AST结构的工具,推荐astexplorer.net/

AI翻译

翻译部分比较简单,这里是传入中文语言包,然后输出英文语言包。只要调用ollama的api,传入对应的模型和promot,约定输出结构就行。

js 复制代码
const axios = require('axios');

/**
 * 调用 Ollama  模型实现中译英
 * @param {string} chineseText - 要翻译的中文语言包文本
 * @returns {Promise<string>} 翻译后的英文语言包文本
 */
async function translateChineseToEnglish(chineseText) {
  // Ollama 本地 API 地址(默认端口 11434)
  const OLLAMA_API_URL = 'http://localhost:11434/api/generate';

  // 构建提示词
  const prompt = `
    你是一个专业的中英翻译助手,请严格按照以下要求执行:
    1. 将中文语言包JSON翻译为应为语言包
    2. 语言包结构如下: {"k_abc12345": "是","k_rst12345": "确认"},key为i18n key,value为对应语言
    1. 唯一输出内容为英文语言包JSON:示例: {"k_abc12345": "yes","k_rst12345": "confirm"}, key不变,值为中文文本的英文翻译结果;
    2. 禁止输出:任何非 JSON 内容(包括思考过程、解释、换行、空格、前缀/后缀);
    3. 翻译要求:准确、通顺,符合英文表达习惯。
    需要翻译的中文语言包:${chineseText}
  `.trim();

  try {
    // 发送请求到 Ollama API
    const response = await axios.post(
      OLLAMA_API_URL,
      {
        model: 'qwen3:8b',
        prompt: prompt,
        stream: false, 
        temperature: 0.1, 
        max_tokens: 1000, 
      },
      {
        headers: {
          'Content-Type': 'application/json',
        },
        timeout: 30000, // 超时时间 30 秒
      },
    );

    // 解析响应结果
    if (response.data && response.data.response) {
      // 清理结果中的多余空格/换行
      const translatedText = response.data.response.trim();
      return translatedText;
    } else {
      throw new Error('模型返回结果格式异常');
    }
  } catch (error) {
    // 错误处理
   
  }
}

// 示例使用languageJson:{ k_abc12345: '是', k_lmn67890: 'yes' }
async function translate(languageJson) {
  // 要翻译的中文文本
  const chineseText = JSON.stringify(languageJson);

  try {
    console.log('待翻译的中文:', chineseText);
    console.log('------------------------');

    const englishText = await translateChineseToEnglish(chineseText);
    console.log('翻译结果:', englishText);
    return JSON.parse(englishText);
  } catch (error) {
    console.error('翻译出错:', error.message);
  }
  return {};
}
module.exports = { translate };

AST翻译词条替换

i18n key生成

这里首先需要获取到中文,并生成i18n key,对同一中文生成的key应该一样,可以使用md5摘要算法。

js 复制代码
const genKey = (zh) => 'k_' + crypto.createHash('md5').update(zh).digest('hex').slice(0, 8);

核心逻辑:AST操作

AST操作相对繁琐,这里用到的工具有babel和recast,recast能保留原文件的内容格式。

js 复制代码
const fg = require('fast-glob');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');

const traverse = require('@babel/traverse').default;
const { parse: babelParse } = require('@babel/parser');
const recast = require('recast');
const t = require('@babel/types');
const { translate } = require('./translate.cjs');
const excludes = [
  /language/,
   ...
];
/* ────── 配置 ────── */
const SRC_DIR = path.resolve(process.cwd(), 'src');
const LOCALE_FILES = [
  path.resolve(SRC_DIR, '自定义路径', 'zh-CN.json'),
  path.resolve(SRC_DIR, '自定义路径', 'en-US.json'),
  // 可继续添加更多语种
];
const GLOB = ['**/*.{js,jsx,ts,tsx}'];

const CHN_RE =
  /[\u3400-\u4dbf\u4e00-\u9fff\u{20000}-\u{2a6df}\u{2a700}-\u{2b73f}\u{2b740}-\u{2b81f}\u{2b820}-\u{2ceaf}\u3000-\u303f\uff00-\uffef]/u;
const KEY_RE = /^k_[0-9a-f]{8}$/;
const I18N_IMPORT = '@/i18n';

/* ────── 工具 ────── */
const dict = Object.create(null);


const buildReactCallWithAlias = (aliasName, key) => t.callExpression(t.identifier(aliasName), [t.stringLiteral(key)]);
const buildCommonCall = (key) =>
  t.callExpression(t.memberExpression(t.identifier('i18n'), t.identifier('t')), [t.stringLiteral(key)]);


function hasJSX(ast) {
  let yes = false;
  traverse(ast, {
    JSXElement(p) {
      yes = true;
      p.stop();
    },
    JSXFragment(p) {
      yes = true;
      p.stop();
    },
  });
  return yes;
}
function getFunctionName(fnPath) {
  const n = fnPath.node;
  if (t.isFunctionDeclaration(n) && n.id) return n.id.name;
  if (
    (t.isFunctionExpression(n) || t.isArrowFunctionExpression(n)) &&
    t.isVariableDeclarator(fnPath.parent) &&
    t.isIdentifier(fnPath.parent.id)
  )
    return fnPath.parent.id.name;
  return null;
}
function nameLooksLikeComponentOrHook(name) {
  if (!name) return false;
  if (name.startsWith('use')) return true;
  return /^[A-Z]/.test(name);
}
function functionHasJSX(fnPath) {
  let found = false;
  fnPath.traverse({
    JSXElement(p) {
      found = true;
      p.stop();
    },
    JSXFragment(p) {
      found = true;
      p.stop();
    },
  });
  return found;
}
function isClassLikeFunction(fnPath) {
  return !!fnPath.findParent((pp) => pp.isClassBody && pp.isClassBody());
}
function getHookableFunctionScope(p) {
  const fn = p.getFunctionParent();
  if (!fn) return null;
  if (!(fn.isFunctionDeclaration() || fn.isFunctionExpression() || fn.isArrowFunctionExpression())) return null;
  if (!fn.get('body').isBlockStatement()) return null;
  if (isClassLikeFunction(fn)) return null;
  const name = getFunctionName(fn);
  if (nameLooksLikeComponentOrHook(name) || functionHasJSX(fn)) return fn;
  return null;
}


/* ────── 处理单个文件 ────── */
async function transformFile(absPath) {
  const code = await fs.readFile(absPath, 'utf8');
  if (!CHN_RE.test(code)) return;

  let ast;
  try {
    ast = recast.parse(code, {
      parser: {
        parse(src) {
          return babelParse(src, {
            sourceType: 'unambiguous',
            plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
            tokens: true,
          });
        },
      },
    });
  } catch (e) {
    console.warn('skip parse error:', path.relative(process.cwd(), absPath), e.reasonCode || e.message);
    return;
  }

  const reactLike = hasJSX(ast);
  let modified = false;
  let injectedUseTranslationSomewhere = false;
  let usedI18nCommon = false;
  const tAliasMap = new WeakMap();

  /* ── 内部工具 ── */
  function findExistingTAlias(fnPath) {
    let alias = null;
    fnPath.traverse({
      VariableDeclarator(vp) {
        const n = vp.node;
        if (!t.isObjectPattern(n.id)) return;
        for (const pr of n.id.properties) {
          if (t.isObjectProperty(pr) && t.isIdentifier(pr.key, { name: 't' })) {
            const local = pr.shorthand ? 't' : t.isIdentifier(pr.value) ? pr.value.name : null;
            if (local && t.isCallExpression(n.init) && t.isIdentifier(n.init.callee, { name: 'useTranslation' })) {
              alias = local;
              vp.stop();
            }
          }
        }
      },
    });
    return alias;
  }
  function ensureTInFunction(fnPath) {
    const cached = tAliasMap.get(fnPath.node);
    if (cached) return cached;
    const existed = findExistingTAlias(fnPath);
    if (existed) {
      tAliasMap.set(fnPath.node, existed);
      return existed;
    }
    const aliasId = fnPath.scope.hasBinding('t') ? fnPath.scope.generateUidIdentifier('t') : t.identifier('t');
    const decl = t.variableDeclaration('const', [
      t.variableDeclarator(
        t.objectPattern([t.objectProperty(t.identifier('t'), aliasId, false, aliasId.name === 't')]),
        t.callExpression(t.identifier('useTranslation'), []),
      ),
    ]);
    const bodyPath = fnPath.get('body');
    if (!bodyPath.isBlockStatement()) return null;
    bodyPath.unshiftContainer('body', decl);
    injectedUseTranslationSomewhere = true;
    tAliasMap.set(fnPath.node, aliasId.name);
    return aliasId.name;
  }
  function getTAliasForPath(p) {
    if (!reactLike) return null;
    const fn = getHookableFunctionScope(p);
    if (!fn) return null;
    return ensureTInFunction(fn);
  }

  /* ── AST 遍历 ── */
  traverse(ast, {
    StringLiteral(p) {
      const raw = p.node.value;
      if (!CHN_RE.test(raw)) return;
      if (KEY_RE.test(raw)) return;
      if (
        isImportSource(p) ||
        isRequirePath(p) ||
        isDynamicImportArg(p) ||
        isObjectKey(p) ||
        isI18nKey(p) ||
        isInTypePosition(p) ||
        isConsoleArg(p) ||
        isThrowError(p)
      )
        return;

      const key = genKey(raw);
      if (!dict[key]) dict[key] = raw;
      const tAlias = getTAliasForPath(p);
      const inJsxAttr = p.parentPath.isJSXAttribute() && p.parent.value === p.node;
      const call = tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key));
      if (inJsxAttr) p.replaceWith(t.jsxExpressionContainer(call));
      else p.replaceWith(call);
      p.skip();
      modified = true;
    },

    TemplateLiteral(p) {
      if (p.node.expressions.length) return;
      const cooked = p.node.quasis[0].value.cooked;
      if (!cooked || !CHN_RE.test(cooked) || KEY_RE.test(cooked)) return;
      if (
        isImportSource(p) ||
        isRequirePath(p) ||
        isDynamicImportArg(p) ||
        isObjectKey(p) ||
        isInTypePosition(p) ||
        isConsoleArg(p)
      )
        return;

      const key = genKey(cooked);
      if (!dict[key]) dict[key] = cooked;
      const tAlias = getTAliasForPath(p);
      p.replaceWith(tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key)));
      p.skip();
      modified = true;
    },

    JSXText(p) {
      const raw = p.node.value;
      if (!CHN_RE.test(raw)) return;
      const leading = (raw.match(/^\s+/) || [''])[0];
      const trailing = (raw.match(/\s+$/) || [''])[0];
      const middle = raw.slice(leading.length, raw.length - trailing.length);
      if (!middle || !CHN_RE.test(middle) || KEY_RE.test(middle)) return;

      const key = genKey(middle);
      if (!dict[key]) dict[key] = middle;
      const tAlias = getTAliasForPath(p);
      const expr = tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key));
      const parts = [];
      if (leading) parts.push(t.jsxText(leading));
      parts.push(t.jsxExpressionContainer(expr));
      if (trailing) parts.push(t.jsxText(trailing));
      p.replaceWithMultiple(parts);
      p.skip();
      modified = true;
    },
  });

  /* ── 补充 import ── */
  if (injectedUseTranslationSomewhere) {
    ensureUseTranslationImport(ast.program);
    modified = true;
  }
  if (usedI18nCommon) {
    let programPath = null;
    traverse(ast, {
      Program(pp) {
        programPath = pp;
        pp.stop();
      },
    });
    if (programPath) {
      ensureI18nImport(ast.program, programPath);
      modified = true;
    }
  }

  /* ── 写回文件 ── */
  if (modified) {
    const output = recast.print(ast, { quote: 'single' }).code;
    await fs.writeFile(absPath, output, 'utf8');
    console.log('✔ transformed', path.relative(process.cwd(), absPath));
  }
}

/* ────── 同步 locale 文件 ────── */
async function flushDict() {
  const keys = Object.keys(dict);
  if (!keys.length) {
    console.log('✨  没有新的翻译条目');
    return;
  }

  const oldJsonArr = await Promise.all(
    LOCALE_FILES.map(async (f) => {
      const text = await fs.readFile(f, 'utf8');
      try {
        return JSON.parse(text);
      } catch (e) {
        console.error(e, text);
        return {};
      }
    }),
  );
  //中文语言包
  const toTranslate = {};
  const file = LOCALE_FILES[0];
  const oldJson = oldJsonArr[0];
  const merged = { ...oldJson };
  for (const k of keys) {
    if (!(k in oldJson)) {
      merged[k] = dict[k];
      toTranslate[k] = dict[k];
    }
  }
  await fs.mkdir(path.dirname(file), { recursive: true });
  await fs.writeFile(file, JSON.stringify(merged, null, 2) + '\n', 'utf8');
  if (Object.keys(toTranslate).length > 0) {
    const translated = await translate(toTranslate);
    let toMerged = translated || dict;
    for (let i = 1; i < LOCALE_FILES.length; i++) {
      const file = LOCALE_FILES[i];
      const oldJson = oldJsonArr[i];
      const merged = { ...oldJson };
      for (const k of keys) if (!(k in oldJson)) merged[k] = toMerged[k] || dict[k];
      await fs.mkdir(path.dirname(file), { recursive: true });
      await fs.writeFile(file, JSON.stringify(merged, null, 2) + '\n', 'utf8');
    }
  }

  const newCount = keys.filter((k) => !(k in oldJsonArr[0])).length;
  console.log(
    `✨  已同步到所有 locale 文件:${LOCALE_FILES.map((f) => path.relative(process.cwd(), f)).join(
      ', ',
    )},新增 ${newCount} 条`,
  );
}

/* ────── 主入口 ────── */
(async () => {
  const cliArgs = process.argv.slice(2);
  const targets = cliArgs.length ? cliArgs : ['src'];
  const patterns = [];

  for (const raw of targets) {

    let abs = path.resolve(process.cwd(), raw);
    let stat = await fs.stat(abs).catch(() => null);

   
    if (!stat) {
      const insideSrc = path.join(SRC_DIR, raw);
      abs = insideSrc;
      stat = await fs.stat(abs).catch(() => null);
    }


    if (!stat) {
      // 先找目录
      const dirMatches = await fg(`**/${raw}`, {
        cwd: SRC_DIR,
        onlyDirectories: true, // 关键改动: 只返回目录
        absolute: true,
        suppressErrors: true,
      });
      if (dirMatches.length) {
        abs = dirMatches[0];
        stat = await fs.stat(abs);
      } else {
       
        const fileMatches = await fg(`**/${raw}`, {
          cwd: SRC_DIR,
          onlyFiles: true,
          absolute: true,
          suppressErrors: true,
        });
        if (fileMatches.length) {
          abs = fileMatches[0];
          stat = await fs.stat(abs);
        }
      }
    }


    if (!stat) {
      console.warn(`⚠️  路径不存在: ${raw}`);
      continue;
    }


    if (stat.isFile()) {
      patterns.push(abs); 
      continue;
    }

  
    const relToSrc = path.relative(SRC_DIR, abs).replace(/\\/g, '/');
    const prefix = relToSrc ? relToSrc : '.';
    for (const ptn of GLOB) {
      patterns.push(path.posix.join(prefix, ptn));
    }
  }


  if (!patterns.length) {
    console.log('⚠️  无可扫描文件');
    return;
  }

  const files = await fg(patterns, { cwd: SRC_DIR, absolute: true });

  for (const f of files) {
    if (excludes.some((item) => item.test(f))) {
      continue;
    }
    await transformFile(f);
  }
  await flushDict();
})().catch((err) => {
  console.error(err);
  process.exit(1);
});

对i18n依赖的处理

对未引入i18n相关依赖的文件增加依赖引入

js 复制代码
function ensureUseTranslationImport(program) {
  let hasNamed = false;
  for (const n of program.body) {
    if (t.isImportDeclaration(n) && n.source.value === 'react-i18next') {
      if (n.specifiers.some((s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported, { name: 'useTranslation' })))
        hasNamed = true;
    }
  }
  if (hasNamed) return;
  program.body.unshift(
    t.importDeclaration(
      [t.importSpecifier(t.identifier('useTranslation'), t.identifier('useTranslation'))],
      t.stringLiteral('react-i18next'),
    ),
  );
}
function ensureI18nImport(program, programPath) {
  if (programPath.scope.hasBinding('i18n')) return;
  program.body.unshift(
    t.importDeclaration([t.importDefaultSpecifier(t.identifier('i18n'))], t.stringLiteral(I18N_IMPORT)),
  );
}

需要排除的场景

这里排除了一些场景

  • import或require资源中的中文
  • 作为i18n key的中文
  • 对象的中文key
  • ts类型中的中文
  • throw error的中文
  • console的中文
js 复制代码
/* ────── 辅助判断函数(与原脚本一致) ────── */
function isImportSource(p) {
  return (
    (p.parent.type === 'ImportDeclaration' ||
      p.parent.type === 'ExportNamedDeclaration' ||
      p.parent.type === 'ExportAllDeclaration') &&
    p.parent.source === p.node
  );
}
function isRequirePath(p) {
  return (
    p.parent.type === 'CallExpression' &&
    p.parent.callee.type === 'Identifier' &&
    p.parent.callee.name === 'require' &&
    p.parent.arguments[0] === p.node
  );
}
function isDynamicImportArg(p) {
  return p.parent.type === 'CallExpression' && p.parent.callee.type === 'Import' && p.parent.arguments[0] === p.node;
}
function isI18nKey(p) {
  if (p.parent.type === 'CallExpression') {
    if (p.parent.callee.type === 'MemberExpression') {
      return p.parent.callee.property?.name === 't';
    } else if (p.parent.callee.type === 'Identifier') {
      return p.parent.callee.name === 't';
    }
  }
  return false;
}
function isObjectKey(p) {
  return p.parent.type === 'ObjectProperty' && p.parent.key === p.node && !p.parent.computed;
}
function isInTypePosition(p) {
  return !!p.findParent(
    (pp) => pp.isTSType() || pp.isTSLiteralType?.() || pp.isTSImportType?.() || pp.isTSTypeAnnotation?.(),
  );
}
function isThrowError(p) {
  return p.parent.type === 'ThrowStatement' || (p.parent.type === 'NewExpression' && p.parent.callee.name === 'Error');
}
function isConsoleArg(p) {
  const parent = p.parent;
  if (!parent) return false;
  if (parent.type !== 'CallExpression') return false;
  const args = parent.arguments || [];
  if (args.indexOf(p.node) === -1) return false;
  const c = parent.callee;
  if (t.isMemberExpression(c) && t.isIdentifier(c.object, { name: 'console' })) {
    const prop = t.isIdentifier(c.property) ? c.property.name : t.isStringLiteral(c.property) ? c.property.value : '';
    return ['log', 'error', 'warn', 'info', 'debug'].includes(prop);
  }
  return false;
}
相关推荐
土豆12502 小时前
MiniMax M2 Coding Plan + Claude Code:打造你的低成本高效率AI编程搭档
ai编程·claude
陈佬昔没带相机2 小时前
SDD 规范驱动开发 AI 编程简单实践后,我找到了它的使用场景
ai编程
土豆12502 小时前
Rust 错误处理完全指南:从入门到精通
前端·rust·编程语言
武子康2 小时前
大数据-197 K折交叉验证实战:sklearn 看均值/方差,选更稳的 KNN 超参
大数据·后端·机器学习
QT 小鲜肉2 小时前
【Linux命令大全】001.文件管理之mmove命令(实操篇)
linux·服务器·前端·chrome·笔记
码事漫谈3 小时前
C++数据竞争与无锁编程
后端
码事漫谈3 小时前
C++虚函数表与多重继承内存布局深度剖析
后端
前端开发爱好者3 小时前
VSCode 重磅更新!要收费了?
前端·javascript·visual studio code
烛阴3 小时前
C# 正则表达式(4):分支与回溯引用
前端·正则表达式·c#