Inject the enriched SEO head into live pages, filling gaps rather than fighting Yoast - #10
Merged
Merged
Conversation
The app shipped GET /api/delivery/v2/seo-head (2026-08-07): the missing half of the CMS channel. Until now v2 offered only `page` — the whole optimized document, for detected AI crawlers — so `seoAudience: 'all'` had no mechanism behind it on a plugin-served site. The switch read "on", the fix badged "Live", and a browser (or Googlebot) got the untouched origin. The app now fails closed on a `seo_head` capability recorded on the api_keys row at connect time, which this plugin never sent, so every CiteCue WordPress site currently reports a channel that cannot inject. Citecue_Seo_Head closes that. Two rules shape all of it. Never emit a duplicate. WordPress core prints <title> and rel=canonical on its own, before any SEO plugin is involved, and Yoast, Rank Math, AIOSEO, SEOPress, TSF and Slim SEO each print some combination of title, description, OpenGraph and JSON-LD. A second <title> is invalid HTML and a second canonical makes Google pick one arbitrarily. So it fills gaps only: it buffers wp_head from -PHP_INT_MAX to PHP_INT_MAX, maps both sides to slots (title, canonical, each meta name/property, JSON-LD as a whole) and drops every CiteCue tag whose slot is taken. Detecting emitted markup rather than sniffing for WPSEO_VERSION is what makes that correct against plugins and themes nobody here has heard of. merge() is pure, so the contract is tested against real Yoast-, core- and partial-plugin-shaped head dumps rather than through a request. Never block a human. The crawler proxy may spend a network call because only a bot is waiting; here a real visitor is. The render path therefore reads the transient cache and nothing else — a cold URL renders untouched and queues a WP-Cron fetch, so the next visitor gets the tags, and a stale block keeps being printed while the refresh runs. Cold pages cost one un-enriched view, never one slow one. The refresh shares the circuit breaker and the per-minute lookup budget with the crawler path. Also here: - The connect claim reports the capability, and the plugin records what it last reported. A site connected before this release, or one that switches injection off afterwards, disagrees with what CiteCue holds — the settings screen and a Plugins-screen notice ask for the one reconnect that fixes it. - slot_for() is an allowlist, not just a classifier: only application/ld+json scripts, only canonical/alternate links, only meta tags carrying a name/property. This markup arrives over the network and lands in a browser. - Deliberately no DONOTCACHEPAGE. The tags are identical for every visitor, so a page cache storing them is correct — unlike the crawler-only path. - current_url() moves to the container. The page cache and the head cache key off the same string, so two callers spelling a URL differently would be two cache entries for one page. - wp_unschedule_hook(), not wp_clear_scheduled_hook(), clears the refresh jobs: each carries its own URL, so an argument-less clear would match none. 297 tests pass on both the core and WooCommerce passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six findings, and the first three change what this feature is allowed to do. Rebuild every tag instead of echoing the response's own markup. The allowlist was implemented by searching a tag for `rel=`/`name=` and, if it liked what it found, printing the original string — and a search cannot read markup. `\brel` matched `data-rel="canonical"`, and even anchored to whitespace it still finds ` rel=canonical` inside `foo="a rel=canonical"`. Either way a `<link rel="stylesheet" onload="...">` was authorized as a canonical and printed verbatim onto a real visitor's page. Attributes are now parsed by walking name/value pairs left to right, which cannot mistake a quoted value for a name, and nothing survives that this class did not itself write: link hrefs are esc_url_raw'd to http/https, meta values are escaped into a tag we build, JSON-LD is re-encoded from the decoded data with JSON_HEX_TAG so it cannot end its own script element, and every other attribute is discarded. Stop sending transactional URLs to CiteCue. The injector had no WooCommerce exclusion, so an order-received or account page put its full URL — order id, `wc_order_*` key, account token — into a cron argument and then into the `u=` parameter. The proxy's exclusion moves to the container and both paths share it. Independently of that, the lookup URL now drops every query argument WordPress does not recognize as a query variable, so a token in the address is never cached, queued or sent whatever page it is on. Bound the refresh queue. The per-URL lock bounds one URL, not the number of URLs, and the outbound budget is only spent when a job runs — so `/?x=<random>`, which renders the homepage, could push unlimited events into WordPress's serialized cron option and make every later write more expensive. Stripping unrecognized query arguments removes most of the ways to mint a distinct URL; a second per-minute budget bounds what is left. And three correctness fixes: - Never unwind a buffer this class did not open. The mismatch branch flushed down to our level, closing buffers belonging to whatever minifier or cache opened them; their later ob_get_clean() would then take an unrelated one. It now leaves every buffer alone and skips injection for that request. - Capture from template_redirect, not from the start of wp_head. A theme that prints <title>, a canonical or its own OpenGraph in header.php does so before wp_head runs, so a capture scoped to the action read those slots as empty and appended the duplicate the gap-fill exists to prevent. Still not a whole-page buffer: wp_head sits in <head>, so it closes within the first few kilobytes. - Flush the delivery cache when the selected project changes. Page bodies, llms.txt and head blocks all key off the cache salt and the URL and NOT the project, so repointing a site at another project served the previous one's content under the new one's name for up to a day. Fixed where the project changes rather than in one cache's key, because all three had it. Also skips password-protected posts: CiteCue has no metadata for content it cannot read, and a description of it does not belong on the gate. 316 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…budgets atomic Four findings, one of which reopened the hole the previous commit closed. parse_str() does not report the parameter names that are in the URL. It rewrites `.` and space in a name to `_`, so a parameter literally called `x.1` comes back as `x_1` — and the removal list built from those names asked remove_query_arg() to drop something the URL does not contain, leaving the real parameter untouched. `/?x.1=<random>` therefore walked straight past the strip and went back to minting unlimited cache keys and cron arguments for the homepage. Rewritten to split the query string by hand and, more importantly, to KEEP what is recognized rather than REMOVE what is not: a name this code cannot parse is now simply never copied across, so the failure mode is a dropped parameter rather than a bypass. The per-minute budgets were read-then-write, so concurrent requests all read the same count, all found themselves under the limit, and all proceeded — overshooting the ceiling by roughly the concurrency. Where a persistent object cache exists, wp_cache_incr() makes the count exact. Where one does not there is nothing to be exact with, since the options table has no atomic increment reachable through the transient API, so that path stays best-effort and says so. Shared by both budgets rather than fixing only the new one: the crawler path's lookup budget had the identical weakness, and leaving a weaker sibling beside the fixed one is a trap for whoever reads this next. And two smaller ones: - test_store_pages_are_never_enriched asked about `/checkout/order-received/42/`, which is a 404 on the test site, and the injector declines a 404 several checks before it reaches the store-page rule — so the test would have passed with the WooCommerce exclusion deleted. It now uses a URL that resolves and asserts that it does. - The Performance section still described the lookup budget as covering the crawler and llms.txt paths, which stopped being true when the metadata refresh started sharing it. 317 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up on the previous commit's own new code, from a second CodeRabbit pass. `wp_cache_incr()` returning false was read as a fresh bucket and allowed. That is right for the case it was written for — an entry evicted between the add and the increment — and wrong for the one it did not consider: a backend that is not answering fails EVERY increment, so every call returns "allowed" and the ceiling stops existing. Fail-open is the one outcome a rate limit may not have. A failed increment now falls through to the transient counter instead. Atomic where the object cache can be, counted where it cannot, never unlimited. Also pins the counter's edge directly: it grants exactly its limit and then refuses. Both per-minute ceilings share it now, so one off-by-one there is one off-by-one in the crawler path too. 318 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_404_evicts_and_records_a_miss checked the eviction and not the miss. Without the miss every subsequent page view re-queues the fetch for a URL CiteCue has already said it has nothing for, which is the reason the negative cache exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The app shipped
GET /api/delivery/v2/seo-headon 2026-08-07 — the missing half of the CMS channel. Until then v2 offered onlypage(the whole optimized document, for detected AI crawlers), soseoAudience: 'all'had no mechanism behind it on a plugin-served site: the switch read "on", the fix badged "Live", and a browser or Googlebot got the untouched origin.The app now fails closed on a
seo_headcapability written to the connection'sapi_keysrow at connect time (deriveSeoInjectionSupported). This plugin never sent it, so every CiteCue WordPress site currently reports a delivery channel that cannot inject — honestly, but at the cost of a feature customers are paying for.What changed
New
Citecue_Seo_Headadds CiteCue's title, meta description, OpenGraph, canonical and JSON-LD to the<head>of the page a human sees. CiteCue only serves a block forenrichedpages inallaudience mode — content-parity markup — so this is additive, not cloaking. The visible page is never touched.Two rules shape the implementation.
Never emit a duplicate
WordPress core prints
<title>andrel=canonicalon its own, before any SEO plugin is involved; Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework and Slim SEO each print some combination of title, description, OpenGraph and JSON-LD. A second<title>is invalid HTML and a second canonical makes Google pick one arbitrarily.So it fills gaps only: it buffers
wp_headfrom-PHP_INT_MAXtoPHP_INT_MAX, maps both sides to slots (title, canonical, each meta name/property, JSON-LD as a whole) and drops every CiteCue tag whose slot is taken. Detecting emitted markup rather than sniffing forWPSEO_VERSIONis what makes it correct against plugins and themes nobody here has heard of. A full Yoast head gets nothing added; a site with no OpenGraph gets the OG tags.Never block a human
The crawler proxy may spend a network call because only a bot waits. Here a real visitor does — so the render path reads the transient cache and nothing else. A cold URL renders untouched and queues a WP-Cron fetch, so the next visitor gets the tags; a stale block keeps printing while the refresh runs. Cold pages cost one un-enriched view, never one slow one. The refresh shares the circuit breaker and per-minute lookup budget with the crawler path.
Also here
seo_head, and the plugin records what it last reported. A site connected before this release — or one that switches injection off afterwards — disagrees with what CiteCue holds, in either direction; the settings screen and a Plugins-screen notice ask for the one reconnect that fixes it.slot_for()is an allowlist, not just a classifier. Onlyapplication/ld+jsonscripts, onlycanonical/alternatelinks, only<meta>carrying aname/property. This markup arrives over the network and lands in a browser.DONOTCACHEPAGE. The tags are identical for every visitor, so a page cache storing them is correct — unlike the crawler-only path. Consequence: a page cached before its block was warm keeps the un-enriched copy until that entry expires. Documented.current_url()moved to the container. The page cache and the head cache key off the same string; two callers spelling a URL differently would be two cache entries for one page.wp_unschedule_hook(), notwp_clear_scheduled_hook(), clears the refresh jobs — each carries its own URL, so an argument-less clear would match none of them.Version 1.1.0. Two new filters:
citecue_should_inject_seo_headandcitecue_seo_head_tags(the escape hatch for handing a slot back to CiteCue).For the reviewer
The buffering is the risky part.
finish_capture()takes a conservative exit whenever the buffer level doesn't line up on close — flushes what it owns so no output is lost or reordered, and injects nothing that request. Worth a second pair of eyes: a missing block is a non-event, mangled<head>output is not.merge()is pure on purpose. It is the whole conflict-avoidance contract with every other SEO plugin on the site, and being pure is what lets it be tested against a real Yoast head dump without a request in sight — seetests/cases/test-seo-head-merge.php.One known residual over-claim, left alone deliberately. The capability is binary but real coverage isn't: a Yoast site now reports
seoInjectionSupported: true— correctly, the plugin can inject — while gap-fill may mean nothing lands on that particular page. Closing it properly needs per-page reporting on the app side, which is out of scope here.Verification
composer test:coreandCITECUE_STUB_WOOCOMMERCE=1)phpcsandcomposer lintclean🤖 Generated with Claude Code