先说结论
页面构建器把内容存在三个不同的地方,每处的翻译方式和坑都不一样:
| 内容形态 | 存在哪 | 长什么样 | 翻译时的核心风险 |
|---|---|---|---|
| 短代码型 | post_content |
[row][col][button text="Get a quote"] |
方括号和标签名被机器翻译改写 |
| 区块型 | post_content |
<!-- wp:paragraph {"align":"center"} --> |
HTML 注释被改坏,整块变成「无效区块」 |
| 构建器私有数据 | postmeta |
PHP 序列化数组或 JSON | 直接对原始字符串替换 → 反序列化失败,整页数据丢失 |
下面按这三类拆开,每类给一个能跑的写法和一个验收方法。
第 0 步:先确认你的站在哪一类
别猜。跑两条 SQL 看清楚。
sql
-- 看页面正文的长度和开头,判断是短代码型还是区块型
SELECT ID, post_title, LENGTH(post_content) AS len, LEFT(post_content, 80) AS head
FROM wp_posts
WHERE post_type = 'page' AND post_status = 'publish'
ORDER BY len ASC
LIMIT 20;
如果 post_content 很短、甚至只有一行占位文字,那内容多半在 meta 里:
sql
SELECT post_id, meta_key, LENGTH(meta_value) AS len
FROM wp_postmeta
WHERE LENGTH(meta_value) > 2000
ORDER BY len DESC
LIMIT 20;
这两种情况可能同时存在:正文里是短代码,短代码背后的数据(比如产品表、价格表)在 meta 里。
坑一:短代码
1.1 标签本身绝不能进翻译
arduino
[button text="Get a quote" url="/contact" size="large"]
把这一整串丢给机器翻译,常见结果是:
- 标签名被翻:
[按钮 text="..."] - 全角化:
[button]------ 方括号变成中文全角括号,短代码直接失配 - 属性名被翻:
[button 文本="..." 网址="..."] - 标签之间被插空格:
[ button ]
任何一种发生,前端都会把原始字符串漏在页面上给访客看。
1.2 属性要分两类,不能一刀切
| 该翻的属性 | 绝不能翻的属性 |
|---|---|
text title label heading caption placeholder alt button_text content |
url href link id post_id image_id size color class icon target columns gap animation |
判断依据很简单:这个值改了,页面的行为或外观会不会变? 会变就别翻。
1.3 做法:先遮罩,再翻,最后原样还原
用 WordPress 自带的 get_shortcode_regex() 把短代码整段抠出来换成占位符,翻完再换回去。这样翻译接口根本看不到方括号。
php
/**
* 把内容里的短代码整体替换成占位符。
*
* @return array{0:string,1:array<string,string>} [遮罩后的内容, 占位符 => 原文]
*/
function aum_mask_shortcodes( string $content ): array {
if ( ! function_exists( 'get_shortcode_regex' ) ) {
return [ $content, [] ];
}
$map = [];
$n = 0;
$masked = preg_replace_callback(
'/' . get_shortcode_regex() . '/s',
static function ( array $m ) use ( &$map, &$n ): string {
$token = '[[AUMSC' . ( ++$n ) . ']]';
$map[ $token ] = $m[0];
return $token;
},
$content
);
return [ null === $masked ? $content : $masked, $map ];
}
function aum_unmask_shortcodes( string $content, array $map ): string {
return strtr( $content, $map );
}
用法:
php
[ $masked, $map ] = aum_mask_shortcodes( $original );
$translated = aum_translate_text( $masked ); // 你自己的翻译调用
$translated = aum_unmask_shortcodes( $translated, $map );
1.4 校验:占位符有没有被改坏
占位符本身也可能被翻译接口加空格、换全角。还原之后一定要验一次。
php
/** 返回原文和译文的短代码标签名列表,用于比对 */
function aum_shortcode_tags( string $content ): array {
preg_match_all( '/' . get_shortcode_regex() . '/s', $content, $m );
$tags = $m[2] ?? [];
sort( $tags );
return $tags;
}
function aum_shortcode_roundtrip_ok( string $original, string $translated ): bool {
// 1) 标签集合必须完全一致
if ( aum_shortcode_tags( $original ) !== aum_shortcode_tags( $translated ) ) {
return false;
}
// 2) 不能有残留占位符
if ( false !== strpos( $translated, '[[AUMSC' ) ) {
return false;
}
return true;
}
不通过就整篇不落库,人工看一眼再重跑。别让它静默上线。
1.5 短代码里的文案属性怎么翻
如果 text="Get a quote" 这种属性值也要翻,按属性名白名单单独处理:
php
function aum_translate_shortcode_attrs( string $shortcode, array $whitelist, callable $translate ): string {
return (string) preg_replace_callback(
'/([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/',
static function ( array $m ) use ( $whitelist, $translate ): string {
if ( ! in_array( strtolower( $m[1] ), $whitelist, true ) ) {
return $m[0];
}
return $m[1] . '="' . $translate( $m[2] ) . '"';
},
$shortcode
);
}
注意这段只覆盖双引号包裹的属性。单引号、无引号的写法要另外加分支,否则会漏。
坑二:区块(HTML 注释型)
2.1 注释被改坏,整块失效
xml
<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center">Hello</p>
<!-- /wp:paragraph -->
机器翻译常见的破坏方式:把 wp: 变成 wp:(全角冒号)、把标签名翻成中文、把 JSON 里的引号换成中文引号、把注释整体删掉。结果就是编辑器里打开显示「此区块包含意外内容」,前端样式全乱。
所以:注释一个字符都不能进翻译接口。只翻 HTML 文本节点。
2.2 先走文本节点,再走白名单属性
用 DOM 而不是正则。正则处理嵌套标签和转义字符会翻车。
php
function aum_translate_block_html( string $html, callable $translate ): string {
if ( '' === trim( $html ) ) {
return $html;
}
$prev = libxml_use_internal_errors( true );
$dom = new DOMDocument();
$dom->loadHTML(
'<?xml encoding="utf-8" ?>' . $html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors( $prev );
$xpath = new DOMXPath( $dom );
// 只翻文本节点,跳过 script / style 内容
foreach ( $xpath->query( '//text()' ) as $node ) {
$parent = $node->parentNode;
$pname = $parent instanceof DOMElement ? strtolower( $parent->nodeName ) : '';
if ( in_array( $pname, [ 'script', 'style' ], true ) ) {
continue;
}
if ( '' === trim( (string) $node->nodeValue ) ) {
continue;
}
$node->nodeValue = $translate( (string) $node->nodeValue );
}
// 只翻这几个属性;class / id / href / src 一律不动
foreach ( $xpath->query( '//*[@alt or @title or @placeholder or @aria-label]' ) as $el ) {
if ( ! $el instanceof DOMElement ) {
continue;
}
foreach ( [ 'alt', 'title', 'placeholder', 'aria-label' ] as $attr ) {
if ( $el->hasAttribute( $attr ) ) {
$el->setAttribute( $attr, $translate( $el->getAttribute( $attr ) ) );
}
}
}
$out = '';
foreach ( $dom->childNodes as $child ) {
if ( XML_PI_NODE === $child->nodeType ) { // 跳过我们塞进去的 encoding 声明
continue;
}
$out .= $dom->saveHTML( $child );
}
return $out;
}
用 DOM 的另一个好处是 &、<、引号会被自动转义,不会因为译文里带了特殊字符把 HTML 撑破。
2.3 遍历区块树时,innerHTML 和 innerContent 会重复翻
parse_blocks() 解析出来的结构里,innerHTML 是整段内部 HTML,innerContent 是切片(内层区块的位置是 null)。两个都翻等于同一段文字翻两遍,第二遍把已经翻好的译文再翻一次,输出会变形。
正确做法:优先只翻 innerContent 里的字符串片段;只有在 innerContent 为空时,退回翻 innerHTML。
php
function aum_walk_blocks( array $blocks, callable $translate ): array {
foreach ( $blocks as &$block ) {
// blockName 为 null 的是自由 HTML / 无效块,保守起见原样保留
if ( empty( $block['blockName'] ) ) {
continue;
}
if ( ! empty( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
foreach ( $block['innerContent'] as $i => $chunk ) {
if ( is_string( $chunk ) && '' !== trim( $chunk ) ) {
$block['innerContent'][ $i ] = aum_translate_block_html( $chunk, $translate );
}
// null 是内层区块占位,不动
}
} elseif ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) {
$block['innerHTML'] = aum_translate_block_html( $block['innerHTML'], $translate );
}
if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
$block['attrs'] = aum_translate_attrs( $block['attrs'], $translate );
}
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = aum_walk_blocks( $block['innerBlocks'], $translate );
}
}
unset( $block );
return $blocks;
}
const AUM_TRANSLATABLE_ATTR_KEYS = [
'alt', 'title', 'caption', 'label', 'placeholder', 'text', 'content', 'heading',
];
function aum_translate_attrs( array $attrs, callable $translate ): array {
foreach ( $attrs as $key => $value ) {
$k = is_string( $key ) ? strtolower( $key ) : '';
if ( is_string( $value ) && in_array( $k, AUM_TRANSLATABLE_ATTR_KEYS, true ) ) {
$attrs[ $key ] = $translate( $value );
} elseif ( is_array( $value ) ) {
$attrs[ $key ] = aum_translate_attrs( $value, $translate );
}
// 数字、布尔、null 一律不动
}
return $attrs;
}
处理完整篇:
php
$blocks = parse_blocks( $original );
$blocks = aum_walk_blocks( $blocks, 'aum_translate_text' );
$translated = serialize_blocks( $blocks );
2.4 一个必须知道的副作用
serialize_blocks( parse_blocks( $x ) ) 不等于 $x。属性顺序、空白、自闭合写法都可能变。功能上没问题,但如果你想用「原文和译文 diff」来做人工校对,这个 diff 会非常吓人。校对建议直接看前端渲染结果,别 diff 源码。
2.5 校验:区块注释计数
php
function aum_count_block_delimiters( string $content ): int {
return (int) preg_match_all( '/<!--\s+wp:/', $content );
}
原文和译文的计数必须相等。少了说明注释被吃掉,多了说明译文里凭空产生了 <!-- wp:。
坑三:动态内容(构建器私有数据)
3.1 最致命的一个:直接对序列化字符串做替换
PHP 序列化的数组长这样:
css
s:12:"Hello world!";
前面的 12 是字节长度。如果你在数据库里直接跑 UPDATE ... REPLACE(meta_value, 'Hello world!', '你好,世界!'),字符串变长了但长度标记还是 12,unserialize() 会返回 false,这一页的构建器数据全部作废,打开编辑器是一片空白。
正确路径只有一条:
php
$raw = get_post_meta( $post_id, $meta_key, true ); // 拿到的已经是反序列化后的数组/对象
// ...改数组...
update_post_meta( $post_id, $meta_key, $new_value ); // 交给 WP 重新序列化
如果手上只有原始字符串(比如从备份文件里读的),先 maybe_unserialize(),改完再 maybe_serialize(),别绕过。
3.2 递归时按 key 白名单跳过
私有数据里混着 ID、URL、颜色值、图标名、CSS。这些翻了页面就废。
php
const AUM_META_SKIP_KEYS = [
'id', 'post_id', 'attachment_id', 'url', 'href', 'link', 'src',
'class', 'css', 'color', 'background', 'icon', 'size', 'width', 'height',
'type', 'template', 'target', 'animation', 'condition',
];
function aum_translate_meta_value( $value, callable $translate, string $key = '' ) {
if ( is_string( $value ) ) {
if ( in_array( strtolower( $key ), AUM_META_SKIP_KEYS, true ) ) {
return $value;
}
if ( '' === trim( $value ) ) {
return $value;
}
// URL、锚点、纯数字/单位、十六进制颜色,一律不翻
if ( preg_match( '#^(https?://|/|\#|\#[0-9a-fA-F]{3,8}$|[\d\.,\s%+\-]+$)#', $value ) ) {
return $value;
}
return $translate( $value );
}
if ( is_array( $value ) ) {
$out = [];
foreach ( $value as $k => $v ) {
$out[ $k ] = aum_translate_meta_value( $v, $translate, is_string( $k ) ? $k : $key );
}
return $out;
}
return $value; // int / float / bool / null
}
function aum_translate_post_meta( int $post_id, array $meta_keys, callable $translate ): void {
foreach ( $meta_keys as $key ) {
$value = get_post_meta( $post_id, $key, true );
if ( '' === $value || null === $value ) {
continue;
}
update_post_meta( $post_id, $key, aum_translate_meta_value( $value, $translate ) );
}
}
$meta_keys 别写死猜出来的名字,先用第 0 步那条 SQL 查出来再填。
3.3 模板复制了,显示条件没跟上
页头、页脚、全局区块通常是单独一条模板记录,翻译时要复制成两份(每语言一份)。坑在于:内容复制了,但「在哪些页面显示」的条件存在另一个 meta 里没复制。结果英文站用的是中文站那份页头,或者两份页头同时出现在同一个页面上。
复制模板时,把这一条模板的所有 meta 一起复制,再逐条检查显示条件里的页面 ID 是否需要换成译文的 ID。
3.4 引用型 ID 要建映射表
bash
[product id="12"]
这里的 12 是中文站的产品,英文站对应的是另一个 ID。翻译不会帮你改,上线后英文页面会显示中文产品。
php
function aum_remap_shortcode_ids( string $content, array $id_map ): string {
return (string) preg_replace_callback(
'/(\[[a-z0-9_-]+[^\]]*?\b(?:id|post_id|product_id|form_id)\s*=\s*")(\d+)(")/i',
static function ( array $m ) use ( $id_map ): string {
$new = $id_map[ (int) $m[2] ] ?? null;
return null === $new ? $m[0] : $m[1] . $new . $m[3];
},
$content
);
}
这只处理短代码属性里的数字。区块 attrs 里的 JSON 数字要在 aum_translate_attrs() 里单独处理,两套逻辑别混。
3.5 内链要换,外链别动
译文里的站内链接应该指向译文页面,站外链接保持原样。判断标准就一条:这个 href 的 host 是不是你自己的站。
php
function aum_localize_links( string $html, array $url_map ): string {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
return (string) preg_replace_callback(
'/(href\s*=\s*")([^"]+)(")/i',
static function ( array $m ) use ( $url_map, $host ): string {
$url = $m[2];
if ( wp_parse_url( $url, PHP_URL_HOST ) !== $host ) {
return $m[0]; // 外链不动
}
$path = wp_parse_url( $url, PHP_URL_PATH );
return isset( $url_map[ $path ] ) ? $m[1] . $url_map[ $path ] . $m[3] : $m[0];
},
$html
);
}
上线前的自动检查
对每一篇译文跑完这几条再落库:
php
function aum_preflight( string $original, string $translated ): array {
$errors = [];
if ( aum_shortcode_tags( $original ) !== aum_shortcode_tags( $translated ) ) {
$errors[] = '短代码标签集合不一致';
}
if ( false !== strpos( $translated, '[[AUMSC' ) ) {
$errors[] = '有占位符没还原';
}
if ( aum_count_block_delimiters( $original ) !== aum_count_block_delimiters( $translated ) ) {
$errors[] = '区块注释计数不一致';
}
if ( false !== strpos( $translated, '[' ) || false !== strpos( $translated, ']' ) ) {
$errors[] = '出现全角方括号,短代码可能已被破坏';
}
if ( preg_match( '/<!--\s*[((]/', $translated ) ) {
$errors[] = '注释里出现可疑字符';
}
return $errors;
}
人工验收清单
自动检查能挡住结构性问题,剩下这些必须人眼过:
- 前端源码里搜
[:有没有裸露的短代码字符串输出给访客。 - 编辑器里打开:有没有「此区块包含意外内容」或区块面板变灰。
- 点一遍所有交互:按钮跳转、表单提交、折叠面板、轮播。
- 图注和 alt:翻译了没有,有没有把文件名塞进 alt。
- 数字和单位:价格、规格、尺寸、日期。数字不该变形,货币符号和单位要符合目标语言习惯,但不能出现「US$399 美元」这种叠加。
- 页头页脚是否只出现一次:模板复制后最容易在这里翻车。
- 站内链接抽样 10 条:是不是都指向译文,有没有 404。
小结
三类内容,三条规矩:
- 短代码:整体遮罩,只按属性名白名单翻值,还原后必须校验标签集合。
- 区块:注释不进翻译接口,只用 DOM 翻文本节点和白名单属性,
innerContent和innerHTML别同时翻。 - 动态内容:走
get_post_meta/update_post_meta,永远不要对序列化后的原始字符串做字符串替换。
把这三条做成流水线里的硬性检查,翻译就不会变成一次性的手工活。
我们自己在做 WordPress 站点翻译时用的就是这套遮罩 → 翻译 → 校验的流程,相关的实现思路放在 AumLang 插件页,插件本身在 wordpress.org 免费,代码可以对照着看。