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:
A paragraph run did not contain a top-level paragraph.
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:
grep -RniF \
--include='*.php' \
--include='*.js' \
'A paragraph run did not contain a top-level paragraph.' \
wp-content/plugins \
wp-content/mu-pluginsThere was only one match:
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.' );
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:
<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:
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:
<p>Text</p>But it could not recognize:
<p class="model">Text</p>Or:
<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:
<p>第一段。</p>
<p>第二段。</p>Under normal circumstances, it should return:
<p>First paragraph.</p>
<p>Second paragraph.</p>But the model might occasionally add HTML attributes on its own:
<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:
~<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:
2 files changed, 12 insertions(+), 6 deletions(-)The core change was adjusting the original:
preg_match_all( '~<p>(.*?)</p>~su', $html, $matches, PREG_OFFSET_CAPTURE );to:
preg_match_all( '~<p(?:\s+[^<>]*)?>(.*?)</p\s*>~isu', $html, $matches, PREG_OFFSET_CAPTURE );This way, in addition to the original:
<p>...</p>it can also recognize the occasional model output of:
<p class="model">...</p>or:
<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:
$paragraphs[] = '<p>' . $inner . '</p>';For example, if the model returns:
<p class="model">English A.</p>locally, only this is preserved:
<p>English A.</p>Then a standard Gutenberg paragraph is regenerated:
<!-- 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.

<p> wrapper parsing and discards model-generated attributes during restoration6. 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:
php swq-paragraph-run-test.phpAll tests passed.
I was particularly concerned with the existing paragraph reorganization capabilities:
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 paragraphsThis showed that the original Paragraph Run behavior had not changed due to this bug fix.
The two new categories of cases also passed:
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 restorationFinal result:
ALL TESTS PASSEDAt 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:
3e2d43c
fix: 修复 paragraph run 带属性 p 标签解析The final scale of the changes was still only:
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:
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:
<p class="...">...</p>while the old Paragraph Run parser only recognized:
<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
