页面构建器做的页面翻译:短代码、区块、动态内容各自会踩什么坑

先说结论

页面构建器把内容存在三个不同的地方,每处的翻译方式和坑都不一样:

内容形态 存在哪 长什么样 翻译时的核心风险
短代码型 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;
}

人工验收清单

自动检查能挡住结构性问题,剩下这些必须人眼过:

  1. 前端源码里搜 [:有没有裸露的短代码字符串输出给访客。
  2. 编辑器里打开:有没有「此区块包含意外内容」或区块面板变灰。
  3. 点一遍所有交互:按钮跳转、表单提交、折叠面板、轮播。
  4. 图注和 alt:翻译了没有,有没有把文件名塞进 alt。
  5. 数字和单位:价格、规格、尺寸、日期。数字不该变形,货币符号和单位要符合目标语言习惯,但不能出现「US$399 美元」这种叠加。
  6. 页头页脚是否只出现一次:模板复制后最容易在这里翻车。
  7. 站内链接抽样 10 条:是不是都指向译文,有没有 404。

小结

三类内容,三条规矩:

  • 短代码:整体遮罩,只按属性名白名单翻值,还原后必须校验标签集合。
  • 区块:注释不进翻译接口,只用 DOM 翻文本节点和白名单属性,innerContent 和 innerHTML 别同时翻。
  • 动态内容:走 get_post_meta / update_post_meta,永远不要对序列化后的原始字符串做字符串替换。

把这三条做成流水线里的硬性检查,翻译就不会变成一次性的手工活。

我们自己在做 WordPress 站点翻译时用的就是这套遮罩 → 翻译 → 校验的流程,相关的实现思路放在 AumLang 插件页,插件本身在 wordpress.org 免费,代码可以对照着看。

相关推荐
阿文和她的Key1 小时前
PHP2Shell事故四层拆解:AI找洞是小时级,披露协调还是邮件级
网络·人工智能·架构·php
贵州诚鑫致达科技有限公司4 小时前
PHP两个9.2分RCE漏洞,速查版本
服务器·php·1024程序员节
hudie7655 小时前
LikeShop 二开最常改的 5 个文件(附改前改后对比)
前端·uni-app·php·个人开发
东宇科技6 小时前
crmeb开源版本安装步骤2026-10-20
后端·php·crmeb
CRMEB系统商城7 小时前
Redis + 消息队列 + 连接池:CRMEB PRO 性能优化的三驾马车
大数据·数据库·人工智能·小程序·php·1024程序员节
李纲明9 小时前
WordPress 挂上 Cloudflare 后询盘和后台异常:缓存规则、SSL 模式和真实 IP 怎么核
运维·缓存·https·cdn·wordpress·cloudflare
CRMEB系统商城9 小时前
服装连锁的导购分销与企微私域
java·大数据·开发语言·人工智能·小程序·php·1024程序员节
宁波阿成9 小时前
CentOS 7.5 升级 PHP 至 8.3.36 完整实操记录
linux·centos·php