没有不值得去解决的问题,也没有不值得去学习的技术!

Fix SlyTranslate Translation Errors When Paragraph Runs Cannot Recognize Attributes

Figure 1: Paragraph Run error when SlyTranslate translates an article using GLM-5.2

作者:

Recently, while continuing to use SlyTranslate + GLM-5.2 to translate a Chinese WordPress blog, I ran into an issue that had occasionally appeared before.

The Chinese article had saved normally, and nothing looked wrong in the Gutenberg editor, but after clicking SlyTranslate’s “Translate now,” the translation failed outright:

Plaintext
A paragraph run did not contain a top-level paragraph.
Figure 1: Paragraph Run error when SlyTranslate translates an article using GLM-5.2
Figure 1: Paragraph Run error when SlyTranslate translates an article using GLM-5.2

I had encountered a similar situation before. At the time, I bypassed it with a somewhat odd workaround: adding an empty paragraph at the end of the Chinese article, saving it, deleting the empty paragraph, and then saving again.

Re-translating succeeded that time.

So this time I started by repeating that attempt a few times, but it no longer had any effect.

Since the article content itself showed no visible issues, repeatedly modifying the Gutenberg blocks seemed pointless, so I decided to track down exactly where the error was coming from.

1. First, locate the source of the error

I searched for the full error text in the WordPress production directory:

Bash
grep -RniF \
  --include='*.php' \
  --include='*.js' \
  'A paragraph run did not contain a top-level paragraph.' \
  wp-content/plugins \
  wp-content/mu-plugins

There was only one match:

Plaintext
wp-content/mu-plugins/swq-glm52-translation-tuning.php:2193:
return new WP_Error( 'swq_full_article_paragraph_run_empty', 'A paragraph run did not contain a top-level paragraph.' );
Figure 2: Locating the Paragraph Run error source in a custom MU Plugin via grep
Figure 2: Locating the Paragraph Run error source in a custom MU Plugin via grep

This narrowed down the troubleshooting scope significantly.

The error was not produced directly by the SlyTranslate core plugin, but by an MU Plugin I had previously added for full-article Gutenberg translation with GLM-5.2:

swq-glm52-translation-tuning.php

Therefore, the problem was more likely in my own Paragraph Run parsing and restoration logic.

2. The purpose of a Paragraph Run

To allow long Gutenberg articles to be translated by AI as a whole while protecting the WordPress block structure as much as possible, I had previously added a Paragraph Run mechanism.

Simply put, consecutive standard top-level paragraphs can form a Paragraph Run.

For example:

HTML
<p>第一段。</p>
<p>第二段。</p>
<p>第三段。</p>

These consecutive paragraphs can be sent to the model for translation as a single unit.

The number of paragraphs before and after translation does not need to match exactly.

During natural language translation, the model might decide that merging two short Chinese paragraphs into one English paragraph is more natural, or it might split one long Chinese paragraph into two English paragraphs.

Therefore, a Paragraph Run itself allows:

3 个 paragraph → 2 个 paragraph

It also allows:

1 个 paragraph → 2 个 paragraph

What truly needs to be strictly preserved is the Gutenberg structure outside the Paragraph Run, such as headings, lists, quotes, images, code blocks, tables, and other protected blocks.

These structures must not be deleted, crossed over, or arbitrarily rearranged during translation.

3. The real problem lies in <p> parsing

After continuing to inspect the code near the error, I quickly found a suspicious spot:

PHP
preg_match_all( '~<p>(.*?)</p>~su', $html, $matches, PREG_OFFSET_CAPTURE );

if ( empty( $matches[0] ) ) {
    return new WP_Error(
        'swq_full_article_paragraph_run_empty',
        'A paragraph run did not contain a top-level paragraph.'
    );
}

The original regex only recognized a strict:

HTML
<p>Text</p>

But it could not recognize:

HTML
<p class="model">Text</p>

Or:

HTML
<p style="color:red">Text</p>

From an HTML perspective, these are still <p> paragraphs.

But for the original Paragraph Run parser, any attribute on the opening tag prevented a match.

If no content matching ~<p>(.*?)</p>~ was found within a Paragraph Run, it would return:

swq_full_article_paragraph_run_empty

