diff --git a/citecue.php b/citecue.php
index 5a509da..71dc938 100644
--- a/citecue.php
+++ b/citecue.php
@@ -3,7 +3,7 @@
* Plugin Name: CiteCue AI Auto-Fix
* Plugin URI: https://github.com/citecue/wordpress-plugin
* Description: Serves CiteCue-optimized versions of your pages to AI bots and crawlers, adds CiteCue's enriched SEO metadata to your live pages, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress.
- * Version: 1.1.1
+ * Version: 1.1.2
* Requires at least: 5.8
* Requires PHP: 7.4
* Author: CiteCue
@@ -138,7 +138,7 @@
return;
}
-define( 'CITECUE_VERSION', '1.1.1' );
+define( 'CITECUE_VERSION', '1.1.2' );
define( 'CITECUE_PLUGIN_FILE', __FILE__ );
define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php
index d635a24..40a1f0e 100644
--- a/includes/class-citecue-seo-head.php
+++ b/includes/class-citecue-seo-head.php
@@ -12,17 +12,28 @@
* content-parity and additive, so adding it is not cloaking, whereas serving a
* rewritten document to a human would be.
*
- * Four rules govern everything below.
+ * Five rules govern everything below.
*
* **Never emit a duplicate.** WordPress core prints `
` and
* `` on its own, and Yoast, Rank Math, AIOSEO, SEOPress,
* The SEO Framework, Slim SEO and Jetpack each print some combination of
* title, description, OpenGraph and JSON-LD. A second `` is invalid
* HTML and a second canonical makes Google pick one arbitrarily — so this
- * fills gaps only: it captures the rendered `` and drops every CiteCue
- * tag whose slot is already taken. Detecting emitted markup rather than
- * sniffing for `WPSEO_VERSION` is what makes that correct against SEO plugins
- * and themes nobody here has heard of.
+ * fills gaps only: it reads the rendered `` and drops every CiteCue tag
+ * whose slot is already taken. Detecting emitted markup rather than sniffing
+ * for `WPSEO_VERSION` is what makes that correct against SEO plugins and
+ * themes nobody here has heard of.
+ *
+ * **Never leave an output buffer open.** Reading the rendered head means
+ * buffering, and a buffer a plugin opens but does not itself close is one the
+ * next component's `ob_get_clean()` can take by mistake — the buffer stack
+ * misaligns and somebody else's page breaks (WordPress.org plugin review).
+ * Nothing here calls `ob_get_clean()`, `ob_end_flush()` or any other closing
+ * function, so nothing here can take a buffer it did not open or forget one it
+ * did. Since WordPress 6.9 core opens the buffer and hands the finished
+ * document to a filter, and that is the entire mechanism; below 6.9 the buffer
+ * is opened in the one form PHP finalizes on its own — `ob_start()` with a
+ * callback — so no hook, early return or fatal can leave it dangling.
*
* **Never block a human.** The proxy may spend a request budget on an outbound
* call because only a bot is waiting. Here a real visitor is, so the render
@@ -64,14 +75,14 @@ class Citecue_Seo_Head {
const REFRESH_LOCK_TTL = MINUTE_IN_SECONDS;
/**
- * `template_redirect` priority the capture opens at. After the crawler
- * proxy and the llms.txt handler, which own priority 0 and both `exit`, so
- * a request either of them serves never opens a buffer here.
+ * `template_redirect` priority the capture is arranged at: last, after
+ * every other callback on the action. The crawler proxy and the llms.txt
+ * handler own priority 0 and both `exit`, core's `redirect_canonical` runs
+ * at 10, and a membership or maintenance plugin redirects here too — so
+ * running last means a request somebody else answers never arranges a
+ * capture at all.
*/
- const CAPTURE_START_PRIORITY = 1;
-
- /** `wp_head` priority the capture closes and injects at: after everyone. */
- const CAPTURE_END_PRIORITY = PHP_INT_MAX;
+ const CAPTURE_PRIORITY = PHP_INT_MAX;
/**
* `` relations that may be injected, and `` values are escaped
@@ -96,16 +107,8 @@ class Citecue_Seo_Head {
private $plugin;
/**
- * Output-buffer nesting level our capture opened at, or null when no
- * capture is in flight.
- *
- * @var int|null
- */
- private $buffer_level = null;
-
- /**
- * The decision start_capture() acted on, carried to finish_capture() so the
- * pair cannot disagree — and so one page load costs one cache read and, at
+ * The decision start_capture() acted on, carried to the injection so the
+ * two cannot disagree — and so one page load costs one cache read and, at
* most, one scheduled refresh.
*
* @var array|null
@@ -128,86 +131,147 @@ public function __construct( Citecue_Plugin $plugin ) {
*/
public function register() {
add_action( self::REFRESH_HOOK, array( $this, 'refresh' ), 10, 1 );
- add_action( 'template_redirect', array( $this, 'start_capture' ), self::CAPTURE_START_PRIORITY );
- add_action( 'wp_head', array( $this, 'finish_capture' ), self::CAPTURE_END_PRIORITY );
+ add_action( 'template_redirect', array( $this, 'start_capture' ), self::CAPTURE_PRIORITY );
}
/**
- * Opens the capture, but only when there is something to inject — buffering
- * a page we will not touch is pure overhead, and every reason not to inject
- * is knowable before the theme renders a byte.
- *
- * Opened at `template_redirect` rather than at the start of `wp_head`
- * (PR #10 review): a theme that prints ``, a canonical or its own
- * OpenGraph directly in `header.php` does so BEFORE `wp_head` runs, so a
- * capture scoped to the action would read those slots as empty and append
- * the duplicate the gap-fill exists to prevent. This is still not a
- * whole-page buffer — `wp_head` sits in ``, so it closes within the
- * first few kilobytes.
+ * Arranges the capture, but only when there is something to inject —
+ * buffering a page we will not touch is pure overhead, and every reason not
+ * to inject is knowable before the theme renders a byte.
+ *
+ * Two mechanisms, one behaviour. WordPress 6.9 added a template output
+ * buffer of its own, opened only when a plugin has registered a
+ * `wp_template_enhancement_output_buffer` filter and closed by core, which
+ * hands the finished document to that filter. Where it exists this class
+ * opens no buffer at all and just asks for the document. Below 6.9 it opens
+ * the same buffer core does, in the same form: `ob_start()` with a
+ * *callback* and without PHP_OUTPUT_HANDLER_FLUSHABLE, so the callback is
+ * invoked exactly once with the whole response.
+ *
+ * The callback form is the point (WordPress.org plugin review). A buffer
+ * opened here has to close after the theme has rendered, which is a
+ * different function by definition — and the shape this replaces, an
+ * `ob_start()` on `template_redirect` paired with an `ob_get_clean()` on
+ * `wp_head`, was left open by every way `wp_head` can fail to reach its
+ * last callback: a template that never calls `wp_head()`, a plugin that
+ * `exit`s inside it, a fatal, or simply another buffer opened in the head
+ * and not closed, which made the pairing unsafe to complete. A callback has
+ * nothing to pair and nothing to leave open — PHP invokes it when the
+ * buffer ends, and ends the buffer itself at the end of the request if
+ * nothing ended it sooner, so the response goes out whatever happens.
+ *
+ * Buffering the response rather than just the head is the cost of that, and
+ * it is the trade core made in 6.9 too. It is bounded on the only axis that
+ * matters here: the buffer is opened solely when a cached block is already
+ * in hand, so a page CiteCue has nothing for streams exactly as it did.
*
* @return void
*/
public function start_capture() {
- $this->buffer_level = null;
- $this->decision = $this->decide();
+ $this->decision = $this->decide();
if ( ! $this->decision['inject'] ) {
return;
}
- ob_start();
- $this->buffer_level = ob_get_level();
+ // WordPress 6.9+. Registered here rather than at `init` because core
+ // decides whether to buffer at all by looking for this filter when the
+ // template is included, which is after this action — so registering it
+ // only on a page there is something to inject into means CiteCue never
+ // makes core buffer a response it would have streamed.
+ if ( function_exists( 'wp_should_output_buffer_template_for_enhancement' ) ) {
+ add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'enhance' ) );
+ return;
+ }
+
+ ob_start(
+ array( $this, 'finish_capture' ),
+ 0, // No chunking: the injection needs the whole response to find the head in it.
+ PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE
+ );
}
/**
- * Closes the capture, re-emits everything rendered so far, and appends the
- * CiteCue tags that found an empty slot.
+ * The output-buffer callback, on WordPress below 6.9 only. PHP calls this
+ * when the buffer ends — which it always does, at the end of the request if
+ * nothing ended it sooner — and sends what it returns.
*
- * @return void
+ * @param string $output Everything rendered since the buffer opened.
+ * @param int $phase PHP output handler phase bitmask.
+ * @return string What is sent to the browser.
*/
- public function finish_capture() {
- $level = $this->buffer_level;
- $decision = $this->decision;
- $this->buffer_level = null;
- $this->decision = null;
+ public function finish_capture( $output, $phase ) {
+ // Ended by a clean rather than a flush, and PHP discards what a handler
+ // returns in that phase — the caller gets the raw bytes. So either the
+ // response is being thrown away, or something that buffered the whole
+ // page is taking it with ob_get_clean(); in both cases nothing returned
+ // here can reach a browser, and the page goes out un-enriched. Core's
+ // own template enhancement filter is skipped on exactly the same
+ // requests, for exactly this reason, and makes exactly this check.
+ if ( 0 !== ( (int) $phase & PHP_OUTPUT_HANDLER_CLEAN ) ) {
+ return (string) $output;
+ }
+
+ return $this->enhance( $output );
+ }
- if ( null === $level || null === $decision ) {
- return;
- }
+ /**
+ * The rendered document with CiteCue's tags added to its head — the one
+ * place the injection happens, shared by both mechanisms above.
+ *
+ * Everything before `` is what the slot check reads, and the tags go
+ * immediately before it. Scoping both to the head is not tidiness: an
+ * inline SVG in the body carries a ``, `` is legal in
+ * body content, and a page that quotes markup in a code sample contains
+ * whatever it quotes — so a document-wide scan would read slots as occupied
+ * that no browser or crawler ever reads as page metadata, and CiteCue would
+ * silently stop filling them.
+ *
+ * A response with no `` is returned exactly as it arrived: a JSON or
+ * CSV export served from a page URL, a fragment, a document another plugin
+ * replaced wholesale. There is no head to fill gaps in, and guessing where
+ * one would have gone is how a plugin corrupts a response it did not
+ * understand.
+ *
+ * @param string $html Rendered document.
+ * @return string
+ */
+ public function enhance( $html ) {
+ $decision = $this->decision;
- // Our buffer is no longer the top one: something opened another inside
- // the head and has not closed it, or closed ours for us. Leave every
- // buffer exactly as it is and inject nothing (PR #10 review). Unwinding
- // down to ours would close a buffer this class did not create, and its
- // owner's later ob_get_clean() would then take an unrelated one —
- // breaking whatever minifier or cache opened it. Ours flushes with the
- // rest at the end of the request, so no output is lost or reordered;
- // only the tags are skipped, which is a non-event.
- if ( ob_get_level() !== $level ) {
- return;
+ // One capture, one injection: whatever else calls this — a filter
+ // applied twice, a buffer finalized more than once — must not append
+ // the block again.
+ $this->decision = null;
+ $html = (string) $html;
+
+ if ( null === $decision || ! $decision['inject'] ) {
+ return $html;
}
- $head = (string) ob_get_clean();
- $tags = self::merge( $head, $decision['block'] );
+ if ( ! preg_match( '##i', $html, $match, PREG_OFFSET_CAPTURE ) ) {
+ return $html;
+ }
- // Everything rendered so far, verbatim — the theme's own markup and
- // other plugins' `wp_head` output passing straight back through.
- echo $head; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ $at = (int) $match[0][1];
+ $tags = self::merge( substr( $html, 0, $at ), $decision['block'] );
if ( ! $tags ) {
- return;
+ return $html;
}
- echo "\n\n";
- // Built by self::rebuild_tag() out of escaped values — never a string
- // from the response — so this is our own markup, not remote markup.
- echo implode( "\n", $tags ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ // The tags are built by self::rebuild_tag() out of escaped values,
+ // never a string from the response, so what is spliced in here is our
+ // own markup rather than remote markup.
+ return substr( $html, 0, $at )
+ . "\n\n" . implode( "\n", $tags ) . "\n"
+ . substr( $html, $at );
}
/**
* Whether this request should be injected into, and with what — reading the
- * cache only, never the network. The testable counterpart of the capture
- * pair, mirroring the decide()/serve() split in Citecue_Proxy.
+ * cache only, never the network. The testable counterpart of the capture,
+ * mirroring the decide()/serve() split in Citecue_Proxy.
*
* @return array{inject:bool,block:string,reason:string}
*/
@@ -448,8 +512,13 @@ public static function merge( $existing, $block ) {
* The default policy is gap-filling: a tag whose slot another plugin
* has already filled is dropped. Use this to re-add one (having removed
* the other plugin's copy yourself) or to drop more. Whatever is
- * returned is printed unescaped, so a filter that adds markup owns
- * escaping it.
+ * returned is spliced into the head unescaped, so a filter that adds
+ * markup owns escaping it.
+ *
+ * This runs inside an output buffer callback. A callback here must not
+ * print anything (PHP silently drops it before 8.5 and deprecates it
+ * after) and must not call `ob_start()`, which is a fatal error in that
+ * context. Return the tags; do not emit them.
*
* @param string[] $tags Tags that survived the gap-fill.
* @param string $block The full block CiteCue returned.
diff --git a/readme.txt b/readme.txt
index 549dca5..ecce623 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Tags: ai, ai-crawlers, gptbot, ai-seo, woocommerce
Requires at least: 5.8
Tested up to: 7.0
Requires PHP: 7.4
-Stable tag: 1.1.1
+Stable tag: 1.1.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -118,6 +118,9 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never
== Upgrade Notice ==
+= 1.1.2 =
+Changes how page metadata is added to the response: no output buffer is left open, and WordPress 6.9's own template output buffer is used where there is one. Nothing to reconfigure.
+
= 1.1.1 =
Fixes a fatal error on sites that still have the old citecue/ folder installed alongside this plugin. Admin notices now appear only on the Plugins and CiteCue screens.
@@ -129,6 +132,11 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin
== Changelog ==
+= 1.1.2 =
+* The metadata layer no longer holds an output buffer of its own open across a request. On WordPress 6.9 and later it uses core's template enhancement output buffer, so the plugin opens no buffer at all; below that it opens one in the form PHP finalizes by itself, and closes nothing. The previous shape opened a buffer on one hook and closed it on another, which left one open on any page whose `wp_head` did not run to the end — and a buffer left open is one the next plugin's `ob_get_clean()` can take by mistake.
+* The tags now go immediately before `` rather than at the end of `wp_head`, and the check for what is already there reads the head only. Markup in the body — a `` inside an inline SVG, a `` quoted in page content — no longer counts as a slot somebody else has filled, so pages carrying either get their metadata again.
+* The capture is arranged after every other `template_redirect` callback, so a request another plugin redirects or answers itself is never buffered.
+
= 1.1.1 =
* Admin notices are confined to the Plugins screen and the CiteCue settings screen. The rejected-key warning and the duplicate-install warning used to print on every screen in the dashboard; neither asks for anything that can be done anywhere else, and the settings screen states both a second time in its status card.
* The reconnect prompt can now be dismissed permanently, per user and per site. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. On multisite the dismissal is scoped to the site it was made on, since the condition it reports on is per-site while WordPress stores user metadata network-wide.
diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php
index 99495b1..193bbdc 100644
--- a/tests/cases/test-seo-head-delivery.php
+++ b/tests/cases/test-seo-head-delivery.php
@@ -407,12 +407,12 @@ public function test_refresh_respects_the_circuit_and_the_budget() {
}
/**
- * The full render: what the rest of wp_head printed comes back untouched,
- * with only the gaps appended after it.
+ * The full render: everything the theme and every other plugin printed
+ * comes back untouched, with only the gaps added before ``.
*
* @return void
*/
- public function test_capture_appends_only_the_gaps() {
+ public function test_the_render_adds_only_the_gaps() {
$this->configure_delivery();
$url = $this->fake_visitor_request();
$this->plugin->cache->set_seo_head(
@@ -421,25 +421,23 @@ public function test_capture_appends_only_the_gaps() {
. ''
);
- $injector = $this->seo_head();
-
- ob_start();
- $injector->start_capture();
- echo 'The theme wrote this';
- $injector->finish_capture();
- $output = ob_get_clean();
+ $output = $this->render(
+ $this->seo_head(),
+ $this->document( 'The theme wrote this' )
+ );
$this->assertStringContainsString( 'The theme wrote this', $output );
$this->assertStringNotContainsString( 'CiteCue title', $output );
$this->assertStringContainsString( 'og:title', $output );
$this->assertSame( 1, substr_count( $output, 'assertStringContainsString( '', $output );
}
/**
* A theme that prints its own `` in header.php does so before
- * `wp_head` runs. The capture opens at `template_redirect` precisely so
- * that markup is still seen — scoped to the action, this would append a
- * second title.
+ * `wp_head` runs. The capture spans the whole response precisely so that
+ * markup is still seen — scoped to the action, this would add a second
+ * title.
*
* @return void
*/
@@ -452,13 +450,10 @@ public function test_theme_markup_printed_before_wp_head_still_claims_its_slot()
. ''
);
- $injector = $this->seo_head();
-
- ob_start();
- $injector->start_capture();
- echo 'Printed by header.php';
- $injector->finish_capture();
- $output = ob_get_clean();
+ $output = $this->render(
+ $this->seo_head(),
+ $this->document( 'Printed by header.php' )
+ );
$this->assertSame( 1, substr_count( $output, 'assertStringNotContainsString( 'CiteCue title', $output );
@@ -466,60 +461,294 @@ public function test_theme_markup_printed_before_wp_head_still_claims_its_slot()
}
/**
- * Another plugin's buffer left open across the end of wp_head must be left
- * exactly where it is. Unwinding to reach ours would close a buffer this
- * class did not create, and its owner's later ob_get_clean() would then
- * take an unrelated one.
+ * The tags go inside the head, before its closing tag, rather than wherever
+ * the render happened to stop.
+ *
+ * @return void
+ */
+ public function test_the_tags_land_inside_the_head() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
+
+ $output = $this->render( $this->seo_head(), $this->document() );
+
+ $this->assertLessThan(
+ strpos( $output, '' ),
+ strpos( $output, 'og:title' ),
+ 'The block belongs before the head closes.'
+ );
+ }
+
+ /**
+ * Markup in the body never claims a head slot. An inline SVG carries a
+ * `` and a page that quotes markup contains whatever it quotes, so a
+ * document-wide scan would read slots as occupied that no crawler reads as
+ * page metadata — and CiteCue would quietly stop filling them.
*
* @return void
*/
- public function test_a_foreign_buffer_is_never_unwound() {
+ public function test_body_markup_never_claims_a_head_slot() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, 'CiteCue title' );
+
+ $output = $this->render(
+ $this->seo_head(),
+ $this->document( '', '' )
+ );
+
+ $this->assertStringContainsString( 'CiteCue title', $output );
+ }
+
+ /**
+ * A response with no head is handed back byte for byte: a JSON or CSV
+ * export served from a page URL, a fragment, a document another plugin
+ * replaced wholesale. There is nothing to fill a gap in, and guessing where
+ * a head would have gone is how a plugin corrupts a response.
+ *
+ * @return void
+ */
+ public function test_a_response_without_a_head_is_untouched() {
$this->configure_delivery();
$url = $this->fake_visitor_request();
$this->plugin->cache->set_seo_head( $url, self::BLOCK );
+ $csv = "sku,price\nCC-1,9.99\n";
+
+ $this->assertSame( $csv, $this->render( $this->seo_head(), $csv ) );
+ }
+
+ /**
+ * Nothing to inject means nothing arranged: no buffer of the plugin's own,
+ * no filter registered for core's, and every byte where the theme put it.
+ *
+ * @return void
+ */
+ public function test_nothing_is_arranged_without_a_block() {
+ $this->configure_delivery();
+ $this->fake_visitor_request();
+
$injector = $this->seo_head();
+ $level = ob_get_level();
- ob_start();
$injector->start_capture();
- echo 'Theme';
- // Somebody else opens one and does not close it before wp_head ends.
- ob_start();
- $foreign_level = ob_get_level();
- echo 'foreign';
+ $this->assertSame( $level, ob_get_level(), 'A page with nothing to inject must not be buffered.' );
+ $this->assertFalse( has_filter( 'wp_template_enhancement_output_buffer' ) );
+ $this->assertSame( $this->document(), $injector->enhance( $this->document() ) );
+ }
- $injector->finish_capture();
+ /**
+ * The buffer discipline the WordPress.org review asked for, stated as the
+ * property that matters: a render leaves the buffer stack exactly as it
+ * found it, even though `wp_head` never runs here — which is the case the
+ * old `ob_start()`/`ob_get_clean()` pairing across two actions could not
+ * survive. Every render in this file goes through the same helper and
+ * asserts the same thing.
+ *
+ * @return void
+ */
+ public function test_a_render_that_never_reaches_wp_head_leaves_no_buffer_behind() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
+
+ $level = ob_get_level();
+
+ $this->assertStringContainsString( 'og:title', $this->render( $this->seo_head(), $this->document() ) );
+ $this->assertSame( $level, ob_get_level() );
+ $this->assertFalse( has_action( 'wp_head', array( $this->seo_head(), 'finish_capture' ) ) );
+ }
+
+ /**
+ * Which mechanism runs is decided by what WordPress provides, and the two
+ * are exclusive: from 6.9 the plugin opens nothing at all and asks core for
+ * the finished document; below it the plugin opens the buffer, and opens it
+ * with a callback — the form PHP finalizes on its own — so there is no
+ * closing call to be bypassed and nothing left open if one is.
+ *
+ * Both halves are asserted, and CI runs both: the matrix pins WordPress
+ * 5.9 and 6.5 alongside the current release.
+ *
+ * @return void
+ */
+ public function test_the_capture_uses_core_s_buffer_where_there_is_one() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
- $this->assertSame( $foreign_level, ob_get_level(), 'finish_capture() must not close a buffer it did not open.' );
- $this->assertSame( 'foreign', ob_get_clean() );
+ $injector = $this->seo_head();
+ $level = ob_get_level();
- $output = ob_get_clean();
- $this->assertSame( 'Theme', $output );
- $this->assertStringNotContainsString( 'og:title', $output );
+ $injector->start_capture();
- ob_end_clean();
+ $opened = ob_get_level() - $level;
+ $status = ob_get_status( true );
+ $mine = $opened ? end( $status ) : array();
+
+ while ( ob_get_level() > $level ) {
+ ob_end_clean();
+ }
+
+ if ( function_exists( 'wp_should_output_buffer_template_for_enhancement' ) ) {
+ $this->assertSame( 0, $opened, 'From WordPress 6.9 core owns the buffer.' );
+ $this->assertNotFalse( has_filter( 'wp_template_enhancement_output_buffer', array( $injector, 'enhance' ) ) );
+ return;
+ }
+
+ $this->assertSame( 1, $opened, 'Below WordPress 6.9 the plugin opens its own.' );
+ $this->assertFalse( has_filter( 'wp_template_enhancement_output_buffer' ) );
+ $this->assertSame( 'Citecue_Seo_Head::finish_capture', $mine['name'], 'The buffer must be the self-finalizing callback form.' );
+ $this->assertSame( 0, $mine['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE, 'A flushable buffer would hand the callback a fragment.' );
}
/**
- * Nothing to inject means nothing touched: no buffer, no marker comment,
- * and every byte of the head exactly where the theme put it.
+ * A buffer somebody else opens inside the head and never closes is neither
+ * taken nor unwound: PHP ends it first and its bytes land in ours. The old
+ * pairing had to detect this case and give up on the tags; there is nothing
+ * to detect now.
+ *
+ * Below WordPress 6.9 this exercises the plugin's own callback buffer; from
+ * 6.9 core owns the buffer and the same nesting applies to it.
*
* @return void
*/
- public function test_capture_is_a_no_op_without_a_block() {
+ public function test_a_foreign_buffer_left_open_is_neither_taken_nor_lost() {
$this->configure_delivery();
- $this->fake_visitor_request();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
$injector = $this->seo_head();
+ $level = ob_get_level();
ob_start();
$injector->start_capture();
- echo 'The theme wrote this';
- $injector->finish_capture();
- $output = ob_get_clean();
- $this->assertSame( 'The theme wrote this', $output );
+ echo 'Theme';
+ ob_start(); // Somebody else's minifier, still open when the response ends.
+ echo '';
+ echo 'hi';
+
+ // The end of the request: PHP flushes every open buffer, innermost
+ // first, which is what invokes the plugin's callback.
+ while ( ob_get_level() > $level + 1 ) {
+ ob_end_flush();
+ }
+ $output = (string) ob_get_clean();
+
+ $this->assertSame( $level, ob_get_level(), 'The plugin must not take, or leave, a buffer.' );
+ $this->assertStringContainsString( 'a minifier', $output, 'The foreign buffer\'s bytes must survive.' );
+ $this->assertStringContainsString( 'Theme', $output );
+ }
+
+ /**
+ * A buffer ended by a clean rather than a flush is handed back untouched:
+ * PHP discards what the callback returns in that phase, so there is nothing
+ * to gain by enriching it. The decision survives for the response that
+ * replaces it.
+ *
+ * @return void
+ */
+ public function test_a_discarded_response_is_not_enhanced() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
+
+ $injector = $this->seo_head();
+ $this->arrange_capture( $injector );
+
+ $document = $this->document();
+
+ $this->assertSame( $document, $injector->finish_capture( $document, PHP_OUTPUT_HANDLER_CLEAN ) );
+ $this->assertStringContainsString( 'og:title', $injector->enhance( $document ) );
+ }
+
+ /**
+ * One capture, one injection. Whatever calls the injection twice — a filter
+ * applied again, a buffer finalized in pieces — must not append the block a
+ * second time.
+ *
+ * @return void
+ */
+ public function test_the_block_is_injected_once() {
+ $this->configure_delivery();
+ $url = $this->fake_visitor_request();
+ $this->plugin->cache->set_seo_head( $url, self::BLOCK );
+
+ $injector = $this->seo_head();
+ $this->arrange_capture( $injector );
+
+ $this->assertStringContainsString( 'og:title', $injector->enhance( $this->document() ) );
+ $this->assertSame( $this->document(), $injector->enhance( $this->document() ) );
+ }
+
+ /**
+ * A rendered page, as the injection receives it.
+ *
+ * @param string $head Extra head markup.
+ * @param string $body Body markup.
+ * @return string
+ */
+ private function document( $head = '', $body = '
Hello
' ) {
+ return '' . $head . '' . $body . '';
+ }
+
+ /**
+ * One page render, end to end, through whichever mechanism this WordPress
+ * provides: core's template enhancement buffer from 6.9, the plugin's own
+ * callback buffer below it. Returns what the visitor receives, and fails
+ * the test unless the render left the output buffer stack as it found it.
+ *
+ * @param Citecue_Seo_Head $injector Injector under test.
+ * @param string $document What the theme renders.
+ * @return string
+ */
+ private function render( Citecue_Seo_Head $injector, $document ) {
+ $level = ob_get_level();
+
+ ob_start(); // Stands in for everything below the plugin on the stack.
+ $injector->start_capture();
+
+ if ( ob_get_level() === $level + 1 ) {
+ // The plugin opened nothing: either there is nothing to inject, or
+ // core owns the buffer and applies the filter to the response.
+ $delivered = has_filter( 'wp_template_enhancement_output_buffer' )
+ ? (string) apply_filters( 'wp_template_enhancement_output_buffer', $document, $document )
+ : $document;
+ ob_end_clean();
+ } else {
+ echo $document; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ // The end of the request, where PHP flushes what is still open.
+ while ( ob_get_level() > $level + 1 ) {
+ ob_end_flush();
+ }
+ $delivered = (string) ob_get_clean();
+ }
+
+ $this->assertSame( $level, ob_get_level(), 'A render must leave the buffer stack as it found it.' );
+
+ return $delivered;
+ }
+
+ /**
+ * Runs the arrangement `template_redirect` runs and takes back whatever
+ * buffer it opened, so a test can call the injection directly without one
+ * outliving it. Ending the buffer this way discards it, which the plugin's
+ * callback treats as a response being thrown away — so the decision it is
+ * holding survives for the test to act on.
+ *
+ * @param Citecue_Seo_Head $injector Injector under test.
+ * @return void
+ */
+ private function arrange_capture( Citecue_Seo_Head $injector ) {
+ $level = ob_get_level();
+
+ $injector->start_capture();
+
+ while ( ob_get_level() > $level ) {
+ ob_end_clean();
+ }
}
/**