Skip to content

Place CiteCue page enhancements on the page you already have (tier 1) - #13

Merged
henry-mosh merged 9 commits into
mainfrom
feat/page-enhancements-tier1
Sep 3, 2026
Merged

Place CiteCue page enhancements on the page you already have (tier 1)#13
henry-mosh merged 9 commits into
mainfrom
feat/page-enhancements-tier1

Conversation

@henry-mosh

Copy link
Copy Markdown
Collaborator

Summary

Implements tier 1 of the page-enhancements handoff: declares the body_blocks and seo_head_baseline delivery capabilities and injects /seo-head's body block immediately before </body> on ordinary page loads. Also closes a stranded-consent gap the app-side consent gate creates, and fixes a bug in the plugin's own reconnect prompt found while doing that.

  • Declare + inject in one release. body_blocks/seo_head_baseline ride seo_head_enabled, built by one Citecue_Settings::declared_capabilities() that the reconnect check also reads, so the two sets can't drift apart.
  • Body injection, verbatim: last </body> (not the first, to avoid a quoted one in a code sample), marker guard on data-citecue="page-enhancement" (recognized however it's quoted), 32 KB cap re-checked locally, empty string is a no-op. Head and body are independent — either can inject while the other doesn't.
  • refresh() no longer discards a body-only answer. /seo-head returns 200 when either half is non-empty; the old code read an empty head as a miss and would have thrown every body-only block away.
  • Reconnect prompt now distinguishes why. seo_head_reconnect_reason() returns enabled / disabled / capabilities / '' instead of one boolean, so a site upgrading into this release (whose metadata setting hasn't moved) isn't told its metadata isn't reaching CiteCue — a fault an admin would go hunting for and not find. Page enhancements got their own status-card row.
  • Content-push consent self-corrects. The app side's consent gate means a reconnect without the "allow content pushes" box now clears the stored ingest secret; nothing told this plugin when that happened. Two reads close it: the plugin's own claim response (absent ingest there is a definite withholding, since the wire contract omits rather than sends false) and a daily reconcile against GET /config's per-project contentPush field (gated on ingest_enabled being on, so it costs nothing on the default-off majority). Revoke-only — contentPush: true never grants; that decision stays on the connect screen.
  • Directory assets restored and made reproducible. The WordPress.org banner/icon were generated into a scratch dir earlier this session and lost to a cleanup. They now live in-repo (.wordpress-org/, assets-src/build-assets.sh), both export-ignored, with the zip guard checking for them so a lost ignore line fails the build instead of shipping page art to every install. docs/releasing.md documents the SVN publish procedure.

Why this isn't ready to release yet

Declaring body_blocks changes the app's block-ownership resolution (resolveBlockDeliveryOwner) from worker to cms for any connection that also has a stored ingest secret — which this plugin always sends. On main in the app repo today, that flips ownership for every new page-enhancement block to a CMS push path this plugin does not implement (upsert_block/remove_block), while /seo-head's body goes empty for exactly those blocks.

The app side is adding a separate block_push capability (currently on a branch, not yet on main, not yet deployed) so that body_blocks alone keeps ownership at worker — which is what this plugin needs. This branch does not declare block_push, so once that capability split ships it will behave correctly; until then, merging this PR to main and releasing to WordPress.org would strand every newly-created enhancement.

Confirmed via a live cross-session exchange with the engineer working the app side (citecue/citecue_app): the capability split is agreed and being implemented, but production has no automated deploy — it's manual — so "merged" won't mean "live." Do not release to WordPress.org until that's confirmed deployed.

Test plan

  • 380 tests pass across core, WooCommerce-stub, and multisite suites (composer test)
  • phpcs/phpcbf clean against WPCS
  • bin/build-plugin-zip.sh — version strings agree, package is exactly the 16 runtime files, no dev files leaked
  • assets-src/build-assets.sh — regenerates the four directory-asset PNGs at exact pixel dimensions
  • Manual: apply a content fix targeting an existing page, approve the block, confirm exactly one data-citecue="page-enhancement" section renders and disappears on revert — blocked on the app-side block_push deploy above

🤖 Generated with Claude Code

henry-idingo and others added 9 commits September 3, 2026 11:31
The `/seo-head` response has carried a `body` half since Phase 3 R14 — the
page-enhancement block, a collapsed facts-and-FAQ section composed from the
customer's own answered facts for a page they already have. Until now no
plugin declared `body_blocks`, so every WordPress site was sent `body: ''`
and the feature reached Cloudflare Worker installs only.

Declared and injected in one change, deliberately. The capability is what
makes CiteCue start sending real block markup, so a release that announced it
without placing it would put markup on a live customer page that nothing
renders.

The block rides the response already fetched for the head, so an enhanced
page costs no extra request and inherits the rules that path already keeps:
cache-only on the render, nothing fetched at all where injection is off.
The two halves are otherwise independent — either can be empty while the
other is not, and `refresh()` no longer reads an empty `head` as an empty
answer, which would have thrown every body-only block away.

Placed verbatim, and that is the contract rather than an oversight: the block
is sanitized at composition and capped on the wire, and re-escaping it would
print its tags as text while `wpautop` or the content filters would run a
shortcode quoted inside an answer. The trust is put where it can be reasoned
about instead — the size cap re-checked here, the marker check that refuses
to place a second copy on a page that already carries one, and the last
`</body>` rather than one quoted in a code sample.

Existing connections need one reconnect and now say so. CiteCue records
capabilities at connect time and withholds anything a plugin never announced,
and the old prompt compared one setting, which had not changed — so without
this the feature would have reached nobody who was already a customer, and
nothing would have said why.

Also asks for the site-wide identity baseline. This plugin prints no
Organization node of its own, and the head merge drops CiteCue's JSON-LD
outright when the page carries any, so it can never become a competing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reconnect prompt had one message per metadata setting, and the capability
drift added in the previous commit fires on neither of them: a site upgrading
into page enhancements has not touched that setting, so it was told its
metadata was not reaching CiteCue — a fault it would then go looking for and
not find. The reason is now computed once and named, and the prompt says the
one that applies.

Consent is deliberately not among the reasons. Allowing content pushes gates
the ingest endpoint and nothing on this path — enhancements arrive through the
same authenticated /seo-head read as the metadata — so prompting for it here
would be asking an administrator to grant write access to their site for a
feature that does not use it. "Content from CiteCue" already reports that
state where it belongs.

Enhancements also get their own row in the status card, rather than being
implied by the metadata row that no longer describes them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consent is about to become real on CiteCue's side: reconnecting with the box
unticked clears the stored signing secret, and nothing tells this site when
that happens. The switch here would go on reading "Accepted" over a channel
with no secret behind it.

Two reads close that, because they cover different moments. The plugin's own
claim response is the first: it sent a secret and asked, and the wire contract
omits the flag rather than sending false, so absence THERE is a definite "not
ticked" rather than "not mentioned" — the one context where that inference is
sound. The daily sync is the second, for a withdrawal made at CiteCue between
reconnects, read off `contentPush` on the project entry `/config` already
returns.

Silence still never grants. `contentPush` being true does not switch pushes
on: it says CiteCue holds a secret, which is not the same as this site
agreeing to be written to, and flipping that from a remote read would grant
write access to a site whose owner had refused it. The reconcile only ever
closes.

Absent is not false, either. The field is read with array_key_exists() rather
than a truthiness test, because a CiteCue that predates it would otherwise
look like one withdrawing consent and switch pushes off on every site at once.
Everything ambiguous — a transport failure, a project list this site's key is
not in — leaves the switch alone, since acting on nothing would revoke a
working connection over a bad afternoon on the network.

The daily read costs a request only where pushes are actually on, which the
default is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both survive only as long as someone can see what they defend, and both
defend against a change on CiteCue's side rather than anything in this file —
which is exactly the shape that gets simplified away by a reader checking the
current API and finding the branch unreachable.

The absent-key guard is the dangerous one. `contentPush` is a required
boolean on every project entry, so against today's API the branch genuinely
cannot be reached and the test genuinely cannot fail. What it covers is the
deployment that does not have the field at all — a rollback, a staging origin
on an older build, a self-hosted app behind on releases — which no schema
describes and which arrives without warning. Against one of those, a
truthiness test reads every project as a withdrawal and switches content
pushes off on every site at once.

The revoke-only guard needed the opposite note. Now that a secret is stored
only where the customer ticked the box, `contentPush: true` really does imply
they consented once, so the argument for treating it as a grant is available
in a way it was not before. It is still a fact about CiteCue's storage at one
instant, not the administrator's standing decision about their own site, and
they may have closed the switch here deliberately since.

Comments and test docblocks only. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The banner and icon were generated into a temporary directory and lost to a
cleanup, which is a poor place for a deliverable that took design iteration
to arrive at. They now live in the repository, beside a script that rebuilds
them.

`.wordpress-org/` holds what WordPress.org serves above the readme;
`assets-src/` holds the banner source and the build. Neither is part of the
plugin — the directory serves them from the SVN `assets/` folder, which sits
beside `trunk/` and is never installed on a site — so both are export-ignored
and the zip guard now names them too. A lost export-ignore line should fail
the build rather than ship page art to every install.

The build renders with headless Chrome because it is the one thing reliably
present that lays out real webfont text, and it inlines the faces as data
URIs first: --screenshot does not wait for a font fetched over file://, so a
relative @font-face renders the whole banner in a fallback with different
metrics and reports success. It asserts the four output dimensions, since a
banner at the wrong size is refused by the directory as a silently missing
header rather than an error.

docs/releasing.md writes down the SVN procedure, which existed only in a
terminal scrollback and has now been reconstructed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "already placed" check was a bare substring search, so a page quoting
`<section data-citecue="page-enhancement">` in a code sample read as a page
that already had one, and would have been denied its enhancement forever.
This class already records that lesson about the head — a page that quotes
markup contains whatever it quotes — and the body check had not learned it.

Scoped to a real opening tag, which escaped markup cannot satisfy because the
escaped form has no `<`, and to the whole attribute value rather than a
prefix. The value boundary is not hypothetical: CiteCue composes the FAQ
payload inside the block as `data-citecue="page-enhancement-faq"`, so a prefix
match treats that sibling as the section marker, and the section is what
delimits the region CiteCue owns.

Both existing guarantees still hold — a real block is still detected however
it is quoted, and a duplicate is still refused.

Found by CodeRabbit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PHPUnit suite calls the injector directly or through a synthetic buffer.
That covers the decisions but not what the decisions are for: whether the
block reaches a browser at all, through a real theme, a real wp_head()/
wp_footer(), and whichever output-buffer mechanism the WordPress under test
provides. On 6.9+ that is core's template enhancement filter, which nothing
in the suite exercises end to end.

`bin/local-rig.sh` stands up WordPress from the core the tests already vendor,
symlinks the plugin in, and points it at a stub CiteCue through the plugin's
own CITECUE_API_BASE override. `verify` asserts against rendered pages and
tears down; `up` leaves it running to look at.

Ten checks, and two of them are the ones this rig exists for. The FAQ payload
is real JSON-LD in a real script element, so the block surviving intact is
demonstrated rather than argued. And a page that quotes the marker in a code
sample still gets its block, which is the CodeRabbit fix proven where it
matters rather than in a regex test.

The stub also logs what the plugin sent, so the Bearer token and channel
header are asserted rather than assumed.

bin/ is already export-ignored, so none of this can reach the plugin zip; the
guard in build-plugin-zip.sh covers it either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

The rig could only talk to its own stub, which meant every assertion rested on
my reading of the contract rather than on the server's behaviour. It now takes
RIG_API_BASE / RIG_API_KEY / RIG_PUBLIC_KEY and skips the stub entirely, which
is also what the post-deploy acceptance test will need.

Run against a local build of the merged app, the real endpoint answers
`{"head":"","body":"<section data-citecue=\"page-enhancement\">…"}` — a
populated body with an empty head, for a project that has a block but no
enriched page. That is the exact case the previous `refresh()` discarded by
reading an empty head as an empty answer, so the fix is now demonstrated
against the server rather than against my guess about it.

Two checks had to learn what they were actually asserting:

The head half is now conditional on what the server sent, because the two
halves are independent and a project with a block and no enriched page is a
legitimate answer. Asserting a head unconditionally failed the plugin for the
server's correct behaviour. It reads the plugin's own cache for the answer
rather than lookup_url(), which derives from $_SERVER and is empty under CLI —
that returned '' and skipped the assertion instead of making it, in the mode
where it should have run.

The channel assertion is built with strtolower() rather than written as a
literal. phpcbf's prose sniff rewrites a bare "wordpress" into "WordPress",
which silently turned a passing check into one that failed against correct
behaviour. The plugin's own sources are unaffected — the header value is
lowercase in every one — but a test that phpcbf can invert is a test that will
be inverted again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's coding-standards job failed on the rig I added: phpcs scans the whole
tree, and `bin/rig/*` tripped 42 violations across ten sniffs. I did not catch
it locally because I had been reading phpcs through `tail`, which shows the
timing line and hides every error above it — the command was exiting 1 the
whole time.

Split by whether the sniff is saying something true about this code.

Fixed rather than excused: the stub's missing @Package tag and file-comment
spacing, an undocumented helper, an undocumented parameter, and two short
ternaries. The helper was also called `wp_json_encode_fallback`, which reads
as a WordPress function in a file that deliberately runs outside WordPress;
it is now `citecue_rig_json` and says so.

Waived, alongside `/tests/*` and for the same reason plus one: the rig is not
shipped — bin/ is export-ignored and the zip guard refuses an archive
containing it — and it is not plugin code. Prefixing a local `$url` in a test
harness with `citecue_` would be ceremony that makes the harness harder to
read without making anything safer. Four categories only: global prefixes,
global overrides, file naming.

Also closes a gap the failure exposed: `composer lint` never covered `bin/`,
so a parse error in the rig would have reached CI as a phpcs crash rather than
a syntax error. It covers it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@henry-mosh
henry-mosh merged commit 0c478a1 into main Sep 3, 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