Skip to content

Inject the enriched SEO head into live pages, filling gaps rather than fighting Yoast - #10

Merged
henry-mosh merged 5 commits into
mainfrom
feat/plugin-app-updates-fe02ca
Aug 7, 2026
Merged

Inject the enriched SEO head into live pages, filling gaps rather than fighting Yoast#10
henry-mosh merged 5 commits into
mainfrom
feat/plugin-app-updates-fe02ca

Conversation

@henry-mosh

Copy link
Copy Markdown
Collaborator

Why

The app shipped GET /api/delivery/v2/seo-head on 2026-08-07 — the missing half of the CMS channel. Until then 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 written to the connection's api_keys row 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_Head adds CiteCue's title, meta description, OpenGraph, canonical and JSON-LD to the <head> of the page a human sees. CiteCue only serves a block for enriched pages in all audience 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> and rel=canonical on 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_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 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

  • Capability reporting + reconnect prompt. The connect claim sends 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. Only application/ld+json scripts, only canonical/alternate links, only <meta> 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. 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(), not wp_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_head and citecue_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 — see tests/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

  • 297 tests pass on both passes (composer test:core and CITECUE_STUB_WOOCOMMERCE=1)
  • phpcs and composer lint clean
  • The three version strings agree and the new class ships in the zip

🤖 Generated with Claude Code

henry-idingo and others added 5 commits August 7, 2026 20:50
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>
@henry-mosh
henry-mosh merged commit 61834f8 into main Aug 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant