首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >页面构建器做的页面怎么翻译:短代码、区块属性、动态内容各在哪一步漏掉

页面构建器做的页面怎么翻译:短代码、区块属性、动态内容各在哪一步漏掉

原创
作者头像
用户10345225
发布于 2026-11-05 12:26:44
发布于 2026-11-05 12:26:44
970
举报

英文版首页上线后,按钮位置上剩了半截 [cta_button,参数表里型号旁边还挂着中文,切成英文再刷新又弹回中文。这几个现象走的不是同一条路:短代码在渲染前后被拆开,区块和构建器把文案存进属性而不是文本节点,语言选择只活在内存里。下面按处理顺序走一遍,代码可以直接放进主题或自建插件。

先确认文案存在哪一层

同一个页面里的文字,存放位置至少有这么几种:

  • 渲染出来的 HTML 文本节点,这是最好处理的一种;
  • HTML 注释里的区块属性,比如 <!-- wp:xxx {...} --> 后面那段 JSON,动态区块的文案只存在这里,渲染结果里根本没有;
  • 构建器自己存的 meta,一页一份 JSON,页头和页脚往往另存成模板;全局的颜色、字号、按钮文案在站点设置里;
  • 运行时才拼出来的内容:循环列表、repeater、AJAX 分页、购物车、按访客地区切换的区块。

只遍历 DOM 文本节点的方案,中间两类一条都拿不到。动手前先做一件事:把渲染后的页面另存一份,再导出一份 post_content 原文,对照着看某段文字到底只在哪一边出现。这一步做完,后面要写几个提取器心里就有数了。

遮罩:先保护,翻完逐个核对

把整段 HTML 直接丢给翻译接口,class="btn-primary" 里的 btn-primary、短代码名、data-* 的值都可能被当成词翻掉。做法是先换成占位符,翻完再换回来,并且逐条核对占位符还在不在——少一个就放弃这一条,用原文,宁可漏翻也不要写出半截标签。

代码语言:php
复制
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);
}

用法:

代码语言:php
复制
[$masked, $map] = aum_mask($html);
$translated = aum_translate_api($masked, 'en'); // 你自己的调用
$restored   = aum_unmask($translated, $map);
if ($restored === null) {
    $restored = $html;
}

占位符的形态按你的翻译引擎试出来定。{{0}} 这种花括号经常被改写,换一种记号基本能解决。

短代码只改属性,正文不要在短代码这层翻

短代码出问题通常是两种:属性里的文案没翻,和同一段正文被翻了两遍。

代码语言:php
复制
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 过滤链之后。

区块属性和构建器 JSON 按路径改

这类数据结构不固定,但文案字段一般落在 text、title、content、label、heading、caption 这些键上。递归收集时记下完整路径,翻完按路径写回,不要重新拼整棵结构。

代码语言:php
复制
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,整段序列化、遮罩、翻译、整体替换回去。

代码语言:php
复制
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 的无闭合标签会让它直接失败;改用临时文档解析:

代码语言:php
复制
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;只处理叶子元素,容器节点整体替换会把里面的标签全清掉。

代码语言:js
复制
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 也是属性。这些必须单独走一遍。

代码语言:php
复制
$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 给直接访问根路径的访客。

代码语言:js
复制
function rememberLang(lang) {
  document.cookie =
    `site_lang=${encodeURIComponent(lang)}; Max-Age=31536000; Path=/; SameSite=Lax; Secure`;
  document.documentElement.lang = lang;
}

服务端读 cookie 做一次 302:

代码语言:php
复制
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> 要跟着改,不然读屏软件和浏览器翻译都会判断错。

上线前用 diff 对一遍数量

结构有没有被翻坏,数量对一下最快。翻译前后的页面各存一份:

代码语言:bash
复制
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)"
done

href 数量少了说明链接被合并或吃掉了,短代码数量对不上说明标签被拆了。再随机抽几条翻过的句子,看里面有没有残留 ⟦、{{、[[ 这类记号。

这套管线自己写不算难,麻烦的是长期跟着构建器的存储格式改。我们把它做成了免费插件 AumLang,做 AI 翻译、输出 hreflang,WooCommerce 商品页的字段也一起处理,可以拿它的实现对着上面几步看:aumcreate.com/plugins/aumlang。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先确认文案存在哪一层
  • 遮罩:先保护,翻完逐个核对
  • 短代码只改属性,正文不要在短代码这层翻
  • 区块属性和构建器 JSON 按路径改
  • 行内片段合成一个翻译单元,别逐节点翻
  • 动态插入的内容:观察器要能停下来
  • 文本节点之外的文案:属性、表单、头部
  • 语言选择怎么记住,缓存会怎么把它搞砸
  • 上线前用 diff 对一遍数量
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档