英文版首页上线后,按钮位置上剩了半截 [cta_button,参数表里型号旁边还挂着中文,切成英文再刷新又弹回中文。这几个现象走的不是同一条路:短代码在渲染前后被拆开,区块和构建器把文案存进属性而不是文本节点,语言选择只活在内存里。下面按处理顺序走一遍,代码可以直接放进主题或自建插件。
同一个页面里的文字,存放位置至少有这么几种:
<!-- wp:xxx {...} --> 后面那段 JSON,动态区块的文案只存在这里,渲染结果里根本没有;只遍历 DOM 文本节点的方案,中间两类一条都拿不到。动手前先做一件事:把渲染后的页面另存一份,再导出一份 post_content 原文,对照着看某段文字到底只在哪一边出现。这一步做完,后面要写几个提取器心里就有数了。
把整段 HTML 直接丢给翻译接口,class="btn-primary" 里的 btn-primary、短代码名、data-* 的值都可能被当成词翻掉。做法是先换成占位符,翻完再换回来,并且逐条核对占位符还在不在——少一个就放弃这一条,用原文,宁可漏翻也不要写出半截标签。
function aum_mask(string $html): array {
$map = [];
$masked = preg_replace_callback(
'/<!--.*?-->|<\/?[a-zA-Z][^>]*>|\[\/?[a-zA-Z_][a-zA-Z0-9_-]*(?:\s[^\]]*)?\]/s',
static function (array $m) use (&$map): string {
$token = '⟦' . count($map) . '⟧';
$map[$token] = $m[0];
return $token;
},
$html
);
return [$masked, $map];
}
function aum_unmask(string $text, array $map): ?string {
foreach ($map as $token => $_) {
if (substr_count($text, $token) !== 1) {
return null; // 被吞掉或被复制,这一条作废
}
}
return strtr($text, $map);
}用法:
[$masked, $map] = aum_mask($html);
$translated = aum_translate_api($masked, 'en'); // 你自己的调用
$restored = aum_unmask($translated, $map);
if ($restored === null) {
$restored = $html;
}占位符的形态按你的翻译引擎试出来定。{{0}} 这种花括号经常被改写,换一种记号基本能解决。
短代码出问题通常是两种:属性里的文案没翻,和同一段正文被翻了两遍。
function aum_translate_shortcode_atts(string $content, callable $translate): string {
$pattern = get_shortcode_regex();
$allowed = ['text', 'title', 'label', 'heading', 'desc', 'placeholder'];
return preg_replace_callback('/' . $pattern . '/s', static function (array $m) use ($translate, $allowed): string {
$atts = $m[3];
if ($atts === '') {
return $m[0];
}
$new = preg_replace_callback(
'/([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)"/',
static function (array $a) use ($translate, $allowed): string {
if (!in_array(strtolower($a[1]), $allowed, true) || trim($a[2]) === '') {
return $a[0];
}
return $a[1] . '="' . esc_attr($translate($a[2])) . '"';
},
$atts
);
if ($new === null || $new === $atts) {
return $m[0];
}
$pos = strpos($m[0], $atts);
return $pos === false ? $m[0] : substr_replace($m[0], $new, $pos, strlen($atts));
}, $content);
}几点:属性白名单比黑名单可靠,url、link、class、id、style、color、size、icon 这类永远不进去;只替换属性那一段子串,不要用正则捕获组重新拼整个短代码,拼错一个括号整块就废了。包裹型短代码 [box]正文[/box] 中间的内容属于正文,交给正文管线,这里再翻一遍就是重复翻译。
还有一种翻坏方式和翻译无关:wpautop。译文里要是多出一个空行,它会在短代码中间插 <p>,短代码当场失效。回填前把连续空行压掉,或者把回填放到 the_content 过滤链之后。
这类数据结构不固定,但文案字段一般落在 text、title、content、label、heading、caption 这些键上。递归收集时记下完整路径,翻完按路径写回,不要重新拼整棵结构。
function aum_collect_strings($node, array $path, array &$out, array $skipKeys): void {
if (is_array($node)) {
foreach ($node as $k => $v) {
$seg = (string) $k;
if (in_array($seg, $skipKeys, true)) {
continue;
}
aum_collect_strings($v, array_merge($path, [$seg]), $out, $skipKeys);
}
return;
}
if (!is_string($node) || trim($node) === '') {
return;
}
$v = trim($node);
// 纯 URL、纯路径、纯数值连带单位,不当文案
if (preg_match('~^(https?://|/|\{)\S*$~i', $v)) {
return;
}
if (preg_match('~^\d+(\.\d+)?(px|em|rem|%|vh|vw|s)?$~i', $v)) {
return;
}
$out[implode('.', $path)] = $node;
}
function aum_set_by_path(array &$data, string $path, string $value): void {
$ref = &$data;
foreach (explode('.', $path) as $seg) {
if (!is_array($ref) || !array_key_exists($seg, $ref)) {
return;
}
$ref = &$ref[$seg];
}
$ref = $value;
}跳过的键里至少要有 class、classes、id、css、style、url、link、href、src、icon、type、widgetType。写回 JSON 时用的编码参数要和原来一致,JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 混着用会让每次保存都产生大段 diff,缓存和版本比对都会失真。
构建器渲染出来经常是 有任何问题 <a class="btn">立即咨询</a>,再套一层 <span>。逐个文本节点翻,两段各自翻,语序和标点会断;要是短代码的输出正好被拆在这几个节点里,翻完就是 [cta_button 加半截词。
做法是把连续的文本节点和行内元素合成一个 run,整段序列化、遮罩、翻译、整体替换回去。
const AUM_INLINE = ['a', 'span', 'strong', 'em', 'b', 'i', 'small', 'mark', 'sub', 'sup', 'u', 'abbr'];
const AUM_SKIP = ['script', 'style', 'code', 'pre', 'textarea', 'kbd', 'samp'];
function aum_translate_runs(DOMNode $node, callable $translate): void {
$run = [];
$flush = function () use (&$run, $translate) {
if (!$run) return;
$nodes = $run;
$run = [];
$doc = $nodes[0]->ownerDocument;
$html = '';
foreach ($nodes as $n) {
$html .= $doc->saveHTML($n);
}
if (trim(strip_tags($html)) === '') return;
[$masked, $map] = aum_mask($html);
$out = aum_unmask($translate($masked), $map);
if ($out === null || $out === $html) return;
$frag = aum_html_to_fragment($doc, $out);
if ($frag === null) return;
$parent = $nodes[0]->parentNode;
$parent->insertBefore($frag, $nodes[0]);
foreach ($nodes as $n) {
$parent->removeChild($n);
}
};
foreach (iterator_to_array($node->childNodes) as $child) {
$name = $child instanceof DOMElement ? strtolower($child->nodeName) : '';
if ($child instanceof DOMText || in_array($name, AUM_INLINE, true)) {
$run[] = $child;
continue;
}
$flush();
if ($child instanceof DOMElement && !in_array($name, AUM_SKIP, true)) {
aum_translate_runs($child, $translate);
}
}
$flush();
}把返回的片段变回节点,不要用 appendXML,HTML5 的无闭合标签会让它直接失败;改用临时文档解析:
function aum_html_to_fragment(DOMDocument $doc, string $html): ?DOMDocumentFragment {
$tmp = new DOMDocument('1.0', 'UTF-8');
$prev = libxml_use_internal_errors(true);
$tmp->loadHTML('<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><div id="aum-root">' . $html . '</div>');
libxml_clear_errors();
libxml_use_internal_errors($prev);
$root = (new DOMXPath($tmp))->query('//div[@id="aum-root"]')->item(0);
if (!$root) return null;
$frag = $doc->createDocumentFragment();
foreach (iterator_to_array($root->childNodes) as $child) {
$frag->appendChild($doc->importNode($child, true));
}
return $frag;
}页头页脚模板可能被渲染进同一个 DOM,同一句话会出现两次。按文本哈希做一层缓存,重复的句子只请求一次。
MutationObserver 写完 DOM 会再触发自己,不处理就是死循环加重复翻译,账单也跟着涨。两个要点:改 DOM 时先 disconnect,改完再 observe;只处理叶子元素,容器节点整体替换会把里面的标签全清掉。
const pending = new Set();
let scheduled = false;
const observer = new MutationObserver((records) => {
for (const r of records) {
for (const n of r.addedNodes) {
if (n.nodeType === 1 && !n.closest('script,style,code,pre,textarea')) {
pending.add(n);
}
}
}
if (scheduled || !pending.size) return;
scheduled = true;
requestAnimationFrame(flush); // 让构建器把这一帧的节点插完
});
async function flush() {
scheduled = false;
const nodes = [...pending].filter((n) => n.isConnected && !n.dataset.i18nDone);
pending.clear();
const units = [];
for (const n of nodes) {
if (n.children.length > 0) continue; // 容器不整体替换
const src = n.textContent.trim();
if (!src) continue;
n.dataset.i18nDone = '1';
units.push({ node: n, src });
}
if (!units.length) return;
const res = await fetch('/wp-json/aum/v1/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.aumI18n?.nonce ?? '' },
body: JSON.stringify({ lang: document.documentElement.lang, items: units.map((u) => u.src) }),
});
const data = await res.json();
observer.disconnect();
try {
units.forEach((u, i) => {
const out = data.items?.[i];
if (typeof out === 'string' && out !== u.src) {
u.node.dataset.i18nSrc ??= u.src; // 留原文,切回语言时不用再请求
u.node.textContent = out;
}
});
} finally {
observer.observe(document.body, { childList: true, subtree: true });
}
}
observer.observe(document.body, { childList: true, subtree: true });disconnect 会丢掉这段时间里的记录,同一 tick 内其他代码插入的节点会漏掉。补一个兜底:构建器自己有「渲染完成」事件的话,在那之后主动跑一次全量扫描。
placeholder、title、alt、aria-label 都是属性,遍历文本节点一个都碰不到。<input type="submit"> 的文案在 value 里,<option> 的 label 也是属性。这些必须单独走一遍。
$xp = new DOMXPath($doc);
foreach ($xp->query('//*[@placeholder or @title or @alt or @aria-label]') as $el) {
foreach (['placeholder', 'title', 'alt', 'aria-label'] as $a) {
if ($el->hasAttribute($a)) {
$v = trim($el->getAttribute($a));
if ($v !== '') {
$el->setAttribute($a, $translate($v));
}
}
}
}
// 按钮和提交框的 value 是文案,其他 input 的 value 是数据,不能动
foreach ($xp->query('//input[@type="submit" or @type="button"]') as $el) {
$el->setAttribute('value', $translate($el->getAttribute('value')));
}页面头部的东西在 DOM 之外: <meta name="description">、og:*、<title>。JSON-LD 里的 name、description 可以翻,@type、url、sku、price、priceCurrency 一个都不能碰。整段 JSON-LD 丢进翻译接口,输出必然是坏的。
只存内存变量,刷新就丢。存 localStorage 能记住,但首屏还是先渲染默认语言,闪一下才切。要让首屏就是对的,语言必须落在 URL 上,再加一份 cookie 给直接访问根路径的访客。
function rememberLang(lang) {
document.cookie =
`site_lang=${encodeURIComponent(lang)}; Max-Age=31536000; Path=/; SameSite=Lax; Secure`;
document.documentElement.lang = lang;
}服务端读 cookie 做一次 302:
add_action('template_redirect', function () {
if (is_admin() || wp_doing_ajax()) return;
$lang = $_COOKIE['site_lang'] ?? '';
if (!in_array($lang, ['zh', 'en'], true)) return;
if (aum_current_lang_from_url() === $lang) return;
wp_safe_redirect(aum_url_for_lang($lang), 302);
exit;
});这里有个坑:整页缓存或 CDN 如果不按 cookie 分桶,这个 302 会把 A 用户的英文页发给 B 用户。要么让缓存层按 cookie 变化,要么干脆只认 URL 前缀、cookie 只在第一次访问时用一次。另外 <link rel="alternate" hreflang> 必须服务端输出,靠 JS 补出来的对搜索引擎基本没用;客户端切换语言时,<html lang> 要跟着改,不然读屏软件和浏览器翻译都会判断错。
结构有没有被翻坏,数量对一下最快。翻译前后的页面各存一份:
for f in before.html after.html; do
printf '%s href=%s shortcode=%s bracket=%s\n' \
"$f" \
"$(grep -o 'href="' "$f" | wc -l)" \
"$(grep -oE '\[/?[a-z_]+' "$f" | wc -l)" \
"$(grep -o '\[' "$f" | wc -l)"
donehref 数量少了说明链接被合并或吃掉了,短代码数量对不上说明标签被拆了。再随机抽几条翻过的句子,看里面有没有残留 ⟦、{{、[[ 这类记号。
这套管线自己写不算难,麻烦的是长期跟着构建器的存储格式改。我们把它做成了免费插件 AumLang,做 AI 翻译、输出 hreflang,WooCommerce 商品页的字段也一起处理,可以拿它的实现对着上面几步看:aumcreate.com/plugins/aumlang。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。