fix: bound render-summary strings, restore postdata globals, drain buffers

Review fix round 1 for lit/page-render-summary: extract shared Text::shorten()
(reused by NodeSummary), replace data: URIs with a short marker, prefer
data-src/data-lazy-src over placeholder src, dedup nested p/li/blockquote
text and ignore script/style/noscript content, restore every setup_postdata
global (not just $post), drain output buffers robustly around the_content,
and disclose that rendering can trigger oEmbed/network requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 12:31:20 +02:00
co-authored by Claude Opus 5.5
parent 2e9acd686a
commit b3cd0eee62
7 changed files with 345 additions and 47 deletions
@@ -9,28 +9,60 @@ use WP_Post;
/**
* Renders the content area of an entry in-process, like the front end does for the_content
* (Divi 5 renders its blocks there – verified in DDEV). Header, footer and Theme Builder
* layouts are not part of it. Not final so abilities can be unit-tested with a mock.
* layouts are not part of it. Rendering runs every plugin's/theme's content filters and
* shortcodes exactly as the front end would, which can trigger network requests (e.g. oEmbed
* lookups) that get cached by WordPress or other plugins as a side effect. Not final so
* abilities can be unit-tested with a mock.
*/
class PageRenderer
{
/**
* Globals setup_postdata() populates from the post loop (WP_Query::the_post()); all of
* them are restored to their pre-render value afterwards, not just $post itself.
*/
private const POSTDATA_GLOBALS = ['id', 'authordata', 'pages', 'page', 'more', 'numpages', 'multipage'];
public function render(WP_Post $post): string
{
$previous = $GLOBALS['post'] ?? null;
$previousPost = $GLOBALS['post'] ?? null;
$previousGlobals = [];
foreach (self::POSTDATA_GLOBALS as $name) {
$previousGlobals[$name] = $GLOBALS[$name] ?? null;
}
$GLOBALS['post'] = $post;
setup_postdata($post);
$level = ob_get_level();
ob_start();
try {
setup_postdata($post);
$html = (string) apply_filters('the_content', (string) $post->post_content);
} finally {
$echoed = (string) ob_get_clean();
$GLOBALS['post'] = $previous;
if ($previous instanceof WP_Post) {
setup_postdata($previous);
} else {
wp_reset_postdata();
$echoed = self::drain($level);
$GLOBALS['post'] = $previousPost;
foreach ($previousGlobals as $name => $value) {
$GLOBALS[$name] = $value;
}
}
return $echoed . $html;
}
/**
* Pops every output buffer opened while rendering, including one a rogue shortcode/filter
* left open (drained here instead of leaking into the caller) – but never one it already
* closed for us: the loop only pops down to the level recorded before our own ob_start(),
* so a filter that closes one buffer too many simply loses whatever was already echoed
* (nothing left to drain) rather than us popping a buffer that belongs to the caller.
* Buffers are combined in the order their content was written (innermost popped first,
* then prepended) so a leaked nested buffer's output stays after what came before it.
*/
private static function drain(int $level): string
{
$output = '';
while (ob_get_level() > $level) {
$output = (string) ob_get_clean() . $output;
}
return $output;
}
}