diff --git a/README.md b/README.md index d2d1a78..2287701 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ Middleware between your WordPress site and the AI web. The plugin: 1. **Serves AI-optimized pages to AI bots and crawlers.** When GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User and friends request a page, the plugin fetches the CiteCue-optimized version of that page from the [CiteCue app](https://github.com/henry-mosh/citecue_app) delivery API and serves it instead of the theme output. Human visitors always get your normal site, and any miss, timeout or CiteCue outage falls straight through to the normal page — the integration can never break your site. -2. **Publishes your `llms.txt`** ([llmstxt.org](https://llmstxt.org) convention) at `https://your-site.com/llms.txt`, generated and kept current by CiteCue. -3. **Accepts new content pushed by CiteCue** — content briefs, FAQ packs and other gap-filling pages that promote your brand where AI answers currently miss it — through a signed REST endpoint. Pushed content lands as a **draft** by default so nothing goes live without review. +2. **Enriches your live pages' SEO metadata.** CiteCue's title, meta description, OpenGraph, canonical and JSON-LD tags are added to the `` of the page a *human* sees, so Google and AI answer engines get them too — not only the crawlers served in (1). It fills gaps only: anything WordPress, your theme or your SEO plugin already printed is left exactly as it is. +3. **Publishes your `llms.txt`** ([llmstxt.org](https://llmstxt.org) convention) at `https://your-site.com/llms.txt`, generated and kept current by CiteCue. +4. **Accepts new content pushed by CiteCue** — content briefs, FAQ packs and other gap-filling pages that promote your brand where AI answers currently miss it — through a signed REST endpoint. Pushed content lands as a **draft** by default so nothing goes live without review. ## How it works @@ -17,18 +18,28 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor │ UA matches AI-crawler registry? ──no──► normal theme output │ │ │yes │ │ ▼ │ -│ GET {app}/api/delivery/v2/page?k=…&u=…&b=… │ -│ Authorization: Bearer ck_live_… X-Citecue-Channel: wordpress │ -│ │200/304: serve optimized HTML (x-citecue: served) │ -│ │404 miss / timeout / error: normal theme output │ +│ GET {app}/api/delivery/v2/page?k=…&u=…&b=… wp_head: │ +│ Authorization: Bearer ck_live_… append cached │ +│ │200/304: serve optimized HTML head tags that │ +│ │404 / timeout: normal theme output fill a gap │ └──────────────────────────────────────────────────────────────────┘ ``` +The two halves never overlap. The left one replaces the whole document, for +detected crawlers only. The right one adds head-only tags to the document the +theme rendered, and CiteCue only serves a block for `enriched` pages in `all` +audience mode — content-parity markup, so adding it is additive rather than +cloaking. + - **One request serves and reports.** The v2 delivery endpoint records the crawler hit server-side (`served` for 200/304, `passthrough` for a miss), so CiteCue's Agent Traffic dashboard stays accurate with no extra beacon. - **Conditional revalidation.** Optimized bodies are cached locally with their ETag; revalidation is a cheap 304 round-trip. Misses are negative-cached for 60 s (mirroring the API's `max-age=60` miss sentinel). - **Circuit breaker.** A timeout or 5xx opens a 60 s circuit (10 min on a rejected key): no API calls, stale cache served when available, plain pass-through otherwise. A CiteCue outage never slows human traffic — the API is only ever called for AI-crawler requests in the first place. - **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately. - **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update. +- **Metadata never blocks a render.** The `wp_head` path reads the transient cache and nothing else. A URL with no cached block renders untouched and queues a WP-Cron fetch, so the *next* visitor gets the tags; a stale block keeps being printed while the refresh runs. A human page view never waits on CiteCue. +- **Gap-filling, not overriding.** The injector captures the rendered `` (buffering from `template_redirect` so a theme printing its own `` in `header.php` is seen too, not just `wp_head` output), reads the slots already filled — title, canonical, each meta name/property, JSON-LD — and drops any CiteCue tag whose slot is taken. Detection by output, so it is correct against SEO plugins and themes it has never heard of. It never emits a second `<title>` or a second canonical. +- **Nothing from the response is echoed.** Every tag is parsed, checked against an allowlist of shapes, and *rebuilt* from escaped values: only `application/ld+json` scripts (re-encoded with `JSON_HEX_TAG`), only `canonical`/`alternate` links with an http/https `href`, only `<meta>` carrying a `name`/`property`. Attributes outside that set are discarded. A regex that merely inspects remote markup is one quoted attribute away from authorizing something it misread; rebuilding cannot be. +- **A visitor cannot grow the queue.** The lookup URL is the request's own minus every query argument WordPress does not recognize as a query variable, and scheduling is capped per minute. Without both, `/?x=<random>` — which renders the homepage — would mint unlimited cache keys and cron events. It also keeps order keys, reset tokens and nonces out of what is cached and sent; WooCommerce cart/checkout/account pages are skipped outright, as on the crawler path. - **Verification-compatible.** Served pages carry `X-Citecue: served` and llms.txt carries `X-Citecue: llms-txt` — the headers CiteCue's *Verify installation* button probes for. ## Setup @@ -180,6 +191,7 @@ With WooCommerce active: | `POST /api/delivery/v2/connect/claim` | one-time code | Pairing handshake: code → this site's API key + project | | `GET /api/delivery/v2/config` | `Bearer ck_live_…` | Connection test + project auto-selection by domain | | `GET /api/delivery/v2/page?k&u&b` | `Bearer ck_live_…` + `X-Citecue-Channel: wordpress` | Optimized page for a crawler request (ETag/304; 404 = pass through; hit recorded server-side) | +| `GET /api/delivery/v2/seo-head?k&u` | `Bearer ck_live_…` + `X-Citecue-Channel: wordpress` | Enriched head block for one URL, injected into live pages (204 = nothing to inject; 404 = not optimized; fetched on cron, never on a render) | | `GET /api/delivery/v2/llms.txt?k` | `Bearer ck_live_…` | llms.txt body (ETag/304) | | `GET /api/delivery/v1/crawlers` | none (public) | Daily AI-crawler UA token refresh | @@ -192,19 +204,25 @@ With WooCommerce active: | `citecue_matched_crawler` | filter | Override per-request crawler matching | | `citecue_should_serve` | filter | Veto serving for a specific request | | `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) | -| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler and llms.txt paths combined (default 120); beyond it, requests are answered from cache or passed through | +| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler, llms.txt and metadata-refresh paths combined (default 120); beyond it, requests are answered from cache or passed through | +| `citecue_should_inject_seo_head` | filter | Veto SEO head injection for a specific page | +| `citecue_seo_head_tags` | filter | Change the head tags about to be printed, after the gap-fill — the escape hatch for letting CiteCue win a slot your SEO plugin owns. Output is printed unescaped, so a filter that adds markup owns escaping it | +| `citecue_seo_head_query_vars` | filter | Query variables kept in the metadata lookup URL (defaults to the site's public query vars); anything else is stripped before caching, scheduling or sending | +| `citecue_seo_head_schedule_budget` | filter | Max metadata refreshes queued per minute (default 20) | | `citecue_ingest_postarr` | filter | Adjust the post array before insert/update | | `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) | | `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content | ## Performance -The delivery API is only ever called for an AI-crawler request or for `/llms.txt`. A human page view does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). +The delivery API is never called while a human is waiting. For an AI-crawler request or `/llms.txt` the call is on the request path, because only a bot is blocked by it. For enriched metadata it is on WP-Cron: the render path reads one transient, and a miss queues the fetch rather than making it. + +A human page view with metadata switched off does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). With it switched on, the added cost is one transient read, plus — only when there is something to print — an output buffer held from `template_redirect` to the end of `wp_head` (so it closes within the first few kilobytes of the page, not at the end of it) and a parse of the head it captured. For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download. - **A persistent object cache is recommended.** Cached bodies are transients. With Redis or Memcached they never touch the database. Without one they are rows in `wp_options` — full HTML documents, one per crawled URL, for up to 24 h. They are not autoloaded, so they cost nothing per request, but a heavily crawled site can hold tens of megabytes there until WordPress's twice-daily transient cleanup runs. -- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups and llms.txt together, so no mix of traffic can exceed it. +- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups, llms.txt and metadata refreshes together, so no mix of traffic can exceed it. Queueing a metadata refresh has its own, separate per-minute cap (20), because that one is spent on the render path where a visitor chooses how many URLs to ask about. ## What happens if CiteCue is unavailable @@ -215,6 +233,7 @@ The worst case for an AI crawler is one 3 s wait per minute. For a human visitor ## Notes & caveats - **Full-page caches / CDNs:** a page cache that serves HTML before WordPress loads will answer AI crawlers with the cached human version. Exclude the AI-crawler user agents from your page cache, or rely on CiteCue's Cloudflare Worker install instead of this plugin when your cache sits in front of PHP. Responses served by this plugin set `DONOTCACHEPAGE` and `Cache-Control: private, no-store` so they are never stored for humans. +- **Full-page caches and enriched metadata:** the injected tags are the same for every visitor, so a page cache storing them is correct and desirable — unlike the crawler path, this one deliberately does *not* set `DONOTCACHEPAGE`. The consequence is that a page cached before its block was warm keeps the un-enriched copy until that cache entry expires. - **Physical `llms.txt`:** a real file in the web root is served by the web server before WordPress runs and therefore wins over the plugin. - **Subdirectory installs:** llms.txt is served at the WordPress root (e.g. `/blog/llms.txt`); the domain-root convention requires a root install (or the Cloudflare Worker). - **Uninstall** removes plugin options and scheduled events; content pushed by CiteCue is your content and is kept. @@ -278,3 +297,5 @@ The second URL must be one CiteCue holds an optimized version of; anything else ### Structure notes `Citecue_Proxy` and `Citecue_Llms_Txt` each split into a `decide()` that returns what should happen and a `serve()` that emits headers and calls `exit`. All the branching lives in `decide()`, which is what the tests drive; `serve()` stays deliberately trivial because nothing can assert against a request that has already ended. + +`Citecue_Seo_Head` follows the same split for the same reason, in three pieces rather than two: `decide()` answers "what should this page get" from cache alone, `refresh()` is the only part that talks to the network (on cron, never on a render), and `merge()` is a pure function from (what the rest of `wp_head` printed, CiteCue's block) to the tags that may be added. `merge()` 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. diff --git a/citecue.php b/citecue.php index e3c7353..535b52e 100644 --- a/citecue.php +++ b/citecue.php @@ -2,8 +2,8 @@ /** * 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, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress. - * Version: 1.0.3 + * 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.0 * Requires at least: 5.8 * Requires PHP: 7.4 * Author: CiteCue @@ -56,7 +56,7 @@ static function () { return; } -define( 'CITECUE_VERSION', '1.0.3' ); +define( 'CITECUE_VERSION', '1.1.0' ); define( 'CITECUE_PLUGIN_FILE', __FILE__ ); define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -67,6 +67,7 @@ static function () { require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-api-client.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-connect.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-proxy.php'; +require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-seo-head.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-llms-txt.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-ingest.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-admin.php'; diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index d4a5d7f..95f7447 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -134,6 +134,8 @@ public function notices() { . ' <a href="' . esc_url( $this->settings_url() ) . '">' . esc_html__( 'Open settings', 'citecue-ai-auto-fix' ) . '</a></p></div>'; } + $this->seo_head_reconnect_notice(); + if ( ! isset( $_GET['citecue_msg'] ) || ! isset( $_GET['page'] ) || 'citecue' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only feedback. return; } @@ -174,6 +176,46 @@ public function notices() { . '</p></div>'; } + /** + * Asks for a reconnect when CiteCue's record of this site's SEO-head + * capability is out of date. + * + * CiteCue learns the capability only from the connect exchange, so a site + * that connected before this release injects enriched metadata while the + * app still reports the channel as unable to — and tells the customer their + * "Live" fix is not reaching human visitors. One reconnect fixes it. Shown + * on the Plugins and CiteCue screens only: it is worth acting on, but it is + * not an error, and it has no business following an administrator around + * their whole dashboard. + * + * @return void + */ + private function seo_head_reconnect_notice() { + if ( ! $this->plugin->settings->needs_seo_head_reconnect() ) { + return; + } + + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + $here = $screen ? (string) $screen->id : ''; + if ( 'plugins' !== $here && false === strpos( $here, 'citecue' ) ) { + return; + } + + $enabled = (bool) $this->plugin->settings->get( 'seo_head_enabled' ); + $message = $enabled + ? __( 'this site can now add CiteCue’s enriched title, description, OpenGraph and structured data to your live pages, but CiteCue does not know that yet — until you reconnect, it will keep reporting that your fixes do not reach human visitors.', 'citecue-ai-auto-fix' ) + : __( 'enriched page metadata is switched off here, but CiteCue still expects this site to add it. Reconnect so CiteCue stops reporting metadata it is not getting.', 'citecue-ai-auto-fix' ); + ?> + <div class="notice notice-warning"> + <p> + <strong><?php esc_html_e( 'CiteCue:', 'citecue-ai-auto-fix' ); ?></strong> + <?php echo esc_html( $message ); ?> + </p> + <p><?php $this->action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?></p> + </div> + <?php + } + /** * Completes a handshake when CiteCue redirects back with a one-time code. * @@ -443,7 +485,7 @@ private function render_setup() { // reads every checkbox as "absent means off" — so the delivery // toggles it does not render travel as hidden inputs rather // than being silently switched off by a save. - $this->preserve_toggles( array( 'serve_enabled', 'llms_txt_enabled', 'ingest_enabled' ) ); + $this->preserve_toggles( array( 'serve_enabled', 'llms_txt_enabled', 'seo_head_enabled', 'ingest_enabled' ) ); ?> <table class="form-table" role="presentation"> <tr> @@ -495,6 +537,28 @@ private function render_connected() { <?php endif; ?> </td> </tr> + <tr> + <th scope="row"><?php esc_html_e( 'Enrich page metadata', 'citecue-ai-auto-fix' ); ?></th> + <td> + <label> + <input type="checkbox" name="<?php echo esc_attr( Citecue_Settings::OPTION ); ?>[seo_head_enabled]" value="1" <?php checked( (bool) $settings->get( 'seo_head_enabled' ) ); ?> /> + <?php esc_html_e( 'Add CiteCue’s title, description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them too.', 'citecue-ai-auto-fix' ); ?> + </label> + <p class="description"> + <?php esc_html_e( 'Only fills gaps. Any tag your theme, WordPress or your SEO plugin already outputs is left exactly as it is — CiteCue never emits a second title or canonical.', 'citecue-ai-auto-fix' ); ?> + <?php + $seo_plugin = self::detected_seo_plugin(); + if ( '' !== $seo_plugin ) { + printf( + /* translators: %s: name of the detected SEO plugin. */ + ' ' . esc_html__( '%s is active, so it keeps control of the tags it manages.', 'citecue-ai-auto-fix' ), + esc_html( $seo_plugin ) + ); + } + ?> + </p> + </td> + </tr> <tr> <th scope="row"><?php esc_html_e( 'Serve llms.txt', 'citecue-ai-auto-fix' ); ?></th> <td> @@ -703,6 +767,19 @@ private function render_status_card() { <td><?php esc_html_e( 'Optimized pages', 'citecue-ai-auto-fix' ); ?></td> <td><?php echo $settings->get( 'serve_enabled' ) ? esc_html__( 'Served to AI crawlers', 'citecue-ai-auto-fix' ) : esc_html__( 'Off', 'citecue-ai-auto-fix' ); ?></td> </tr> + <tr> + <td><?php esc_html_e( 'Page metadata', 'citecue-ai-auto-fix' ); ?></td> + <td> + <?php if ( ! $settings->get( 'seo_head_enabled' ) ) : ?> + <?php esc_html_e( 'Off', 'citecue-ai-auto-fix' ); ?> + <?php elseif ( $settings->needs_seo_head_reconnect() ) : ?> + <span style="color:#996800;">—</span> + <?php esc_html_e( 'Enriched here, but CiteCue has not been told yet — reconnect to update it.', 'citecue-ai-auto-fix' ); ?> + <?php else : ?> + <?php esc_html_e( 'Enriched on live pages (gaps only)', 'citecue-ai-auto-fix' ); ?> + <?php endif; ?> + </td> + </tr> <tr> <td><?php esc_html_e( 'llms.txt', 'citecue-ai-auto-fix' ); ?></td> <td> @@ -749,6 +826,36 @@ private function render_status_card() { <?php } + /** + * The name of an active SEO plugin, or '' when none is recognized. + * + * Display only. The injector never asks this question — it reads what + * `wp_head` actually printed, which is the only way to be right about a + * theme or a plugin that is not on this list. Naming the one we do + * recognize just turns "only fills gaps" from a claim into something the + * administrator can check against their own site. + * + * @return string + */ + private static function detected_seo_plugin() { + $known = array( + 'WPSEO_VERSION' => 'Yoast SEO', + 'RANK_MATH_VERSION' => 'Rank Math', + 'AIOSEO_VERSION' => 'All in One SEO', + 'SEOPRESS_VERSION' => 'SEOPress', + 'THE_SEO_FRAMEWORK_VERSION' => 'The SEO Framework', + 'SLIM_SEO_VER' => 'Slim SEO', + ); + + foreach ( $known as $constant => $label ) { + if ( defined( $constant ) ) { + return $label; + } + } + + return ''; + } + /** * The API base row, or a read-only note when wp-config.php pins it. * diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php index 46016fb..24a3f43 100644 --- a/includes/class-citecue-api-client.php +++ b/includes/class-citecue-api-client.php @@ -6,6 +6,7 @@ * (`Authorization: Bearer ck_live_…` + `X-Citecue-Channel: wordpress`): * GET /api/delivery/v2/config — org projects (connection test / selection) * GET /api/delivery/v2/page — optimized page for an AI crawler (ETag/304) + * GET /api/delivery/v2/seo-head — enriched head block for one URL (humans) * GET /api/delivery/v2/llms.txt — llms.txt body (ETag/304) * the public keyless registry feed: * GET /api/delivery/v1/crawlers — AI crawler UA token registry @@ -143,7 +144,7 @@ private function post_json( $url, array $payload, $timeout ) { * the ingest secret at somewhere other than the configured API base. * * @param string $code One-time code from the connect redirect. - * @param array $site {site_url, rest_url, ingest_secret, plugin_version, woocommerce}. + * @param array $site {site_url, rest_url, ingest_secret, plugin_version, woocommerce, seo_head}. * @return array|WP_Error {apiKey, publicKey, domain, ingest?} */ public function claim_connect_code( $code, array $site ) { @@ -260,6 +261,47 @@ public function get_page( $url, $crawler_token, $etag = '' ) { ); } + /** + * GET /api/delivery/v2/seo-head — the enriched head block for one URL. + * + * CiteCue has three distinct empty answers here and they are not + * interchangeable, so they are passed up untouched rather than collapsed: + * 204 means the project is fine but there is nothing to inject right now + * (audience not `all`, or no enriched page for this URL), 404 is the same + * "unknown key / disabled project / bad input" sentinel the page endpoint + * uses, and 401 is a rejected key. A 204 carries no body at all. + * + * @param string $url Absolute URL of the page being rendered. + * @return array|WP_Error {status:int, head:string} + */ + public function get_seo_head( $url ) { + $endpoint = add_query_arg( + array( + 'k' => rawurlencode( (string) $this->settings->get( 'public_key' ) ), + 'u' => rawurlencode( (string) $url ), + ), + $this->settings->api_base() . '/api/delivery/v2/seo-head' + ); + + $result = $this->get( $endpoint, $this->auth_headers(), $this->serve_timeout() ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $head = ''; + if ( 200 === $result['status'] ) { + $data = json_decode( $result['body'], true ); + if ( is_array( $data ) && isset( $data['head'] ) && is_string( $data['head'] ) ) { + $head = $data['head']; + } + } + + return array( + 'status' => $result['status'], + 'head' => $head, + ); + } + /** * GET /api/delivery/v2/llms.txt — the project's llms.txt. * diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index d57511d..26dc9de 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -190,8 +190,49 @@ public function consume_lookup_budget() { * * @param int $limit Default 120. */ - $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) ); - $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS ); + return $this->consume_minute_budget( 'citecue_budget_', apply_filters( 'citecue_lookup_budget', 120 ) ); + } + + /** + * Consumes one unit of a per-minute counter, as close to atomically as the + * site's object cache allows. + * + * Read-then-write is a race (PR #10 review): concurrent requests all read + * the same count, all find themselves under the limit, and all proceed, so + * the ceiling can be overshot by roughly the concurrency. `wp_cache_incr()` + * is a single operation on the backing store, so where a persistent object + * cache exists the count is exact. + * + * Without one there is nothing to be exact with — the options table has no + * atomic increment reachable through the transient API — so that path stays + * best-effort by necessity, and both callers are rate limits whose failure + * mode is a bounded overshoot rather than an unbounded one. It is also + * where a failed increment lands, so no failure of the fast path can ever + * answer "allowed" without counting something. Shared by both budgets so + * they cannot drift apart on this. + * + * @param string $prefix Transient/cache key prefix, including the trailing separator. + * @param int $limit Units allowed in one minute. + * @return bool Whether a unit was available. + */ + private function consume_minute_budget( $prefix, $limit ) { + $limit = max( 1, (int) $limit ); + $key = $prefix . (int) floor( time() / MINUTE_IN_SECONDS ); + + if ( wp_using_ext_object_cache() ) { + wp_cache_add( $key, 0, 'citecue', 2 * MINUTE_IN_SECONDS ); + $count = wp_cache_incr( $key, 1, 'citecue' ); + if ( false !== $count ) { + return $count <= $limit; + } + // The increment failed: the entry was evicted between the add and + // the increment, or the backend is not answering. Fall through to + // the counter below rather than reading it as a fresh bucket + // (CodeRabbit review) — a backend failing every increment would + // then return "allowed" every time and remove the ceiling + // altogether, which is the one outcome a rate limit may not have. + } + $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; @@ -200,6 +241,105 @@ public function consume_lookup_budget() { return true; } + /** + * Transient key for a URL's SEO head block. Keyed separately from the page + * body: the two have different lifetimes and different eviction triggers — + * a page can stop being injectable (audience switched off) while its + * optimized body is still perfectly servable to crawlers. + * + * @param string $url Absolute page URL. + * @return string + */ + private function seo_head_key( $url ) { + return 'citecue_sh_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ); + } + + /** + * Cached SEO head block for a URL, or null. + * + * @param string $url Absolute page URL. + * @return array{block:string,cached_at:int}|null + */ + public function get_seo_head( $url ) { + $hit = get_transient( $this->seo_head_key( $url ) ); + return ( is_array( $hit ) && isset( $hit['block'] ) ) ? $hit : null; + } + + /** + * Stores a URL's SEO head block. + * + * @param string $url Absolute page URL. + * @param string $block Head markup. + * @return void + */ + public function set_seo_head( $url, $block ) { + set_transient( + $this->seo_head_key( $url ), + array( + 'block' => (string) $block, + 'cached_at' => time(), + ), + self::BODY_TTL + ); + } + + /** + * Removes a cached SEO head block. Called whenever CiteCue says it has + * nothing for the URL, so a block from before the audience switch was + * flipped (or before the page was unapproved) cannot keep being printed on + * a live page for the rest of the day. + * + * @param string $url Absolute page URL. + * @return void + */ + public function delete_seo_head( $url ) { + delete_transient( $this->seo_head_key( $url ) ); + } + + /** + * Whether CiteCue recently said it has no head block for this URL. + * + * @param string $url Absolute page URL. + * @return bool + */ + public function is_recent_seo_head_miss( $url ) { + return (bool) get_transient( 'citecue_shm_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ) ); + } + + /** + * Records that CiteCue has no head block for this URL. Mirrors the API's + * `max-age=60` on both of its empty answers (204 and the 404 sentinel). + * + * @param string $url Absolute page URL. + * @return void + */ + public function set_seo_head_miss( $url ) { + set_transient( 'citecue_shm_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL ); + } + + /** + * Consumes one unit of the per-minute budget for QUEUEING a metadata + * refresh, which is a different ceiling from the outbound-call one above + * and needs to be (PR #10 review). + * + * `consume_lookup_budget()` is spent when a job RUNS, so it bounds what + * reaches CiteCue and nothing else. Scheduling happens on the render path, + * where an anonymous visitor decides how many distinct URLs to ask about — + * and every scheduled event is a row in WordPress's serialized `cron` + * option, which is rewritten in full on every change. Unbounded, that turns + * page views into an ever more expensive database write. This caps it. + * + * @return bool Whether a refresh may be queued. + */ + public function consume_seo_head_schedule_budget() { + /** + * Filters the maximum SEO head refreshes queued per minute. + * + * @param int $limit Default 20. + */ + return $this->consume_minute_budget( 'citecue_shb_', apply_filters( 'citecue_seo_head_schedule_budget', 20 ) ); + } + /** * Cached llms.txt, or null. * diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php index 6c3c8ab..a0af591 100644 --- a/includes/class-citecue-connect.php +++ b/includes/class-citecue-connect.php @@ -143,6 +143,13 @@ public function verify_state( $state ) { public function claim( $code ) { $settings = $this->plugin->settings; + // The capability, not the ambition: CiteCue uses this to decide whether + // `seoAudience: 'all'` is a promise this channel can keep, and a site + // that has injection switched off keeps it no better than a plugin that + // cannot inject at all. Reporting the live setting is what stops the + // app badging a fix "Live" over a head nothing writes to. + $seo_head = (bool) $settings->get( 'seo_head_enabled' ); + $result = $this->plugin->api->claim_connect_code( $code, array( @@ -151,6 +158,7 @@ public function claim( $code ) { 'ingest_secret' => $settings->ensure_ingest_secret(), 'plugin_version' => CITECUE_VERSION, 'woocommerce' => class_exists( 'WooCommerce' ), + 'seo_head' => $seo_head, ) ); @@ -159,9 +167,13 @@ public function claim( $code ) { } $update = array( - 'api_key' => $result['apiKey'], - 'public_key' => $result['publicKey'], - 'project_domain' => $result['domain'], + 'api_key' => $result['apiKey'], + 'public_key' => $result['publicKey'], + 'project_domain' => $result['domain'], + // Only after the exchange succeeded: a failed claim wrote nothing + // on CiteCue's side, so recording it here would silence the + // reconnect prompt for a capability the app never learned about. + 'seo_head_reported' => $seo_head, ); // CiteCue's connect screen is where the customer is told that content @@ -200,11 +212,15 @@ public function disconnect() { // flag — but sanitize() only runs once register_setting() has, // and the empty value is what a write that bypasses the filter // has to see. Either path must end up with no key. - 'api_key' => '', - 'api_key_clear' => 1, - 'public_key' => '', - 'project_domain' => '', - 'ingest_enabled' => false, + 'api_key' => '', + 'api_key_clear' => 1, + 'public_key' => '', + 'project_domain' => '', + 'ingest_enabled' => false, + // Back to "never reported": the next connection mints a new key + // with its own capabilities, and what the old one recorded says + // nothing about it. + 'seo_head_reported' => null, ) ); diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index fc4f658..f660d3b 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -91,6 +91,7 @@ private function __construct() { ( new Citecue_Llms_Txt( $this ) )->register(); ( new Citecue_Proxy( $this ) )->register(); + ( new Citecue_Seo_Head( $this ) )->register(); ( new Citecue_Ingest( $this ) )->register(); if ( is_admin() ) { @@ -101,6 +102,63 @@ private function __construct() { add_action( self::CRON_HOOK, array( $this, 'daily_sync' ) ); } + /** + * The absolute URL of the current request, as the delivery API should be + * asked about it. CiteCue normalizes it server-side (scheme/www/trailing + * slash/tracking params), so this only has to be faithful. + * + * Lives on the container because two callers need it — the crawler proxy + * and the SEO head injector — and they must agree to the character: the + * page cache and the head cache key off the same string, and a URL the two + * spell differently is two cache entries for one page. + * + * @return string + */ + public static function current_url() { + if ( ! isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { + return ''; + } + $scheme = is_ssl() ? 'https' : 'http'; + $host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); + // esc_url_raw(), not sanitize_text_field(): the latter deletes every + // percent-encoded sequence it finds, so /caf%C3%A9/ would reach CiteCue + // as /caf/ and be cached under the wrong key. + $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); + return esc_url_raw( $scheme . '://' . $host . $uri ); + } + + /** + * WooCommerce requests neither delivery path may touch: cart, checkout + * (incl. order-pay / order-received), account pages and every other WC + * endpoint are session/transactional; `?add-to-cart=` GETs mutate the cart + * and `wc-ajax` calls are API traffic. Product and shop-archive pages + * remain eligible — those are the highest-value pages to optimize. + * + * Shared with the SEO head injector rather than owned by the proxy + * (PR #10 review). The proxy's reason for skipping these is that they are + * session content; the injector has a second, sharper one — their URLs + * carry order ids, `wc_order_*` keys and account tokens, and that path + * would put the URL in a cron argument and then send it to CiteCue. One + * copy means the two can never disagree about which those pages are. + * + * @return bool True when this request belongs to WooCommerce. + */ + public static function is_woocommerce_request() { + if ( ! class_exists( 'WooCommerce' ) ) { + return false; + } + if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) { + return true; + } + if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url() ) { + return true; + } + if ( isset( $_GET['wc-ajax'] ) || isset( $_GET['add-to-cart'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request classification. + return true; + } + return false; + } + /** * Init: cron self-heal. * @@ -158,5 +216,10 @@ public static function activate() { */ public static function deactivate() { wp_clear_scheduled_hook( self::CRON_HOOK ); + // Queued metadata refreshes, one per URL. wp_unschedule_hook(), not + // wp_clear_scheduled_hook(): the latter only clears events whose + // arguments match the ones passed, and every one of these carries its + // own URL, so an argument-less call would clear none of them. + wp_unschedule_hook( Citecue_Seo_Head::REFRESH_HOOK ); } } diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php index 6179608..96b6a91 100644 --- a/includes/class-citecue-proxy.php +++ b/includes/class-citecue-proxy.php @@ -82,7 +82,7 @@ public function decide() { return self::pass( 'not-a-crawler' ); } - $url = $this->current_url(); + $url = Citecue_Plugin::current_url(); if ( '' === $url ) { return self::pass( 'no-url' ); } @@ -250,7 +250,7 @@ private function is_eligible_request() { if ( '' !== (string) get_query_var( 'sitemap' ) ) { return false; } - if ( $this->is_excluded_woocommerce_request() ) { + if ( Citecue_Plugin::is_woocommerce_request() ) { return false; } if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { @@ -271,50 +271,6 @@ private function is_eligible_request() { return true; } - /** - * WooCommerce requests the proxy must never touch: cart, checkout (incl. - * order-pay / order-received), account pages and every other WC endpoint - * are session/transactional; `?add-to-cart=` GETs mutate the cart and - * `wc-ajax` calls are API traffic. Product and shop-archive pages remain - * eligible — those are the highest-value pages to serve optimized. - * - * @return bool True when this request belongs to WooCommerce. - */ - private function is_excluded_woocommerce_request() { - if ( ! class_exists( 'WooCommerce' ) ) { - return false; - } - if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) { - return true; - } - if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url() ) { - return true; - } - if ( isset( $_GET['wc-ajax'] ) || isset( $_GET['add-to-cart'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request classification. - return true; - } - return false; - } - - /** - * The absolute URL of the current request. CiteCue normalizes it - * server-side (scheme/www/trailing-slash/tracking params). - * - * @return string - */ - private function current_url() { - if ( ! isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { - return ''; - } - $scheme = is_ssl() ? 'https' : 'http'; - $host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); - // esc_url_raw(), not sanitize_text_field(): the latter deletes every - // percent-encoded sequence it finds, so /caf%C3%A9/ would reach CiteCue - // as /caf/ and be cached under the wrong key. - $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); - return esc_url_raw( $scheme . '://' . $host . $uri ); - } - /** * Emits the optimized document and ends the request. Stamps the * `X-Citecue: served` header CiteCue's install verifier looks for, and diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php new file mode 100644 index 0000000..d635a24 --- /dev/null +++ b/includes/class-citecue-seo-head.php @@ -0,0 +1,770 @@ +<?php +/** + * Injects CiteCue's enriched SEO head block into ordinary page loads. + * + * This is the human-facing half of the delivery channel, and the opposite of + * Citecue_Proxy in every way that matters. The proxy replaces the whole + * document, for detected AI crawlers only. This one adds a handful of + * head-only tags — title, meta description, OpenGraph, canonical, JSON-LD — + * to the page a browser (or Googlebot) already sees, leaving the visible page + * untouched. CiteCue only serves a block for `enriched` pages in `all` + * audience mode, which is what keeps the two apart: enriched markup is + * 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. + * + * **Never emit a duplicate.** WordPress core prints `<title>` and + * `<link rel="canonical">` 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 `<title>` is invalid + * HTML and a second canonical makes Google pick one arbitrarily — so this + * fills gaps only: it captures the rendered `<head>` 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 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 + * path reads the transient cache and nothing else: a miss injects nothing and + * schedules a background refresh, so the next visitor gets the block. Cold + * pages cost one un-enriched view, never one slow one. + * + * **Never print markup we did not build.** The block is trusted content for + * this site's own head, but it arrives over the network and lands in a human's + * browser. Nothing from the response is echoed: every tag is parsed, checked + * against an allowlist of shapes, and rebuilt from escaped values. A regex + * that merely *inspects* remote markup is one quoted attribute away from + * authorizing something it misread (PR #10 review) — rebuilding cannot be. + * + * **Never let a visitor grow the queue.** The URL asked about is the request's + * own, minus every query argument WordPress does not recognize, and scheduling + * is capped per minute. Without both, `/?x=<random>` — which resolves to the + * homepage — would mint an unbounded number of cache keys and cron events. + * + * @package Citecue + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Live-page SEO head injection. + */ +class Citecue_Seo_Head { + + /** How long a cached block stays fresh before a background refresh (matches the API's max-age=300). */ + const FRESH_SECONDS = 5 * MINUTE_IN_SECONDS; + + /** Cron hook for the out-of-band fetch. */ + const REFRESH_HOOK = 'citecue_refresh_seo_head'; + + /** How long one URL's refresh request is suppressed, so a burst of visitors schedules one job. */ + 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. + */ + const CAPTURE_START_PRIORITY = 1; + + /** `wp_head` priority the capture closes and injects at: after everyone. */ + const CAPTURE_END_PRIORITY = PHP_INT_MAX; + + /** + * `<link>` relations that may be injected, and `<meta>` values are escaped + * into a tag we build ourselves. Anything else in the block is dropped: + * `stylesheet`, `preload` and friends have no business arriving from a + * metadata endpoint. + */ + const ALLOWED_LINK_RELS = array( 'canonical', 'alternate' ); + + /** + * Query arguments kept when there is no `wp` object to ask. Only the + * built-ins that actually select content on a plain-permalink install — + * enough that such a site still gets its pages enriched. + */ + const FALLBACK_QUERY_VARS = array( 'p', 'page_id', 'cat', 'tag', 'name', 'pagename', 'post_type', 'paged', 'page', 'author_name', 'category_name', 'year', 'monthnum', 'day' ); + + /** + * Plugin container. + * + * @var Citecue_Plugin + */ + 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 + * most, one scheduled refresh. + * + * @var array|null + */ + private $decision = null; + + /** + * Constructor. + * + * @param Citecue_Plugin $plugin Plugin container. + */ + public function __construct( Citecue_Plugin $plugin ) { + $this->plugin = $plugin; + } + + /** + * Hooks the capture and the background refresh worker. + * + * @return void + */ + 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 ); + } + + /** + * 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 `<title>`, 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 `<head>`, so it closes within the + * first few kilobytes. + * + * @return void + */ + public function start_capture() { + $this->buffer_level = null; + $this->decision = $this->decide(); + + if ( ! $this->decision['inject'] ) { + return; + } + + ob_start(); + $this->buffer_level = ob_get_level(); + } + + /** + * Closes the capture, re-emits everything rendered so far, and appends the + * CiteCue tags that found an empty slot. + * + * @return void + */ + public function finish_capture() { + $level = $this->buffer_level; + $decision = $this->decision; + $this->buffer_level = null; + $this->decision = null; + + if ( null === $level || null === $decision ) { + return; + } + + // 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; + } + + $head = (string) ob_get_clean(); + $tags = self::merge( $head, $decision['block'] ); + + // 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 + + if ( ! $tags ) { + return; + } + + echo "\n<!-- CiteCue -->\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 + } + + /** + * 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. + * + * @return array{inject:bool,block:string,reason:string} + */ + public function decide() { + $settings = $this->plugin->settings; + if ( ! $settings->get( 'seo_head_enabled' ) || ! $settings->is_delivery_configured() ) { + return self::skip( 'not-configured' ); + } + if ( ! $this->is_eligible_request() ) { + return self::skip( 'not-eligible' ); + } + + $url = self::lookup_url(); + if ( '' === $url ) { + return self::skip( 'no-url' ); + } + + /** + * Filters whether to inject CiteCue's SEO head into this page. + * + * @param bool $should_inject Default true. + * @param string $url URL the block is looked up by. + */ + if ( ! apply_filters( 'citecue_should_inject_seo_head', true, $url ) ) { + return self::skip( 'vetoed' ); + } + + $cache = $this->plugin->cache; + $cached = $cache->get_seo_head( $url ); + + if ( $cached && $cache->is_fresh( $cached, self::FRESH_SECONDS ) ) { + return self::block( $cached['block'], 'cached' ); + } + + // CiteCue recently said it has nothing for this URL. Unlike the crawler + // path this one runs on every human page view, so without the negative + // cache an un-enriched site would schedule a refresh job per visitor. + if ( $cache->is_recent_seo_head_miss( $url ) ) { + return self::skip( 'recent-miss' ); + } + + $this->schedule_refresh( $url ); + + // Stale-while-revalidate: a day-old block is still this page's own + // metadata, and withholding it while the refresh runs would blank the + // tags on every page during a CiteCue outage. + return $cached ? self::block( $cached['block'], 'stale' ) : self::skip( 'no-cache' ); + } + + /** + * Fetches one URL's block out of band. Runs on cron, never on a page + * render, so this is the only place here that may take a network round + * trip — and it shares the circuit breaker and the per-minute lookup budget + * with the crawler path, so the site's total outbound calls stay bounded + * however traffic is distributed between the two. + * + * @param string $url Absolute page URL. + * @return string Outcome (diagnostic only, and what the tests assert on). + */ + public function refresh( $url ) { + $url = (string) $url; + $settings = $this->plugin->settings; + + if ( ! $settings->get( 'seo_head_enabled' ) || ! $settings->is_delivery_configured() ) { + return 'not-configured'; + } + + $cache = $this->plugin->cache; + if ( $cache->is_circuit_open() ) { + return 'circuit-open'; + } + if ( ! $cache->consume_lookup_budget() ) { + return 'budget-exhausted'; + } + + $response = $this->plugin->api->get_seo_head( $url ); + + if ( is_wp_error( $response ) ) { + $cache->trip_circuit(); + return 'transport-error'; + } + + switch ( $response['status'] ) { + case 200: + if ( '' === $response['head'] ) { + // A 200 carrying no block is a payload we do not understand. + // Treat it as "nothing to inject" rather than caching an + // empty string that would read as a valid block. + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'empty'; + } + $cache->set_seo_head( $url, $response['head'] ); + return 'fresh'; + + case 204: + // Valid project, nothing to inject right now — the audience is + // not `all`, or this URL has no enriched page. Evict, so a block + // from before the switch was flipped cannot keep appearing. + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'nothing-to-inject'; + + case 404: + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'not-optimized'; + + case 401: + update_option( 'citecue_auth_failed', time(), false ); + $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); + return 'unauthorized'; + + default: + $cache->trip_circuit(); + return 'server-error'; + } + } + + /** + * The URL this request's block is looked up by: the request's own, minus + * every query argument WordPress does not recognize as a query variable. + * + * Dropping the rest is not tidiness (PR #10 review). `/?x=<random>` still + * resolves to the homepage, so keeping the raw query string would let an + * anonymous visitor mint unlimited distinct cache keys and cron arguments + * for one page; and a query string can carry things that must never be + * sent to a third party — a password-reset token, a WooCommerce order key, + * a nonce. What survives is the set that actually selects content, which is + * exactly what CiteCue could have an enriched page for. + * + * @return string + */ + public static function lookup_url() { + $url = Citecue_Plugin::current_url(); + if ( '' === $url ) { + return ''; + } + + $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); + if ( '' === $query ) { + return $url; + } + + // Split the query string by hand rather than with parse_str(), and keep + // what is allowed rather than removing what is not (PR #10 review). + // parse_str() rewrites `.` and space in a parameter name to `_`, so it + // reports `x_1` for a parameter literally named `x.1` — and a removal + // list built from those names asks remove_query_arg() to drop something + // the URL does not contain, leaving the real parameter in place. That + // is the whole bypass this function exists to prevent, so it is built + // the other way round: an unrecognized name is not removed, it is + // simply never copied across, and a name this code cannot parse can + // therefore never survive. + $allowed = self::allowed_query_vars(); + $keep = array(); + foreach ( explode( '&', $query ) as $pair ) { + if ( '' === $pair ) { + continue; + } + $name = urldecode( explode( '=', $pair, 2 )[0] ); + if ( in_array( $name, $allowed, true ) ) { + $keep[] = $pair; + } + } + + $base = explode( '?', $url, 2 )[0]; + + return $keep ? $base . '?' . implode( '&', $keep ) : $base; + } + + /** + * Query variable names that may survive into the lookup URL. + * + * Taken from the live `WP` object, so a custom post type or a plugin that + * registers its own public query variable keeps working without this + * needing to know about it. Falls back to the built-ins when there is no + * `WP` object (WP-Cron, WP-CLI, a unit test calling this directly). + * + * @return string[] + */ + private static function allowed_query_vars() { + $wp = isset( $GLOBALS['wp'] ) ? $GLOBALS['wp'] : null; + + $vars = ( $wp instanceof WP && ! empty( $wp->public_query_vars ) ) + ? $wp->public_query_vars + : self::FALLBACK_QUERY_VARS; + + /** + * Filters the query variables kept in the SEO head lookup URL. + * + * Anything not listed is stripped before the URL is cached, scheduled + * or sent to CiteCue. Add to it only for a variable that genuinely + * selects different content, and never for one that carries a token. + * + * @param string[] $vars Allowed query variable names. + */ + $vars = apply_filters( 'citecue_seo_head_query_vars', $vars ); + + return is_array( $vars ) ? array_map( 'strval', $vars ) : array(); + } + + /** + * The CiteCue tags that may be added to a head that already contains + * `$existing`: every tag whose slot — the title, the canonical, one meta + * name/property, JSON-LD as a whole — nothing else has claimed. + * + * Pure, and deliberately so: this is the whole conflict-avoidance contract + * with every other SEO plugin on the site, and it is worth being able to + * test it against a real Yoast head dump without a request in sight. + * + * @param string $existing Rendered head markup. + * @param string $block CiteCue's head block. + * @return string[] Rebuilt tags to append, in the order CiteCue sent them. + */ + public static function merge( $existing, $block ) { + $occupied = self::slots_in( $existing ); + $tags = array(); + + foreach ( self::tags_in( $block ) as $tag ) { + $rebuilt = self::rebuild_tag( $tag ); + if ( null === $rebuilt ) { + continue; + } + list( $slot, $html ) = $rebuilt; + if ( isset( $occupied[ $slot ] ) ) { + continue; + } + // Claim it here too, so a block that somehow carries two canonicals + // cannot contribute both. + $occupied[ $slot ] = true; + $tags[] = $html; + } + + /** + * Filters the CiteCue head tags about to be printed. + * + * 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. + * + * @param string[] $tags Tags that survived the gap-fill. + * @param string $block The full block CiteCue returned. + * @param string $existing Rendered head markup. + */ + $tags = apply_filters( 'citecue_seo_head_tags', $tags, $block, $existing ); + + return is_array( $tags ) ? array_values( array_filter( $tags, 'is_string' ) ) : array(); + } + + /** + * Splits head markup into the individual elements this class reasons about. + * + * @param string $html Head markup. + * @return string[] + */ + private static function tags_in( $html ) { + $pattern = '#<title\b[^>]*>.*?|]*>.*?|<(?:meta|link)\b[^>]*>#is'; + return preg_match_all( $pattern, (string) $html, $matches ) ? $matches[0] : array(); + } + + /** + * The slots occupied by markup already in the head. + * + * @param string $html Head markup. + * @return array + */ + private static function slots_in( $html ) { + $slots = array(); + foreach ( self::tags_in( $html ) as $tag ) { + $slot = self::slot_for( $tag ); + if ( '' !== $slot ) { + $slots[ $slot ] = true; + } + } + return $slots; + } + + /** + * The slot one existing element claims, or '' for one that claims none. + * + * Only asks "is this slot taken", so any `` relation counts and any + * `$#is', $tag, $match ) ) { + return null; + } + $data = json_decode( trim( $match[1] ), true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { + return null; + } + // JSON_HEX_TAG is what makes this safe inside a script element: it + // renders every angle bracket as a < / > escape, so a + // closing script tag buried in a string value cannot end the + // element early. Re-encoding from the decoded data, rather than + // passing the original text through, is what guarantees there is + // nothing else in there either. + $json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); + return ( false === $json || '' === $json ) + ? null + : array( 'jsonld', '' ); + } + + if ( preg_match( '#^' ); + } + + if ( preg_match( '#^'; + return array( 'meta:' . strtolower( $key ), $html ); + } + + return null; + } + + /** + * An attribute value or element body as plain text, ready to be escaped + * back out. Decoded first: the value arrives HTML-encoded, so escaping it + * as-is would turn `&` into `&amp;` on the page. + * + * @param string $raw Encoded value. + * @return string + */ + private static function text( $raw ) { + return trim( html_entity_decode( wp_strip_all_tags( (string) $raw ), ENT_QUOTES, 'UTF-8' ) ); + } + + /** + * An element's attributes, lowercased names to unquoted values. + * + * Walks name/value pairs left to right rather than searching for one + * attribute at a time, which is the difference between reading markup and + * guessing at it: a search for `rel=` finds it inside `foo="a rel=x"`, + * whereas a scan that consumes each quoted value as part of its own pair + * cannot. First occurrence wins, matching how browsers resolve a repeated + * attribute. + * + * @param string $tag One element. + * @return array + */ + private static function attributes( $tag ) { + $out = array(); + + if ( ! preg_match( '#^<[a-zA-Z][a-zA-Z0-9]*([^>]*)>#', (string) $tag, $match ) ) { + return $out; + } + + $pattern = '#([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|\'[^\']*\'|[^\s"\'>]+))?#'; + if ( ! preg_match_all( $pattern, $match[1], $found, PREG_SET_ORDER ) ) { + return $out; + } + + foreach ( $found as $pair ) { + $name = strtolower( $pair[1] ); + if ( isset( $out[ $name ] ) ) { + continue; + } + $value = isset( $pair[2] ) ? $pair[2] : ''; + if ( '' !== $value && ( '"' === $value[0] || "'" === $value[0] ) ) { + $value = substr( $value, 1, -1 ); + } + $out[ $name ] = $value; + } + + return $out; + } + + /** + * Queues one background fetch for a URL, at most once a minute per URL and + * within a site-wide per-minute ceiling. + * + * The per-URL lock alone does not bound anything (PR #10 review): distinct + * URLs take distinct locks, and the outbound lookup budget is only spent + * when a job RUNS, so without a ceiling here an anonymous visitor could + * push unlimited events into WordPress's serialized cron option and make + * every subsequent write more expensive. `lookup_url()` removes most of the + * ways to mint a distinct URL; this bounds what is left. + * + * @param string $url Lookup URL. + * @return void + */ + private function schedule_refresh( $url ) { + $lock = 'citecue_shq_' . md5( Citecue_Cache::normalize_url( $url ) ); + if ( get_transient( $lock ) ) { + return; + } + + if ( ! $this->plugin->cache->consume_seo_head_schedule_budget() ) { + return; + } + + set_transient( $lock, 1, self::REFRESH_LOCK_TTL ); + + wp_schedule_single_event( time(), self::REFRESH_HOOK, array( $url ) ); + } + + /** + * Whether this request is one to inject into: a frontend GET rendering a + * real page. Logged-in users are deliberately included — the tags are the + * page's own public metadata, and an administrator checking View Source is + * exactly who needs to see them. + * + * @return bool + */ + private function is_eligible_request() { + if ( is_admin() ) { + return false; + } + if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) { + return false; + } + if ( is_feed() || is_robots() || is_trackback() || is_preview() || is_embed() ) { + return false; + } + if ( is_customize_preview() ) { + return false; + } + // A 404 or a search results page has no CiteCue counterpart, and a + // canonical pointing anywhere from either would be actively wrong. + if ( is_404() || is_search() ) { + return false; + } + if ( function_exists( 'is_favicon' ) && is_favicon() ) { + return false; + } + // The same store pages the crawler proxy refuses to touch, for a reason + // that applies twice over here (PR #10 review): those URLs carry order + // ids, `wc_order_*` keys and account tokens, and this path would put + // the URL in a cron argument and then send it to CiteCue. + if ( Citecue_Plugin::is_woocommerce_request() ) { + return false; + } + // Content behind a password is not content CiteCue has, and its + // metadata should not describe what a visitor cannot read. + if ( is_singular() && post_password_required() ) { + return false; + } + return true; + } + + /** + * A "leave this head alone" decision. + * + * @param string $reason Why nothing is injected (diagnostic only). + * @return array{inject:bool,block:string,reason:string} + */ + private static function skip( $reason ) { + return array( + 'inject' => false, + 'block' => '', + 'reason' => $reason, + ); + } + + /** + * An "inject this block" decision. + * + * @param string $block CiteCue head block. + * @param string $reason Where the block came from (diagnostic only). + * @return array{inject:bool,block:string,reason:string} + */ + private static function block( $block, $reason ) { + return array( + 'inject' => true, + 'block' => (string) $block, + 'reason' => $reason, + ); + } +} diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php index c742c18..1061071 100644 --- a/includes/class-citecue-settings.php +++ b/includes/class-citecue-settings.php @@ -44,6 +44,13 @@ public static function defaults() { // Delivery. 'serve_enabled' => true, 'llms_txt_enabled' => true, + 'seo_head_enabled' => true, + // The value of seo_head_enabled last reported to CiteCue, or null + // if this site has never reported one. CiteCue records the + // capability on the API key at connect time and has no other way to + // learn it, so this is how the settings screen knows to ask for a + // reconnect — see needs_seo_head_reconnect(). + 'seo_head_reported' => null, // Content ingest (CiteCue -> WordPress post creation). 'ingest_enabled' => false, 'ingest_secret' => '', @@ -182,6 +189,35 @@ public function is_delivery_configured() { return '' !== (string) $this->get( 'api_key' ) && '' !== (string) $this->get( 'public_key' ); } + /** + * Whether CiteCue's record of this site's SEO-head capability disagrees + * with what the site is actually doing, so a reconnect is worth asking for. + * + * CiteCue stores the capability on the API key, written only by the connect + * exchange, and reads its absence as "cannot inject" — deliberately, so a + * plugin built before the endpoint existed can never make the app promise + * markup nothing puts on the page. That fail-closed default is also what + * makes this comparison simple: never-reported and reported-false mean the + * same thing to CiteCue. + * + * The disagreement is worth surfacing in both directions. Under-claiming + * loses the customer a feature they are paying for and shows "this channel + * can't inject" against a plugin that now can; over-claiming is the failure + * the capability exists to prevent. + * + * @return bool + */ + public function needs_seo_head_reconnect() { + if ( ! $this->is_connected() ) { + return false; + } + + $reported = $this->get( 'seo_head_reported' ); + $known = null === $reported ? false : (bool) $reported; + + return (bool) $this->get( 'seo_head_enabled' ) !== $known; + } + /** * Ensures the ingest shared secret exists, generating one if missing. * @@ -238,6 +274,7 @@ public function sanitize( $input ) { $out['serve_enabled'] = ! empty( $input['serve_enabled'] ); $out['llms_txt_enabled'] = ! empty( $input['llms_txt_enabled'] ); + $out['seo_head_enabled'] = ! empty( $input['seo_head_enabled'] ); $out['ingest_enabled'] = ! empty( $input['ingest_enabled'] ); if ( isset( $input['ingest_post_status'] ) && in_array( $input['ingest_post_status'], array( 'draft', 'pending', 'publish' ), true ) ) { @@ -268,6 +305,11 @@ public function sanitize( $input ) { if ( isset( $input['project_domain'] ) && is_string( $input['project_domain'] ) ) { $out['project_domain'] = sanitize_text_field( $input['project_domain'] ); } + // Tri-state: null means "never reported", which is what an install that + // predates the capability must keep reading as until it reconnects. + if ( array_key_exists( 'seo_head_reported', $input ) ) { + $out['seo_head_reported'] = null === $input['seo_head_reported'] ? null : (bool) $input['seo_head_reported']; + } // A changed key may fix a previous auth failure; let serving retry now. if ( $out['api_key'] !== $current['api_key'] ) { @@ -275,6 +317,18 @@ public function sanitize( $input ) { delete_transient( 'citecue_circuit' ); } + // A changed project invalidates every cached body, llms.txt and head + // block on the site (PR #10 review). All three key off the cache salt + // and the URL and NOT off the project, so without this an administrator + // who repoints the site at another CiteCue project keeps being served + // the previous project's content under the new one's name — its + // optimized pages to crawlers, and its title, canonical and structured + // data onto live pages, for up to a day. Disconnecting already flushes; + // switching project is the same event by another route. + if ( $out['public_key'] !== $current['public_key'] ) { + ( new Citecue_Cache() )->flush(); + } + $this->values = $out; return $out; } diff --git a/readme.txt b/readme.txt index 7c63b15..496f2d9 100644 --- a/readme.txt +++ b/readme.txt @@ -4,17 +4,18 @@ Tags: ai, llms.txt, gptbot, ai-seo, woocommerce Requires at least: 5.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.0.3 +Stable tag: 1.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html -Serve AI-optimized versions of your pages to AI bots and crawlers, publish your llms.txt, and receive brand-building draft content from CiteCue. +Serve AI-optimized versions of your pages to AI bots and crawlers, enrich your live pages' SEO metadata, publish your llms.txt, and receive draft content from CiteCue. == Description == CiteCue AI Auto-Fix connects your WordPress site to CiteCue: * **AI crawler middleware** — when an AI bot or crawler (GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User and more) requests a page, the plugin serves the CiteCue-optimized version of that page. Human visitors always see your normal site. Any miss, timeout or outage passes straight through to the normal page. +* **Enriched page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: any tag WordPress, your theme or your SEO plugin already outputs is left exactly as it is, so there is never a second title or canonical. * **llms.txt** — publishes the llms.txt file CiteCue generates for your brand at your site root. * **Content from CiteCue** — a signed endpoint through which CiteCue can push new brand-building content (content briefs, FAQ packs, gap-filling pages) into WordPress as drafts for your review. * **WooCommerce-aware** — cart, checkout, account pages and cart-modifying links are never intercepted, while product and shop pages are served optimized. Pushed content can also create or enrich WooCommerce products (draft by default, matched by SKU with explicit consent). @@ -41,10 +42,14 @@ Privacy Policy: https://citecue.com/privacy The service is reached at `https://app.citecue.com` (or the origin you pin with the `CITECUE_API_BASE` constant, for self-hosted CiteCue deployments). -**Connecting the site** — once, when an administrator clicks "Connect to CiteCue". Your browser is sent to `app.citecue.com/connect/wordpress` with this site's address so CiteCue can show you which project you are pairing. WordPress then posts to `/api/delivery/v2/connect/claim`: the one-time code from that redirect, this site's address, its REST API address, this site's content-push secret, the plugin version, and whether WooCommerce is active. CiteCue returns the API key it issued for this site. The API-key fallback instead sends the key you paste to `/api/delivery/v2/config`, which returns your organization's projects. +**Connecting the site** — once, when an administrator clicks "Connect to CiteCue". Your browser is sent to `app.citecue.com/connect/wordpress` with this site's address so CiteCue can show you which project you are pairing. WordPress then posts to `/api/delivery/v2/connect/claim`: the one-time code from that redirect, this site's address, its REST API address, this site's content-push secret, the plugin version, whether WooCommerce is active, and whether enriched page metadata is switched on. CiteCue returns the API key it issued for this site. The API-key fallback instead sends the key you paste to `/api/delivery/v2/config`, which returns your organization's projects. **Serving a page to an AI crawler** — on each request from a matched AI crawler, and never for a human visitor or a logged-in user. The plugin sends the requested URL, the matched crawler's User-Agent token and the site's project key to `/api/delivery/v2/page`. No visitor data — no IP address, no cookies, no personal data — is sent. CiteCue records the crawler hit so it can report it back to you. Responses are cached, misses are remembered for a minute, and a per-minute budget caps the total. +**Enriching a page's metadata** — in the background, on WP-Cron, for a URL a visitor has requested while enriched metadata is switched on. The requested URL and the site's project key are sent to `/api/delivery/v2/seo-head`. No visitor data — no IP address, no cookies, no personal data — is sent, and this never happens while a visitor is waiting: a page with no cached block yet is rendered untouched and the fetch is queued for afterwards. + +Before a URL is cached, queued or sent, every query argument WordPress does not recognise as a query variable is removed from it, so tokens, order keys and nonces that happen to be in the address are never included. WooCommerce cart, checkout, account and order pages are skipped entirely. Responses are cached, empty answers are remembered for a minute, the same per-minute budget caps outbound calls, and a second per-minute cap limits how many refreshes a burst of traffic can queue. + **Serving llms.txt** — when `/llms.txt` is requested and the feature is on. The site's project key is sent to `/api/delivery/v2/llms.txt`. The response is cached. **Refreshing the AI-crawler list** — once a day, on WP-Cron, for a connected site only. An unauthenticated request to `/api/delivery/v1/crawlers` fetches the current list of AI crawler User-Agent tokens, so newly launched crawlers are recognised without a plugin update. @@ -79,7 +84,15 @@ Settings → CiteCue → "Recent AI crawler activity" tells you which it is. A " = Will human visitors ever see the optimized version? = -No. Only requests whose User-Agent matches the AI-crawler registry are served optimized content, and those responses are never cached for regular traffic. +No. Only requests whose User-Agent matches the AI-crawler registry are served the optimized *page*, and those responses are never cached for regular traffic. Enriched metadata is different and deliberately so: those are head-only tags describing the page a visitor is already looking at, so they are added for everyone, including Google. The visible page is never altered. + += Will this conflict with Yoast SEO, Rank Math, All in One SEO or SEOPress? = + +No. CiteCue reads what your theme, WordPress and your SEO plugin actually printed into ``, and adds only the tags none of them emitted — checking the output rather than looking for a particular plugin, so it is equally correct with an SEO plugin nobody has heard of. A site where Yoast already handles the title, description, canonical, OpenGraph and schema gets nothing added, which is the right answer. A site where it handles the basics but emits no OpenGraph gets the OpenGraph tags. + +CiteCue's tags carry a `data-citecue` attribute, so View Source tells you exactly which ones it added. + +To hand a slot back to CiteCue, remove your SEO plugin's copy of that tag and re-add CiteCue's through the `citecue_seo_head_tags` filter. To switch the whole thing off, untick "Enrich page metadata" under Settings → CiteCue. = What happens if CiteCue is down? = @@ -95,11 +108,24 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never == Upgrade Notice == += 1.1.0 = +Adds enriched page metadata for live pages. Existing connections need one reconnect before CiteCue knows this site can do it — Settings → CiteCue will ask. + = 1.0.1 = The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploading the zip from GitHub, delete the old citecue folder after updating — your settings and connection are stored in the database and carry over untouched. == Changelog == += 1.1.0 = +* New: enriched page metadata. CiteCue's title, meta description, OpenGraph, canonical and structured-data tags are added to your live pages, so search engines and AI answer engines see them, not just AI crawlers. Uses CiteCue's `/api/delivery/v2/seo-head` endpoint. +* Fills gaps only: anything WordPress, your theme or your SEO plugin already prints is left untouched, so the plugin never emits a second title or canonical. Detection reads the real `` output rather than looking for particular plugins. +* Never delays a page: the render path reads cache only. A URL with nothing cached yet renders untouched and the fetch is queued for afterwards. Cached blocks survive a CiteCue outage and are served while a refresh runs. +* The connection now tells CiteCue whether this site injects metadata, so CiteCue stops reporting fixes as reaching human visitors when nothing puts them on the page. Sites connected before this release are asked to reconnect once. +* Nothing from the response is printed as it arrived: every tag is parsed, checked against an allowlist of shapes and rebuilt from escaped values, with structured data re-encoded so it cannot escape its own script element. +* Query arguments WordPress does not recognise are stripped from the URL before it is cached, queued or sent, and store pages are skipped — so order keys, reset tokens and nonces are never included, and a visitor cannot fill the scheduler with unique addresses for one page. +* Changing the selected CiteCue project now clears the delivery cache, instead of serving the previous project's pages, llms.txt and metadata under the new one for up to a day. +* New filters: `citecue_should_inject_seo_head` (skip a page), `citecue_seo_head_tags` (change what is printed), `citecue_seo_head_query_vars` and `citecue_seo_head_schedule_budget`. + = 1.0.3 = * No functional change. Annotates the DONOTCACHEPAGE definitions so code-quality tooling stops reporting a naming-convention violation the constant cannot avoid — page caches look for that exact name. diff --git a/tests/cases/test-connect.php b/tests/cases/test-connect.php index 55c0186..f8dad68 100644 --- a/tests/cases/test-connect.php +++ b/tests/cases/test-connect.php @@ -169,6 +169,97 @@ public function test_a_claim_hands_citecue_the_ingest_secret_and_site_urls() { $this->assertSame( CITECUE_VERSION, $sent['plugin_version'] ); } + /** + * CiteCue decides whether `seoAudience: 'all'` is a promise this channel + * can keep by reading the capability recorded here — and reads its absence + * as "cannot inject", so under-reporting costs the customer the feature. + * + * @return void + */ + public function test_a_claim_reports_the_seo_head_capability() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $sent = json_decode( $this->http->last( 'connect' )['args']['body'], true ); + $this->assertTrue( $sent['seo_head'] ); + $this->assertTrue( $this->plugin->settings->get( 'seo_head_reported' ) ); + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * A site with injection switched off keeps the promise no better than a + * plugin that cannot inject at all, so it must say so. + * + * @return void + */ + public function test_a_claim_reports_injection_being_switched_off() { + $this->plugin->settings->update( array( 'seo_head_enabled' => false ) ); + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $sent = json_decode( $this->http->last( 'connect' )['args']['body'], true ); + $this->assertFalse( $sent['seo_head'] ); + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * A failed claim wrote nothing on CiteCue's side, so recording the + * capability locally would silence the reconnect prompt for something the + * app never learned. + * + * @return void + */ + public function test_a_failed_claim_reports_no_capability() { + $this->http->queue( 'connect', 400, wp_json_encode( array( 'error' => 'invalid_code' ) ) ); + + $this->connect->claim( 'nonsense' ); + + $this->assertNull( $this->plugin->settings->get( 'seo_head_reported' ) ); + } + + /** + * An install that connected before this release injects enriched metadata + * while CiteCue still reports the channel as unable to. That disagreement + * is exactly what the admin prompt exists to catch. + * + * @return void + */ + public function test_a_connection_predating_the_capability_asks_for_a_reconnect() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'seo_head_reported' => null ) ); + + $this->assertTrue( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * Turning injection off after connecting is the same disagreement in the + * other direction — over-claiming — and is worth the same prompt. + * + * @return void + */ + public function test_switching_injection_off_after_connecting_asks_for_a_reconnect() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'seo_head_enabled' => false, + ) + ); + + $this->assertTrue( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * An unconnected site has nothing to reconcile and must not be nagged. + * + * @return void + */ + public function test_an_unconnected_site_is_never_asked_to_reconnect() { + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + /** * The secret must never ride in the browser redirect, only in the * server-to-server exchange — and a redirect there would be a chance to diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 250d988..c42b07c 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -84,6 +84,22 @@ public function test_deactivation_clears_the_cron() { $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); } + /** + * Queued metadata refreshes are single events carrying a URL each, so there + * can be many of them and none is found by the daily hook's name. + * + * @return void + */ + public function test_deactivation_clears_queued_metadata_refreshes() { + wp_schedule_single_event( time(), Citecue_Seo_Head::REFRESH_HOOK, array( home_url( '/a/' ) ) ); + wp_schedule_single_event( time(), Citecue_Seo_Head::REFRESH_HOOK, array( home_url( '/b/' ) ) ); + + Citecue_Plugin::deactivate(); + + $this->assertFalse( wp_next_scheduled( Citecue_Seo_Head::REFRESH_HOOK, array( home_url( '/a/' ) ) ) ); + $this->assertFalse( wp_next_scheduled( Citecue_Seo_Head::REFRESH_HOOK, array( home_url( '/b/' ) ) ) ); + } + /** * A dropped schedule (a cron plugin, a botched migration) must heal itself * rather than silently stop refreshing the crawler registry forever. diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php new file mode 100644 index 0000000..99495b1 --- /dev/null +++ b/tests/cases/test-seo-head-delivery.php @@ -0,0 +1,541 @@ +'; + + /** + * A JSON body of the shape the delivery endpoint returns. + * + * @param string $block Head block. + * @return string + */ + private function payload( $block = self::BLOCK ) { + return wp_json_encode( array( 'head' => $block ) ); + } + + /** + * A render never waits on CiteCue. A cold URL injects nothing and queues + * the fetch instead — the visitor pays no network round trip, and the next + * one gets the block. + * + * @return void + */ + public function test_cold_url_injects_nothing_and_schedules_a_refresh() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + + $decision = $this->seo_head()->decide(); + + $this->assertFalse( $decision['inject'] ); + $this->assertSame( 'no-cache', $decision['reason'] ); + $this->assertSame( 0, $this->http->count(), 'The render path must not call the API.' ); + $this->assertNotFalse( wp_next_scheduled( Citecue_Seo_Head::REFRESH_HOOK, array( $url ) ) ); + } + + /** + * A burst of visitors on a cold URL queues one job, not one per visitor. + * + * @return void + */ + public function test_repeated_views_schedule_one_refresh() { + $this->configure_delivery(); + $this->fake_visitor_request(); + + $injector = $this->seo_head(); + $injector->decide(); + $injector->decide(); + $injector->decide(); + + $scheduled = _get_cron_array(); + $jobs = 0; + foreach ( (array) $scheduled as $events ) { + if ( isset( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ) ) { + $jobs += count( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ); + } + } + + $this->assertSame( 1, $jobs ); + } + + /** + * The warm path: a fresh cached block is injected without touching the + * network or the cron queue. + * + * @return void + */ + public function test_fresh_cache_is_injected() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + + $decision = $this->seo_head()->decide(); + + $this->assertTrue( $decision['inject'] ); + $this->assertSame( 'cached', $decision['reason'] ); + $this->assertSame( self::BLOCK, $decision['block'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Stale-while-revalidate: a day-old block is still this page's own + * metadata. Withholding it during a CiteCue outage would blank the tags + * across the whole site rather than let them age. + * + * @return void + */ + public function test_stale_cache_is_served_while_the_refresh_is_queued() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + $this->age_cached_block( $url, Citecue_Seo_Head::FRESH_SECONDS + 60 ); + + $decision = $this->seo_head()->decide(); + + $this->assertTrue( $decision['inject'] ); + $this->assertSame( 'stale', $decision['reason'] ); + $this->assertNotFalse( wp_next_scheduled( Citecue_Seo_Head::REFRESH_HOOK, array( $url ) ) ); + } + + /** + * Once CiteCue has said it has nothing for a URL, the render path stops + * queueing work for it. Without this every human page view on an + * un-enriched site would schedule a job. + * + * @return void + */ + public function test_recent_miss_stops_scheduling() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head_miss( $url ); + + $decision = $this->seo_head()->decide(); + + $this->assertFalse( $decision['inject'] ); + $this->assertSame( 'recent-miss', $decision['reason'] ); + $this->assertFalse( wp_next_scheduled( Citecue_Seo_Head::REFRESH_HOOK, array( $url ) ) ); + } + + /** + * The setting is the switch, and it governs before anything else happens. + * + * @return void + */ + public function test_disabled_setting_injects_nothing() { + $this->configure_delivery( array( 'seo_head_enabled' => false ) ); + $this->fake_visitor_request(); + + $this->assertSame( 'not-configured', $this->seo_head()->decide()['reason'] ); + } + + /** + * A 404 and a search page have no CiteCue counterpart, and a canonical + * pointing anywhere from either would be actively wrong. + * + * @return void + */ + public function test_404_and_search_are_not_eligible() { + $this->configure_delivery(); + + // A post id nothing owns. The suite runs on plain permalinks, so a + // pretty path would resolve to the blog index rather than a 404. + $this->go_to( home_url( '/?p=999999' ) ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->assertTrue( is_404() ); + $this->assertSame( 'not-eligible', $this->seo_head()->decide()['reason'] ); + + $this->go_to( home_url( '/?s=widgets' ) ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->assertTrue( is_search() ); + $this->assertSame( 'not-eligible', $this->seo_head()->decide()['reason'] ); + } + + /** + * The filter a site can veto individual pages with. + * + * @return void + */ + public function test_filter_can_veto_a_page() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + + add_filter( 'citecue_should_inject_seo_head', '__return_false' ); + + $this->assertSame( 'vetoed', $this->seo_head()->decide()['reason'] ); + } + + /** + * `/?x=` still renders the homepage, so a raw request URL would let + * an anonymous visitor mint unlimited cache keys and cron arguments for one + * page. Unrecognized query arguments are stripped before any of that. + * + * @return void + */ + public function test_unknown_query_arguments_never_reach_the_lookup_url() { + $this->configure_delivery(); + $this->fake_visitor_request( '/?x=abcdef&utm_source=news' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'x=abcdef', $url ); + $this->assertStringNotContainsString( 'utm_source', $url ); + } + + /** + * A dot or a space in the parameter name is the bypass this had to survive: + * parse_str() reports `x.1` as `x_1`, so a strip built on removing the + * names it reports would ask for a name the URL does not contain and leave + * the real one in place — unbounded cache keys again. + * + * @return void + */ + public function test_query_names_that_parse_str_would_rewrite_are_still_stripped() { + $this->configure_delivery(); + $this->fake_visitor_request( '/?x.1=abcdef&y%20z=1' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'abcdef', $url ); + $this->assertStringNotContainsString( 'x.1', $url ); + $this->assertStringNotContainsString( 'y%20z', $url ); + $this->assertStringNotContainsString( '?', $url ); + } + + /** + * Stripping must not break a plain-permalink site, where the query string + * is how a page is addressed at all. + * + * @return void + */ + public function test_recognized_query_arguments_survive() { + $this->configure_delivery(); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->fake_visitor_request( '/?p=' . $post_id . '&sessiontoken=secret' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringContainsString( 'p=' . $post_id, $url ); + $this->assertStringNotContainsString( 'sessiontoken', $url ); + } + + /** + * A burst of unique URLs cannot push unlimited events into WordPress's + * serialized cron option: the per-URL lock does not bound distinct URLs, + * and the outbound budget is only spent when a job runs. + * + * @return void + */ + public function test_scheduling_is_capped_per_minute() { + $this->configure_delivery(); + add_filter( 'citecue_seo_head_schedule_budget', static fn() => 3 ); + + // Real posts, each a distinct eligible URL: a nonexistent id is a 404, + // which the injector declines before it ever reaches scheduling. + $injector = $this->seo_head(); + foreach ( self::factory()->post->create_many( 8, array( 'post_status' => 'publish' ) ) as $post_id ) { + $this->fake_visitor_request( '/?p=' . $post_id ); + $injector->decide(); + } + + $jobs = 0; + foreach ( (array) _get_cron_array() as $events ) { + if ( isset( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ) ) { + $jobs += count( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ); + } + } + + $this->assertSame( 3, $jobs ); + } + + /** + * The budget hands out exactly its limit and then refuses. Worth pinning + * directly: both per-minute ceilings share this counter, and a rate limit + * that answers "allowed" one time too many is a rate limit that can answer + * it every time. + * + * @return void + */ + public function test_the_schedule_budget_stops_at_its_limit() { + add_filter( 'citecue_seo_head_schedule_budget', static fn() => 3 ); + + $granted = 0; + for ( $i = 0; $i < 10; $i++ ) { + if ( $this->plugin->cache->consume_seo_head_schedule_budget() ) { + ++$granted; + } + } + + $this->assertSame( 3, $granted ); + } + + /** + * The background worker stores what CiteCue returns. + * + * @return void + */ + public function test_refresh_caches_the_block() { + $this->configure_delivery(); + $url = home_url( '/hello-world/' ); + $this->http->queue( 'seo_head', 200, $this->payload() ); + + $this->assertSame( 'fresh', $this->seo_head()->refresh( $url ) ); + + $cached = $this->plugin->cache->get_seo_head( $url ); + $this->assertSame( self::BLOCK, $cached['block'] ); + } + + /** + * The request carries the project key, the URL and the Bearer key — the v2 + * channel's contract. + * + * @return void + */ + public function test_refresh_sends_the_authenticated_v2_request() { + $this->configure_delivery(); + $this->http->queue( 'seo_head', 200, $this->payload() ); + + $this->seo_head()->refresh( home_url( '/hello-world/' ) ); + + $request = $this->http->last( 'seo_head' ); + $this->assertStringContainsString( 'k=' . self::PUBLIC_KEY, $request['url'] ); + $this->assertStringContainsString( rawurlencode( home_url( '/hello-world/' ) ), $request['url'] ); + $this->assertSame( 'Bearer ' . self::API_KEY, $request['args']['headers']['Authorization'] ); + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- the header value is literally lowercase. + $this->assertSame( 'wordpress', $request['args']['headers']['X-Citecue-Channel'] ); + } + + /** + * 204 is "valid project, nothing to inject right now" — the audience is not + * `all`, or this URL has no enriched page. A block cached before the switch + * was flipped must not survive it. + * + * @return void + */ + public function test_204_evicts_a_previously_cached_block() { + $this->configure_delivery(); + $url = home_url( '/hello-world/' ); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + $this->http->queue( 'seo_head', 204 ); + + $this->assertSame( 'nothing-to-inject', $this->seo_head()->refresh( $url ) ); + + $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + $this->assertTrue( $this->plugin->cache->is_recent_seo_head_miss( $url ) ); + } + + /** + * 404 is the same "unknown key / disabled project" sentinel the page + * endpoint uses, and is handled the same way. + * + * @return void + */ + public function test_404_evicts_and_records_a_miss() { + $this->configure_delivery(); + $url = home_url( '/hello-world/' ); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + $this->http->queue( 'seo_head', 404, 'not_optimized' ); + + $this->assertSame( 'not-optimized', $this->seo_head()->refresh( $url ) ); + + $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + // The half this test was named for but did not check: without the miss, + // every subsequent page view would queue the fetch again. + $this->assertTrue( $this->plugin->cache->is_recent_seo_head_miss( $url ) ); + } + + /** + * A rejected key opens the long circuit and raises the admin notice, as + * everywhere else in the plugin. + * + * @return void + */ + public function test_401_trips_the_auth_circuit() { + $this->configure_delivery(); + $this->http->queue( 'seo_head', 401, '{"error":"invalid_key"}' ); + + $this->assertSame( 'unauthorized', $this->seo_head()->refresh( home_url( '/hello-world/' ) ) ); + + $this->assertNotEmpty( get_option( 'citecue_auth_failed' ) ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * A timeout opens the circuit and keeps whatever we already had. + * + * @return void + */ + public function test_transport_error_keeps_the_cached_block() { + $this->configure_delivery(); + $url = home_url( '/hello-world/' ); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + $this->http->queue_error( 'seo_head' ); + + $this->assertSame( 'transport-error', $this->seo_head()->refresh( $url ) ); + + $this->assertSame( self::BLOCK, $this->plugin->cache->get_seo_head( $url )['block'] ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * The refresh honours the circuit and the shared lookup budget, so the two + * delivery paths cannot between them exceed the site's outbound ceiling. + * + * @return void + */ + public function test_refresh_respects_the_circuit_and_the_budget() { + $this->configure_delivery(); + + $this->plugin->cache->trip_circuit(); + $this->assertSame( 'circuit-open', $this->seo_head()->refresh( home_url( '/a/' ) ) ); + + delete_transient( 'citecue_circuit' ); + $this->exhaust_lookup_budget(); + $this->assertSame( 'budget-exhausted', $this->seo_head()->refresh( home_url( '/a/' ) ) ); + + $this->assertSame( 0, $this->http->count( 'seo_head' ) ); + } + + /** + * The full render: what the rest of wp_head printed comes back untouched, + * with only the gaps appended after it. + * + * @return void + */ + public function test_capture_appends_only_the_gaps() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( + $url, + 'CiteCue title' + . '' + ); + + $injector = $this->seo_head(); + + ob_start(); + $injector->start_capture(); + echo 'The theme wrote this'; + $injector->finish_capture(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'The theme wrote this', $output ); + $this->assertStringNotContainsString( 'CiteCue title', $output ); + $this->assertStringContainsString( 'og:title', $output ); + $this->assertSame( 1, substr_count( $output, '` 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. + * + * @return void + */ + public function test_theme_markup_printed_before_wp_head_still_claims_its_slot() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( + $url, + 'CiteCue title' + . '' + ); + + $injector = $this->seo_head(); + + ob_start(); + $injector->start_capture(); + echo 'Printed by header.php'; + $injector->finish_capture(); + $output = ob_get_clean(); + + $this->assertSame( 1, substr_count( $output, 'assertStringNotContainsString( 'CiteCue title', $output ); + $this->assertStringContainsString( 'og:title', $output ); + } + + /** + * 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. + * + * @return void + */ + public function test_a_foreign_buffer_is_never_unwound() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + + $injector = $this->seo_head(); + + 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'; + + $injector->finish_capture(); + + $this->assertSame( $foreign_level, ob_get_level(), 'finish_capture() must not close a buffer it did not open.' ); + $this->assertSame( 'foreign', ob_get_clean() ); + + $output = ob_get_clean(); + $this->assertSame( 'Theme', $output ); + $this->assertStringNotContainsString( 'og:title', $output ); + + ob_end_clean(); + } + + /** + * Nothing to inject means nothing touched: no buffer, no marker comment, + * and every byte of the head exactly where the theme put it. + * + * @return void + */ + public function test_capture_is_a_no_op_without_a_block() { + $this->configure_delivery(); + $this->fake_visitor_request(); + + $injector = $this->seo_head(); + + ob_start(); + $injector->start_capture(); + echo 'The theme wrote this'; + $injector->finish_capture(); + $output = ob_get_clean(); + + $this->assertSame( 'The theme wrote this', $output ); + } + + /** + * Ages a cached block so the freshness window has passed. + * + * @param string $url Absolute page URL. + * @param int $seconds How far to backdate it. + * @return void + */ + private function age_cached_block( $url, $seconds ) { + $key = new ReflectionMethod( 'Citecue_Cache', 'seo_head_key' ); + $key->setAccessible( true ); + + $transient = $key->invoke( $this->plugin->cache, $url ); + $hit = get_transient( $transient ); + $hit['cached_at'] = time() - $seconds; + set_transient( $transient, $hit, Citecue_Cache::BODY_TTL ); + } +} diff --git a/tests/cases/test-seo-head-merge.php b/tests/cases/test-seo-head-merge.php new file mode 100644 index 0000000..9720128 --- /dev/null +++ b/tests/cases/test-seo-head-merge.php @@ -0,0 +1,321 @@ +` — two titles, two canonicals, and + * an SEO plugin they paid for quietly fighting with ours. + * + * @package Citecue + */ + +/** + * Gap-filling merge. + */ +class Test_Citecue_Seo_Head_Merge extends Citecue_Test_Case { + + /** + * A CiteCue block of the shape the delivery API actually returns: the + * `data-citecue`-marked head elements of an enriched page. + * + * @return string + */ + private function block() { + return implode( + "\n", + array( + 'Acme Widgets — Industrial fasteners', + '', + '', + '', + '', + '', + '', + ) + ); + } + + /** + * An empty head takes the whole block. + * + * @return void + */ + public function test_empty_head_takes_every_tag() { + $tags = Citecue_Seo_Head::merge( '', $this->block() ); + + $this->assertCount( 7, $tags ); + $this->assertStringContainsString( 'Acme Widgets — Industrial fasteners', $tags[0] ); + } + + /** + * WordPress alone — no SEO plugin at all — already prints a title and a + * canonical, so those two are the baseline conflict on every site. + * + * @return void + */ + public function test_core_title_and_canonical_are_never_duplicated() { + $existing = 'Hello world — Example' . "\n" + . ''; + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'rel="canonical"', $all ); + // Everything core does not emit still comes through. + $this->assertStringContainsString( 'name="description"', $all ); + $this->assertStringContainsString( 'og:title', $all ); + } + + /** + * A Yoast-shaped head: title, description, canonical, OpenGraph, Twitter + * and a JSON-LD graph. Nothing of ours may survive it. + * + * @return void + */ + public function test_yoast_shaped_head_leaves_nothing_to_add() { + $existing = implode( + "\n", + array( + 'Hello world - Example', + '', + '', + '', + '', + '', + '', + ) + ); + + $this->assertSame( array(), Citecue_Seo_Head::merge( $existing, $this->block() ) ); + } + + /** + * The realistic middle case: an SEO plugin that manages the basics but + * emits no OpenGraph and no structured data. Those are exactly the gaps + * CiteCue exists to fill. + * + * @return void + */ + public function test_partial_seo_plugin_gets_only_its_gaps_filled() { + $existing = implode( + "\n", + array( + 'Hello world', + '', + '', + ) + ); + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertCount( 4, $tags ); + $this->assertStringContainsString( 'og:title', $all ); + $this->assertStringContainsString( 'og:description', $all ); + $this->assertStringContainsString( 'twitter:card', $all ); + $this->assertStringContainsString( 'application/ld+json', $all ); + } + + /** + * Attribute quoting and casing vary wildly across themes and plugins, and a + * slot missed because of a single quote is a duplicate tag shipped. + * + * @return void + */ + public function test_slot_detection_survives_quoting_and_casing() { + $existing = "Hi\n\n"; + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'rel="canonical"', $all ); + $this->assertStringNotContainsString( 'name="description"', $all ); + } + + /** + * A plain `'; + + $all = implode( "\n", Citecue_Seo_Head::merge( $existing, $this->block() ) ); + + $this->assertStringContainsString( 'application/ld+json', $all ); + } + + /** + * The response is trusted markup for this site's own head, but it arrives + * over the network and lands in a human's browser, so only recognized tag + * shapes may be printed. + * + * @return void + */ + public function test_unexpected_markup_in_the_block_is_dropped() { + $block = implode( + "\n", + array( + '', + '', + '', + '', + '', + ) + ); + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringContainsString( 'og:title', $tags[0] ); + } + + /** + * The reason nothing from the response is echoed: a pattern that merely + * *searches* for `rel=` finds it inside another attribute's quoted value, + * and would authorize a stylesheet with an event handler as a canonical. + * Rebuilding from parsed attributes has no such gap. + * + * @return void + */ + public function test_a_rel_hidden_in_another_attribute_is_not_read_as_canonical() { + $block = '' + . ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * Even on a tag that legitimately claims a slot, only the attributes this + * plugin writes come out the other side. + * + * @return void + */ + public function test_extra_attributes_never_survive_onto_the_page() { + $block = '' + . ''; + + $all = implode( "\n", Citecue_Seo_Head::merge( '', $block ) ); + + $this->assertStringNotContainsString( 'onload', $all ); + $this->assertStringNotContainsString( 'onmouseover', $all ); + $this->assertStringNotContainsString( 'integrity', $all ); + $this->assertStringContainsString( 'https://example.org/', $all ); + $this->assertStringContainsString( 'content="Acme"', $all ); + } + + /** + * A canonical is a URL on the web, not a scheme that executes. + * + * @return void + */ + public function test_non_http_hrefs_are_dropped() { + $block = ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * A literal closing script tag inside the JSON ends the element where it + * appears, leaving JSON that no longer decodes — so the whole thing is + * dropped, and the trailing script never becomes a tag of its own either. + * + * @return void + */ + public function test_a_script_smuggled_into_jsonld_yields_nothing() { + $block = '"}' + . ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * JSON-LD is re-encoded from the decoded data with JSON_HEX_TAG, so an + * angle bracket in a legitimate string value leaves as an escape rather + * than as something a browser could act on. + * + * @return void + */ + public function test_jsonld_angle_brackets_leave_escaped() { + $block = ''; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringNotContainsString( 'A < B', $tags[0] ); + $this->assertStringContainsString( '<', $tags[0] ); + $this->assertSame( 1, substr_count( strtolower( $tags[0] ), '' ) ); + $this->assertStringContainsString( 'Organization', $tags[0] ); + } + + /** + * Malformed JSON is not structured data, and printing it would put an + * invalid block on the page for a search engine to choke on. + * + * @return void + */ + public function test_unparseable_jsonld_is_dropped() { + $this->assertSame( array(), Citecue_Seo_Head::merge( '', '' ) ); + } + + /** + * Values arrive HTML-encoded, so they must be decoded before being escaped + * back out — otherwise an ampersand gains a new `amp;` on every pass. + * + * @return void + */ + public function test_encoded_values_are_not_double_escaped() { + $block = 'Bar & Grill'; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertStringContainsString( 'Bar & Grill', $tags[0] ); + $this->assertStringNotContainsString( '&amp;', $tags[0] ); + } + + /** + * A block that somehow carries the same slot twice contributes it once. + * + * @return void + */ + public function test_block_internal_duplicates_collapse() { + $block = '' + . ''; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringContainsString( 'example.org/a', $tags[0] ); + } + + /** + * The escape hatch for a site that wants CiteCue to win: remove the other + * plugin's tag yourself, then re-add ours through the filter. + * + * @return void + */ + public function test_filter_can_override_the_gap_fill() { + $existing = 'Theirs'; + + add_filter( + 'citecue_seo_head_tags', + static function ( $tags, $block ) { + $tags[] = ''; + unset( $block ); + return $tags; + }, + 10, + 2 + ); + + $all = implode( "\n", Citecue_Seo_Head::merge( $existing, $this->block() ) ); + + $this->assertStringContainsString( 'citecue-test', $all ); + } +} diff --git a/tests/cases/test-settings.php b/tests/cases/test-settings.php index 4e58383..b0b5d37 100644 --- a/tests/cases/test-settings.php +++ b/tests/cases/test-settings.php @@ -177,6 +177,43 @@ public function test_an_unchanged_api_key_leaves_the_backoff_alone() { $this->assertNotFalse( get_option( 'citecue_auth_failed' ) ); } + /** + * Cached bodies, llms.txt and head blocks all key off the cache salt and + * the URL, and NOT off the project — so repointing the site at another + * CiteCue project would keep serving the previous project's content under + * the new one's name, onto live pages, for up to a day. + * + * @return void + */ + public function test_changing_the_project_flushes_every_cache() { + $url = home_url( '/hello-world/' ); + $this->settings->update( array( 'public_key' => 'pk_old' ) ); + $this->plugin->cache->set_page( $url, 'old project', '"v1"', 'enriched' ); + $this->plugin->cache->set_seo_head( $url, 'old project' ); + $this->plugin->cache->set_llms_txt( '# old project', '"v1"' ); + + $this->settings->sanitize( array( 'public_key' => 'pk_new' ) ); + + $this->assertNull( $this->plugin->cache->get_page( $url ) ); + $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + $this->assertNull( $this->plugin->cache->get_llms_txt() ); + } + + /** + * Re-saving the same project must not throw the site's whole cache away. + * + * @return void + */ + public function test_an_unchanged_project_keeps_the_cache() { + $url = home_url( '/hello-world/' ); + $this->settings->update( array( 'public_key' => 'pk_same' ) ); + $this->plugin->cache->set_seo_head( $url, 'still valid' ); + + $this->settings->sanitize( array( 'public_key' => 'pk_same' ) ); + + $this->assertNotNull( $this->plugin->cache->get_seo_head( $url ) ); + } + /** * Checkboxes are absent from the POST when unticked. * diff --git a/tests/cases/test-woocommerce-exclusions.php b/tests/cases/test-woocommerce-exclusions.php index b46e64c..4e111ca 100644 --- a/tests/cases/test-woocommerce-exclusions.php +++ b/tests/cases/test-woocommerce-exclusions.php @@ -56,6 +56,51 @@ public function provide_store_pages() { ); } + /** + * The metadata injector must skip the same store pages, and has a sharper + * reason to than the proxy: an order-received or account URL carries order + * ids, `wc_order_*` keys and account tokens, and this path would put the + * URL in a cron argument and then send it to CiteCue. + * + * @dataProvider provide_store_pages + * + * @param string $page Stubbed WooCommerce page. + * @return void + */ + public function test_store_pages_are_never_enriched( $page ) { + $this->requires_stub(); + + // A URL that actually resolves. `/checkout/order-received/42/` is a 404 + // on this test site, and the injector declines a 404 several checks + // before it reaches the store-page rule — so the assertion below would + // have held with the WooCommerce exclusion deleted (PR #10 review). + $this->fake_visitor_request( '/a-product/?key=wc_order_secret' ); + Citecue_Woocommerce_Stub::pretend( $page ); + $this->assertFalse( is_404(), 'The request must resolve, or this proves nothing about the store-page rule.' ); + + $decision = $this->seo_head()->decide(); + + $this->assertFalse( $decision['inject'] ); + $this->assertSame( 'not-eligible', $decision['reason'] ); + $this->assertSame( 0, $this->http->count() ); + $this->assertSame( array(), _get_cron_array() ? array_filter( _get_cron_array(), static fn( $events ) => isset( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ) ) : array() ); + } + + /** + * Even off a store page, an order key riding in the query string is not + * something to hand a third party. + * + * @return void + */ + public function test_an_order_key_never_reaches_the_lookup_url() { + $this->fake_visitor_request( '/a-product/?key=wc_order_secret&order_id=42' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'wc_order_secret', $url ); + $this->assertStringNotContainsString( 'order_id', $url ); + } + /** * `?add-to-cart=` mutates the cart, so it is not a page view at all. * diff --git a/tests/includes/class-citecue-http-mock.php b/tests/includes/class-citecue-http-mock.php index b368262..9c3a3a8 100644 --- a/tests/includes/class-citecue-http-mock.php +++ b/tests/includes/class-citecue-http-mock.php @@ -68,7 +68,7 @@ public function disable() { /** * Queues a response for an endpoint. * - * @param string $endpoint One of page|llms|config|crawlers. + * @param string $endpoint One of page|seo_head|llms|config|crawlers. * @param int $status HTTP status code. * @param string $body Response body. * @param array $headers Response headers (case-insensitive). @@ -86,7 +86,7 @@ public function queue( $endpoint, $status, $body = '', array $headers = array() /** * Queues a transport failure (timeout, DNS error, refused connection). * - * @param string $endpoint One of page|llms|config|crawlers. + * @param string $endpoint One of page|seo_head|llms|config|crawlers. * @param string $message Error message. * @return $this */ @@ -190,6 +190,9 @@ private static function classify( $url ) { if ( false !== strpos( $url, '/api/delivery/v2/connect/claim' ) ) { return 'connect'; } + if ( false !== strpos( $url, '/api/delivery/v2/seo-head' ) ) { + return 'seo_head'; + } if ( false !== strpos( $url, '/api/delivery/v2/page' ) ) { return 'page'; } diff --git a/tests/includes/class-citecue-test-case.php b/tests/includes/class-citecue-test-case.php index 68e60a7..bbebf6f 100644 --- a/tests/includes/class-citecue-test-case.php +++ b/tests/includes/class-citecue-test-case.php @@ -166,6 +166,41 @@ protected function llms_txt() { return new Citecue_Llms_Txt( $this->plugin ); } + /** + * A SEO head injector bound to the plugin under test. + * + * @return Citecue_Seo_Head + */ + protected function seo_head() { + return new Citecue_Seo_Head( $this->plugin ); + } + + /** + * Turns the current request into an ordinary human page view of a real + * published post. + * + * A real post, not just a path: the injector refuses to touch a 404, so a + * URL with nothing behind it would make every one of its tests pass for the + * wrong reason. + * + * @param string $path Request path, or '' to create a post and visit it. + * @return string The absolute URL of the faked request. + */ + protected function fake_visitor_request( $path = '' ) { + $url = '' !== $path + ? home_url( $path ) + : (string) get_permalink( self::factory()->post->create( array( 'post_status' => 'publish' ) ) ); + + // go_to() runs the main query, so is_404()/is_search()/is_feed() — which + // the injector's eligibility check consults — answer truthfully. + $this->go_to( $url ); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + unset( $_SERVER['HTTP_USER_AGENT'] ); + + return $url; + } + /** * Asserts the proxy left the request to WordPress, for the stated reason. * diff --git a/uninstall.php b/uninstall.php index 229eced..3996fc5 100644 --- a/uninstall.php +++ b/uninstall.php @@ -23,6 +23,11 @@ delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); delete_transient( 'citecue_connect_state' ); -// Page/llms.txt transients are salt-keyed and expire on their own within a day. +// Page/llms.txt/SEO-head transients are salt-keyed and expire on their own +// within a day. wp_clear_scheduled_hook( 'citecue_daily_sync' ); +// Single events, one per URL awaiting a metadata refresh. wp_unschedule_hook() +// rather than wp_clear_scheduled_hook(), which only clears events whose +// arguments match the ones passed — each of these carries its own URL. +wp_unschedule_hook( 'citecue_refresh_seo_head' );