Which ultimately displayed in SlyTranslate as:

A paragraph run did not contain a top-level paragraph.

So this error message did not actually mean the program had performed a full DOM analysis and confirmed “no top-level paragraph.”

What actually happened was:

The paragraph wrapper returned by the model was no longer strictly a bare <p>, and the old parser only recognized bare <p>.

4. The problem stems from wrapper drift in the model’s output

The source article itself was not the focus of this issue.

The current Paragraph Run logic only places qualifying standard Gutenberg paragraphs into a run; paragraphs with special Gutenberg attributes still go through a stricter structural protection path.

What could actually change was the AI’s response.

For example, what was sent to the model was:

HTML
<p>第一段。</p>
<p>第二段。</p>

Under normal circumstances, it should return:

HTML
<p>First paragraph.</p>
<p>Second paragraph.</p>

But the model might occasionally add HTML attributes on its own:

HTML
<p class="something">First paragraph.</p>
<p>Second paragraph.</p>

To a browser, the first example is still a perfectly understandable <p>.

But to the original regex:

Plaintext
~<p>(.*?)</p>~

it was no longer a qualifying paragraph.

This also explains why the article showed no visible problems in the Gutenberg editor.

What had actually changed was not the Chinese source article, but the HTML returned by the translation model.

5. Ultimately, making only a minimal compatibility fix

Since the existing translation process was already running quite stably, I did not redesign the Paragraph Run this time, nor did I change its original merge/split behavior.

I ultimately modified only two files:

swq-glm52-translation-tuning.php

swq-paragraph-run-test.php

The entire commit was only:

Plaintext
2 files changed, 12 insertions(+), 6 deletions(-)

The core change was adjusting the original:

PHP
preg_match_all( '~<p>(.*?)</p>~su', $html, $matches, PREG_OFFSET_CAPTURE );

to:

PHP
preg_match_all( '~<p(?:\s+[^<>]*)?>(.*?)</p\s*>~isu', $html, $matches, PREG_OFFSET_CAPTURE );

This way, in addition to the original:

HTML
<p>...</p>

it can also recognize the occasional model output of:

HTML
<p class="model">...</p>

or:

HTML
<p style="color:red">...</p>

However, simply being able to recognize them was not enough.

The paragraph attributes generated by the model itself should not enter the final Gutenberg article.

So during restoration, instead of directly using the full wrapper returned by the model, I only extract its inner HTML:

PHP
$paragraphs[] = '<p>' . $inner . '</p>';

For example, if the model returns:

HTML
<p class="model">English A.</p>

locally, only this is preserved:

HTML
<p>English A.</p>

Then a standard Gutenberg paragraph is regenerated:

HTML
<!-- wp:paragraph -->
<p>English A.</p>
<!-- /wp:paragraph -->

In other words, the principle of this fix is:

It can tolerate the model occasionally adding attributes to a <p> wrapper, but it will not trust or save those attributes.

Figure 3: The final Git diff only expands <p> wrapper parsing and discards model-generated attributes during restoration
Figure 3: The final Git diff only expands <p> wrapper parsing and discards model-generated attributes during restoration

6. Existing structural protection was not relaxed

Supporting <p class> and <p style> does not mean the Paragraph Run now accepts arbitrary HTML.

The original structural protection rules still exist.

For example, Gutenberg comments, headings, lists, images, blockquotes, pre, nested paragraphs, and non-paragraph root node content are still rejected.

Empty paragraphs and paragraphs containing only punctuation also still fail validation.

Existing checks like protected tokens and inline HTML balance were not relaxed by this change.

Therefore, this did not broaden the Paragraph Run validation rules as a whole.

The only thing actually relaxed was a very specific compatibility boundary:

It originally only recognized bare <p>, but now it can recognize <p> with attributes occasionally returned by the model, and then renormalize them into bare <p> during restoration.

7. Confirming that existing merge/split capabilities were not affected

After modifying the code, I reran the Paragraph Run tests:

Bash
php swq-paragraph-run-test.php

All tests passed.

I was particularly concerned with the existing paragraph reorganization capabilities:

Plaintext
PASS: post 25425 pattern: consecutive source paragraphs may merge without paragraph STRUCT tokens
PASS: merged run restores one standard Gutenberg paragraph
PASS: one source paragraph may split into two target paragraphs
PASS: split run restores two standard Gutenberg paragraphs
PASS: three source paragraphs may become two target paragraphs

This showed that the original Paragraph Run behavior had not changed due to this bug fix.

The two new categories of cases also passed:

Plaintext
PASS: model class paragraph wrapper is accepted as a non-structural wrapper
PASS: model class paragraph wrapper attributes are discarded during standard restoration
PASS: model style paragraph wrapper is accepted as a non-structural wrapper
PASS: model style paragraph wrapper attributes are discarded during standard restoration

Final result:

Plaintext
ALL TESTS PASSED

At the same time, various previously invalid cases continued to be rejected.

This was more important than simply making the current article translate successfully, because a localized bug fix should not break other behaviors that had been working correctly for a long time.

8. Production verification using the previously failed article

After completing the modifications, I first backed up the original swq-glm52-translation-tuning.php in the production environment, then deployed the fixed version to WordPress.

During deployment, I verified the file using SHA-256 and ran a PHP syntax check again.

Afterward, I did not modify the Chinese article that had produced the error.

I did not add an empty paragraph, nor did I delete one; I did not adjust Gutenberg blocks, nor did I attempt to change its serialization by resaving the article.

I directly clicked again on the original article:

Translate now

This time, the translation succeeded.

This result essentially completed the verification loop for the entire issue:

The same article, the same Chinese text, and after only modifying the Paragraph Run parser, the original error disappeared.

Therefore, the interference from the previous accidental success of adding and deleting an empty paragraph could be ruled out.

What actually needed fixing this time was a compatibility issue between the model output and the local parser.

9. Committing the fix

Only after the real translation succeeded in the production environment did I officially commit the code:

Plaintext
3e2d43c
fix: 修复 paragraph run 带属性 p 标签解析

The final scale of the changes was still only:

Plaintext
2 files changed, 12 insertions(+), 6 deletions(-)

For an automated translation process that is already running fairly stably, I prefer to see this scale of change.

Find the specific bug, then patch only this compatibility boundary, rather than readjusting the entire translation protocol for a low-probability anomaly.

10. Summary

Initially seeing:

Plaintext
A paragraph run did not contain a top-level paragraph.

made it easy to suspect a structural problem with the Chinese Gutenberg article itself.

The fact that “adding an empty paragraph, deleting it, and resaving” had occasionally resolved similar issues before made it even easier to steer the investigation toward article serialization.

But the actual problem was very small:

The model occasionally returned:

HTML
<p class="...">...</p>

while the old Paragraph Run parser only recognized:

HTML
<p>...</p>

As a result, a paragraph that was actually still valid was misjudged as “not containing a top-level paragraph.”

The final solution was also fairly simple:

Expand the recognition range of the <p> wrapper, but extract only the inner HTML; any attributes added by the model do not enter the final Gutenberg content.

This resolved the current translation failure without changing the Paragraph Run’s existing merge/split behavior or relaxing other Gutenberg structural protections.

For a translation process that has already been validated by a large volume of articles and is generally running stably, I increasingly prefer this approach:

Locate the specific problem, make a minimal fix, use regression tests to protect existing behavior, and then perform final verification with a real failed article.

This time, the problem was solved, and the original translation capabilities were fully preserved.

系列导航

需要长期技术维护或远程问题排查?

我是拥有 15+ 年经验的 PHP / Go 后端工程师,长期关注已有系统维护、Bug 修复、性能优化、服务器排查、WordPress 网站维护和小功能迭代。

如果你的项目遇到以下情况,可以先从一次小问题排查开始合作:

  • ✅ PHP / Laravel / Yii2 老项目无人维护
  • ✅ Go / Gin 后端接口需要排查或优化
  • ✅ WordPress 网站访问慢、报错或插件冲突
  • ✅ Nginx / MySQL / Redis / Linux 服务器异常
  • ✅ CDN / Cloudflare / DNS / HTTPS 配置问题
  • ✅ 需要长期远程技术支持或兼职维护

更多介绍请查看:关于我 & 合作

微信:13980074657
邮箱:shuijingwanwq@gmail.com
Telegram:@shuijingwan
GitHub:https://github.com/shuijingwan