diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..17b1de0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.zip
+.DS_Store
+node_modules/
+vendor/
diff --git a/README.md b/README.md
index 8b2159f..8e253bc 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,140 @@
-# citecue-wordpress-plugin
\ No newline at end of file
+# CiteCue AI Auto-Fix — WordPress plugin
+
+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.
+
+## How it works
+
+```
+AI crawler (GPTBot, ClaudeBot, …) Human visitor
+ │ │
+ ▼ ▼
+┌─────────────────────────── WordPress ───────────────────────────┐
+│ CiteCue plugin (template_redirect) │
+│ 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 │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- **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 (default 120, filterable) — a spoofed crawler UA spraying unique URLs cannot 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.
+- **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
+
+1. Install and activate the plugin (upload this repo as a zip or drop it into `wp-content/plugins/`).
+2. In CiteCue, create an organization API key (Settings → API keys, `ck_live_…`).
+3. In WordPress, open **Settings → CiteCue**, paste the key, click **Test connection**. The project whose domain matches the site is selected automatically.
+4. Make sure delivery is enabled for the project on CiteCue's Auto-Fix page, and add/generate optimized pages there.
+5. Verify from a terminal:
+
+```bash
+curl -si -A GPTBot https://your-site.com/llms.txt # expect: x-citecue: llms-txt
+curl -si -A GPTBot https://your-site.com/optimized-page/ # expect: x-citecue: served
+```
+
+Or click **Verify installation** in CiteCue.
+
+## Content push API (create posts)
+
+`POST {site}/wp-json/citecue/v1/content` with an HMAC-SHA256 signature. Enable it and copy the shared secret under **Settings → CiteCue → Content from CiteCue**.
+
+**Authentication headers**
+
+| Header | Value |
+|---|---|
+| `Content-Type` | `application/json` |
+| `X-Citecue-Timestamp` | Unix seconds; rejected when more than ±300 s off |
+| `X-Citecue-Signature` | `sha256=` + hex `HMAC_SHA256("{timestamp}.{raw_body}", secret)` |
+
+Signatures are **single-use**: replaying a captured request is rejected with `401 citecue_replayed`. Retries must recompute the timestamp (and therefore the signature).
+
+**Body**
+
+| Field | Type | Notes |
+|---|---|---|
+| `external_id` | string, required | Stable id; pushes with the same id update the same post |
+| `title` | string, required | |
+| `content` | string, required | Post body HTML (sanitized with `wp_kses_post`) |
+| `excerpt` | string | |
+| `slug` | string | |
+| `status` | `draft`\|`pending`\|`publish` | Capped by the "Maximum status" setting (default `draft`) |
+| `type` | `post`\|`page`\|`product` | Default from settings; `product` requires WooCommerce |
+| `categories` | string[] | Term names, created if missing (posts → `category`, products → `product_cat`) |
+| `tags` | string[] | Term names (posts → `post_tag`, products → `product_tag`) |
+| `sku` | string | Products only; also matches an existing product to adopt (see below) |
+| `regular_price` | string | Products only |
+| `meta_description` | string | Stored as `_citecue_meta_description`; printed as `` unless an SEO plugin is active |
+| `source` | string | Provenance label, e.g. `content_brief:opp_123` |
+| `force` | bool | Overwrite even if the post was edited in WordPress since the last push (otherwise → `409 citecue_edited_locally`) |
+
+**Example**
+
+```bash
+SECRET='cws_…' # from Settings → CiteCue
+BODY='{"external_id":"faq-pack-1","title":"Acme FAQ","content":"
What is Acme?
…
","source":"faq_pack:opp_42"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
+
+curl -X POST https://your-site.com/wp-json/citecue/v1/content \
+ -H "Content-Type: application/json" \
+ -H "X-Citecue-Timestamp: $TS" \
+ -H "X-Citecue-Signature: sha256=$SIG" \
+ -d "$BODY"
+```
+
+Responses: `201` created / `200` updated (`{created, updated, post_id, status, permalink, edit_link}`), `400` product push without WooCommerce, `401` bad signature or stale timestamp, `403` ingest disabled, `409` edited locally / SKU exists without `force` / type conflict, `410` push was trashed in WordPress, `429` rate-limited (120/hour, filterable).
+
+There is also a public handshake endpoint: `GET /wp-json/citecue/v1/health` → `{plugin, version, delivery, ingest, woocommerce}`.
+
+## WooCommerce
+
+With WooCommerce active:
+
+- **Store pages are protected.** The middleware never intercepts cart, checkout (including order-pay/order-received), account pages or any other WooCommerce endpoint, and skips `?add-to-cart=` links and `wc-ajax` calls. Product pages, the shop archive and category pages are served optimized like any other page — they are the highest-value AI-crawler targets.
+- **Products can be pushed.** `type: "product"` creates a draft simple product through WooCommerce's CRUD API (`title` → name, `content` → description, `excerpt` → short description, plus `sku`, `regular_price`, `product_cat`/`product_tag` terms). The same status cap applies.
+- **Existing products can be enriched.** When a push's `sku` matches an existing product not previously pushed, the plugin refuses with `409 citecue_sku_exists` unless `force: true` is sent — adopting a product deliberately requires an explicit opt-in because its description gets replaced. After adoption, updates flow by `external_id` like any other push.
+
+## CiteCue API surface consumed
+
+| Endpoint | Auth | Used for |
+|---|---|---|
+| `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/llms.txt?k` | `Bearer ck_live_…` | llms.txt body (ETag/304) |
+| `GET /api/delivery/v1/crawlers` | none (public) | Daily AI-crawler UA token refresh |
+
+## Hooks
+
+| Hook | Type | Purpose |
+|---|---|---|
+| `citecue_crawler_tokens` | filter | Add/remove AI-crawler UA tokens |
+| `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 (default 120); beyond it, crawler requests pass through |
+| `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 |
+
+## 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.
+- **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.
+
+## Development
+
+Plain PHP ≥ 7.4, no build step. Repo root is the plugin root. `php -l` every file; WordPress coding standards style.
diff --git a/citecue.php b/citecue.php
new file mode 100644
index 0000000..87d7930
--- /dev/null
+++ b/citecue.php
@@ -0,0 +1,40 @@
+ time(),
+ 'crawler' => sanitize_text_field( $crawler ),
+ 'path' => substr( sanitize_text_field( $path ), 0, 200 ),
+ 'outcome' => sanitize_key( $outcome ),
+ )
+ );
+
+ update_option( self::OPTION, array_slice( $entries, 0, self::MAX_ENTRIES ), false );
+ }
+
+ /**
+ * Recent entries, newest first.
+ *
+ * @return array[]
+ */
+ public function entries() {
+ $entries = get_option( self::OPTION, array() );
+ return is_array( $entries ) ? $entries : array();
+ }
+}
diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php
new file mode 100644
index 0000000..ad7f489
--- /dev/null
+++ b/includes/class-citecue-admin.php
@@ -0,0 +1,564 @@
+plugin = $plugin;
+ }
+
+ /**
+ * Hooks the admin surface.
+ *
+ * @return void
+ */
+ public function register() {
+ add_action( 'admin_menu', array( $this, 'add_menu' ) );
+ add_action( 'admin_init', array( $this, 'register_settings' ) );
+ add_action( 'admin_notices', array( $this, 'notices' ) );
+ add_action( 'admin_post_citecue_test_connection', array( $this, 'handle_test_connection' ) );
+ add_action( 'admin_post_citecue_refresh_crawlers', array( $this, 'handle_refresh_crawlers' ) );
+ add_action( 'admin_post_citecue_flush_cache', array( $this, 'handle_flush_cache' ) );
+ add_action( 'admin_post_citecue_regen_secret', array( $this, 'handle_regen_secret' ) );
+ add_filter( 'plugin_action_links_' . plugin_basename( CITECUE_PLUGIN_FILE ), array( $this, 'action_links' ) );
+ }
+
+ /**
+ * Adds the Settings → CiteCue page.
+ *
+ * @return void
+ */
+ public function add_menu() {
+ add_options_page(
+ __( 'CiteCue AI Auto-Fix', 'citecue' ),
+ __( 'CiteCue', 'citecue' ),
+ 'manage_options',
+ 'citecue',
+ array( $this, 'render_page' )
+ );
+ }
+
+ /**
+ * Registers the settings group.
+ *
+ * @return void
+ */
+ public function register_settings() {
+ register_setting(
+ 'citecue',
+ Citecue_Settings::OPTION,
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this->plugin->settings, 'sanitize' ),
+ )
+ );
+ }
+
+ /**
+ * Adds a Settings link on the Plugins screen.
+ *
+ * @param array $links Existing links.
+ * @return array
+ */
+ public function action_links( $links ) {
+ array_unshift( $links, '' . esc_html__( 'Settings', 'citecue' ) . '' );
+ return $links;
+ }
+
+ /**
+ * The settings page URL.
+ *
+ * @return string
+ */
+ private function settings_url() {
+ return admin_url( 'options-general.php?page=citecue' );
+ }
+
+ /**
+ * Redirect back to the settings page with a message code.
+ *
+ * @param string $code Message code.
+ * @return void
+ */
+ private function redirect_with( $code ) {
+ wp_safe_redirect( add_query_arg( 'citecue_msg', $code, $this->settings_url() ) );
+ exit;
+ }
+
+ /**
+ * Admin notices: action feedback plus a persistent auth-failure warning.
+ *
+ * @return void
+ */
+ public function notices() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ if ( get_option( 'citecue_auth_failed' ) ) {
+ echo '' . esc_html__( 'CiteCue:', 'citecue' ) . ' '
+ . esc_html__( 'the API key was rejected, so optimized pages are not being served to AI crawlers. Update the key in the CiteCue settings.', 'citecue' )
+ . ' ' . esc_html__( 'Open settings', 'citecue' ) . '
';
+ }
+
+ if ( ! isset( $_GET['citecue_msg'] ) || ! isset( $_GET['page'] ) || 'citecue' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only feedback.
+ return;
+ }
+
+ $messages = array(
+ 'connected' => array( 'success', __( 'Connected to CiteCue.', 'citecue' ) ),
+ 'auto_selected' => array( 'success', __( 'Connected to CiteCue — the project matching this site was selected automatically.', 'citecue' ) ),
+ 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue' ) ),
+ 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check the API base URL and try again.', 'citecue' ) ),
+ 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue' ) ),
+ 'crawlers_fail' => array( 'warning', __( 'Could not refresh the crawler registry; the current list stays active.', 'citecue' ) ),
+ 'flushed' => array( 'success', __( 'Delivery cache flushed.', 'citecue' ) ),
+ 'secret' => array( 'success', __( 'New ingest secret generated. Update it anywhere the old secret was used.', 'citecue' ) ),
+ );
+
+ $code = sanitize_key( wp_unslash( $_GET['citecue_msg'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $messages[ $code ] ) ) {
+ echo '' . esc_html( $messages[ $code ][1] ) . '
';
+ }
+ }
+
+ /**
+ * Save & test connection: persist the submitted settings first (so a key
+ * pasted moments ago is the one being tested), then fetch the org's
+ * projects, cache them, and auto-select the project whose domain matches
+ * this site when none is selected yet.
+ *
+ * @return void
+ */
+ public function handle_test_connection() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ }
+ check_admin_referer( 'citecue_test_connection', 'citecue_test_nonce' );
+
+ if ( isset( $_POST[ Citecue_Settings::OPTION ] ) && is_array( $_POST[ Citecue_Settings::OPTION ] ) ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize() is the sanitizer.
+ $raw = wp_unslash( $_POST[ Citecue_Settings::OPTION ] );
+ $this->plugin->settings->update( $this->plugin->settings->sanitize( $raw ) );
+ }
+
+ $projects = $this->plugin->api->get_config();
+ if ( is_wp_error( $projects ) ) {
+ if ( 'citecue_invalid_key' === $projects->get_error_code() ) {
+ update_option( 'citecue_auth_failed', time(), false );
+ $this->redirect_with( 'auth' );
+ }
+ $this->redirect_with( 'conn_fail' );
+ }
+
+ delete_option( 'citecue_auth_failed' );
+ delete_transient( 'citecue_circuit' );
+
+ $clean = array();
+ foreach ( $projects as $project ) {
+ if ( ! is_array( $project ) || empty( $project['publicKey'] ) ) {
+ continue;
+ }
+ $clean[] = array(
+ 'publicKey' => sanitize_text_field( (string) $project['publicKey'] ),
+ 'domain' => sanitize_text_field( (string) ( isset( $project['domain'] ) ? $project['domain'] : '' ) ),
+ 'enabled' => ! empty( $project['enabled'] ),
+ 'serveLlmsTxt' => ! empty( $project['serveLlmsTxt'] ),
+ );
+ }
+ update_option( 'citecue_projects_cache', $clean, false );
+ update_option( 'citecue_last_config_at', time(), false );
+
+ // Auto-select by host when no project is chosen yet.
+ $settings = $this->plugin->settings;
+ if ( '' === (string) $settings->get( 'public_key' ) ) {
+ $site_host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
+ $site_host = preg_replace( '/^www\./', '', $site_host );
+ foreach ( $clean as $project ) {
+ $domain = strtolower( preg_replace( '/^www\./', '', $project['domain'] ) );
+ if ( '' !== $domain && $domain === $site_host ) {
+ $settings->update(
+ array(
+ 'public_key' => $project['publicKey'],
+ 'project_domain' => $project['domain'],
+ )
+ );
+ $this->redirect_with( 'auto_selected' );
+ }
+ }
+ }
+
+ $this->redirect_with( 'connected' );
+ }
+
+ /**
+ * Manual crawler-registry refresh.
+ *
+ * @return void
+ */
+ public function handle_refresh_crawlers() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ }
+ check_admin_referer( 'citecue_refresh_crawlers' );
+
+ $ok = $this->plugin->crawlers->refresh( $this->plugin->api );
+ $this->redirect_with( $ok ? 'crawlers_ok' : 'crawlers_fail' );
+ }
+
+ /**
+ * Flush the delivery cache.
+ *
+ * @return void
+ */
+ public function handle_flush_cache() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ }
+ check_admin_referer( 'citecue_flush_cache' );
+
+ $this->plugin->cache->flush();
+ $this->redirect_with( 'flushed' );
+ }
+
+ /**
+ * Rotate the ingest secret.
+ *
+ * @return void
+ */
+ public function handle_regen_secret() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ }
+ check_admin_referer( 'citecue_regen_secret' );
+
+ $this->plugin->settings->update( array( 'ingest_secret' => 'cws_' . bin2hex( random_bytes( 20 ) ) ) );
+ $this->redirect_with( 'secret' );
+ }
+
+ /**
+ * Renders the settings page.
+ *
+ * @return void
+ */
+ public function render_page() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $settings = $this->plugin->settings;
+ $projects = get_option( 'citecue_projects_cache', array() );
+ $projects = is_array( $projects ) ? $projects : array();
+ $secret = $settings->ensure_ingest_secret();
+ $registry = $this->plugin->crawlers->registry_info();
+ $api_key = (string) $settings->get( 'api_key' );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ action_button( 'citecue_refresh_crawlers', __( 'Refresh crawler list', 'citecue' ) ); ?>
+ action_button( 'citecue_flush_cache', __( 'Flush delivery cache', 'citecue' ) ); ?>
+ action_button( 'citecue_regen_secret', __( 'Regenerate ingest secret', 'citecue' ) ); ?>
+
+
+ 0 ) {
+ printf(
+ /* translators: %s: human time diff. */
+ ', ' . esc_html__( 'refreshed %s ago', 'citecue' ),
+ esc_html( human_time_diff( $registry['fetched_at'] ) )
+ );
+ } else {
+ echo ', ' . esc_html__( 'bundled list (not refreshed yet)', 'citecue' );
+ }
+ ?>
+
+
+ curl -si -A GPTBot ' . esc_html( home_url( '/llms.txt' ) ) . ''
+ );
+ ?>
+
+
+
+ render_activity(); ?>
+
+
+
+
+ plugin->activity->entries();
+ if ( empty( $entries ) ) {
+ echo '' . esc_html__( 'No AI crawler visits recorded yet.', 'citecue' ) . '
';
+ return;
+ }
+
+ $outcome_labels = array(
+ 'served' => __( 'Served optimized', 'citecue' ),
+ 'served-stale' => __( 'Served (stale cache)', 'citecue' ),
+ 'passthrough' => __( 'Passed through', 'citecue' ),
+ 'error' => __( 'API error — passed through', 'citecue' ),
+ );
+ ?>
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+ |
+ |
+ |
+ |
+
+
+
+
+ settings = $settings;
+ }
+
+ /**
+ * Common headers for authenticated v2 requests.
+ *
+ * @return array
+ */
+ private function auth_headers() {
+ return array(
+ 'Authorization' => 'Bearer ' . (string) $this->settings->get( 'api_key' ),
+ 'X-Citecue-Channel' => 'wordpress',
+ );
+ }
+
+ /**
+ * Request timeout for the crawler-serving hot path, in seconds. Kept short:
+ * a slow CiteCue response only ever delays an AI bot, never a human, but
+ * there is no reason to hold a PHP worker longer than this.
+ *
+ * @return int
+ */
+ private function serve_timeout() {
+ /**
+ * Filters the delivery request timeout (seconds) on the serving path.
+ *
+ * @param int $timeout Seconds.
+ */
+ return max( 1, (int) apply_filters( 'citecue_serve_timeout', 3 ) );
+ }
+
+ /**
+ * Performs a GET and normalizes the response.
+ *
+ * @param string $url Full URL.
+ * @param array $headers Request headers.
+ * @param int $timeout Timeout in seconds.
+ * @return array|WP_Error {status:int, body:string, headers:CaseInsensitiveDictionary|array}
+ */
+ private function get( $url, array $headers, $timeout ) {
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'timeout' => $timeout,
+ 'redirection' => 2,
+ 'user-agent' => 'CiteCue-WordPress/' . CITECUE_VERSION . ' (+' . home_url( '/' ) . ')',
+ 'headers' => $headers,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ return array(
+ 'status' => (int) wp_remote_retrieve_response_code( $response ),
+ 'body' => (string) wp_remote_retrieve_body( $response ),
+ 'headers' => wp_remote_retrieve_headers( $response ),
+ );
+ }
+
+ /**
+ * GET /api/delivery/v2/config — the API key's org projects.
+ *
+ * @return array|WP_Error List of projects ({publicKey, domain, enabled, serveLlmsTxt}).
+ */
+ public function get_config() {
+ $result = $this->get( $this->settings->api_base() . '/api/delivery/v2/config', $this->auth_headers(), 10 );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ if ( 401 === $result['status'] ) {
+ return new WP_Error( 'citecue_invalid_key', __( 'CiteCue rejected the API key. Check it under CiteCue → Settings → API keys.', 'citecue' ) );
+ }
+ if ( 200 !== $result['status'] ) {
+ /* translators: %d: HTTP status code. */
+ return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) );
+ }
+
+ $data = json_decode( $result['body'], true );
+ if ( ! is_array( $data ) || ! isset( $data['projects'] ) || ! is_array( $data['projects'] ) ) {
+ return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) );
+ }
+
+ return $data['projects'];
+ }
+
+ /**
+ * GET /api/delivery/v2/page — the optimized page for a crawler request.
+ * A 304 means "your cached body is still current" and is still counted as
+ * a served hit by CiteCue; 404 is the pass-through sentinel.
+ *
+ * @param string $url Absolute URL of the page being requested.
+ * @param string $crawler_token Matched UA token (server renormalizes it).
+ * @param string $etag Cached ETag for If-None-Match, or ''.
+ * @return array|WP_Error {status, body, etag, mode}
+ */
+ public function get_page( $url, $crawler_token, $etag = '' ) {
+ $endpoint = add_query_arg(
+ array(
+ 'k' => rawurlencode( (string) $this->settings->get( 'public_key' ) ),
+ 'u' => rawurlencode( $url ),
+ 'b' => rawurlencode( $crawler_token ),
+ ),
+ $this->settings->api_base() . '/api/delivery/v2/page'
+ );
+
+ $headers = $this->auth_headers();
+ if ( '' !== $etag ) {
+ $headers['If-None-Match'] = $etag;
+ }
+
+ $result = $this->get( $endpoint, $headers, $this->serve_timeout() );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $response_headers = $result['headers'];
+ return array(
+ 'status' => $result['status'],
+ 'body' => $result['body'],
+ 'etag' => isset( $response_headers['etag'] ) ? (string) $response_headers['etag'] : '',
+ 'mode' => isset( $response_headers['x-citecue-mode'] ) ? (string) $response_headers['x-citecue-mode'] : '',
+ );
+ }
+
+ /**
+ * GET /api/delivery/v2/llms.txt — the project's llms.txt.
+ *
+ * @param string $etag Cached ETag for If-None-Match, or ''.
+ * @return array|WP_Error {status, body, etag}
+ */
+ public function get_llms_txt( $etag = '' ) {
+ $endpoint = add_query_arg(
+ array( 'k' => rawurlencode( (string) $this->settings->get( 'public_key' ) ) ),
+ $this->settings->api_base() . '/api/delivery/v2/llms.txt'
+ );
+
+ $headers = $this->auth_headers();
+ if ( '' !== $etag ) {
+ $headers['If-None-Match'] = $etag;
+ }
+
+ $result = $this->get( $endpoint, $headers, $this->serve_timeout() );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $response_headers = $result['headers'];
+ return array(
+ 'status' => $result['status'],
+ 'body' => $result['body'],
+ 'etag' => isset( $response_headers['etag'] ) ? (string) $response_headers['etag'] : '',
+ );
+ }
+
+ /**
+ * GET /api/delivery/v1/crawlers — public crawler registry feed.
+ *
+ * @return array|WP_Error {version:int, tokens:string[]}
+ */
+ public function get_crawler_registry() {
+ $result = $this->get( $this->settings->api_base() . '/api/delivery/v1/crawlers', array(), 10 );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ if ( 200 !== $result['status'] ) {
+ /* translators: %d: HTTP status code. */
+ return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) );
+ }
+
+ $data = json_decode( $result['body'], true );
+ if ( ! is_array( $data ) || empty( $data['tokens'] ) || ! is_array( $data['tokens'] ) ) {
+ return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) );
+ }
+
+ return $data;
+ }
+}
diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php
new file mode 100644
index 0000000..890b278
--- /dev/null
+++ b/includes/class-citecue-cache.php
@@ -0,0 +1,256 @@
+ 1 ) {
+ $path = untrailingslashit( $path );
+ }
+
+ $query = '';
+ if ( isset( $parts['query'] ) && '' !== $parts['query'] ) {
+ $pairs = array();
+ parse_str( $parts['query'], $pairs );
+ foreach ( array_keys( $pairs ) as $param ) {
+ if ( preg_match( '/^(utm_\w+|ref|fbclid|gclid)$/i', (string) $param ) ) {
+ unset( $pairs[ $param ] );
+ }
+ }
+ if ( $pairs ) {
+ $query = '?' . http_build_query( $pairs );
+ }
+ }
+
+ return $host . ( '/' === $path ? '' : $path ) . $query;
+ }
+
+ /**
+ * Transient key for a page URL.
+ *
+ * @param string $url Absolute page URL.
+ * @return string
+ */
+ private function page_key( $url ) {
+ return 'citecue_pg_' . md5( $this->salt() . '|' . self::normalize_url( $url ) );
+ }
+
+ /**
+ * Cached optimized page, or null.
+ *
+ * @param string $url Absolute page URL.
+ * @return array{body:string,etag:string,mode:string,cached_at:int}|null
+ */
+ public function get_page( $url ) {
+ $hit = get_transient( $this->page_key( $url ) );
+ return ( is_array( $hit ) && isset( $hit['body'] ) ) ? $hit : null;
+ }
+
+ /**
+ * Stores an optimized page body.
+ *
+ * @param string $url Absolute page URL.
+ * @param string $body Optimized HTML.
+ * @param string $etag Response ETag.
+ * @param string $mode Optimization mode (enriched|rewrite).
+ * @return void
+ */
+ public function set_page( $url, $body, $etag, $mode ) {
+ set_transient(
+ $this->page_key( $url ),
+ array(
+ 'body' => $body,
+ 'etag' => $etag,
+ 'mode' => $mode,
+ 'cached_at' => time(),
+ ),
+ self::BODY_TTL
+ );
+ }
+
+ /**
+ * Refreshes the stored timestamp after a 304 revalidation.
+ *
+ * @param string $url Absolute page URL.
+ * @return void
+ */
+ public function touch_page( $url ) {
+ $hit = $this->get_page( $url );
+ if ( $hit ) {
+ $hit['cached_at'] = time();
+ set_transient( $this->page_key( $url ), $hit, self::BODY_TTL );
+ }
+ }
+
+ /**
+ * Removes a cached page. Called when the API returns the miss sentinel for
+ * a URL that used to be optimized (page removed/unapproved in CiteCue) —
+ * the stale body must not resurface via the stale-on-error path.
+ *
+ * @param string $url Absolute page URL.
+ * @return void
+ */
+ public function delete_page( $url ) {
+ delete_transient( $this->page_key( $url ) );
+ }
+
+ /**
+ * Whether this URL recently returned the miss sentinel.
+ *
+ * @param string $url Absolute page URL.
+ * @return bool
+ */
+ public function is_recent_miss( $url ) {
+ return (bool) get_transient( 'citecue_ms_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ) );
+ }
+
+ /**
+ * Records a miss so repeated crawler hits on the same unoptimized URL
+ * skip the API for a minute.
+ *
+ * @param string $url Absolute page URL.
+ * @return void
+ */
+ public function set_miss( $url ) {
+ set_transient( 'citecue_ms_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL );
+ }
+
+ /**
+ * Cached llms.txt, or null.
+ *
+ * @return array{body:string,etag:string,cached_at:int}|null
+ */
+ public function get_llms_txt() {
+ $hit = get_transient( 'citecue_llms_' . $this->salt() );
+ return ( is_array( $hit ) && isset( $hit['body'] ) ) ? $hit : null;
+ }
+
+ /**
+ * Removes the cached llms.txt (serving got disabled on CiteCue).
+ *
+ * @return void
+ */
+ public function delete_llms_txt() {
+ delete_transient( 'citecue_llms_' . $this->salt() );
+ }
+
+ /**
+ * Stores the llms.txt body.
+ *
+ * @param string $body llms.txt content.
+ * @param string $etag Response ETag.
+ * @return void
+ */
+ public function set_llms_txt( $body, $etag ) {
+ set_transient(
+ 'citecue_llms_' . $this->salt(),
+ array(
+ 'body' => $body,
+ 'etag' => $etag,
+ 'cached_at' => time(),
+ ),
+ self::BODY_TTL
+ );
+ }
+
+ /**
+ * Age-based freshness check used to decide when to revalidate llms.txt.
+ *
+ * @param array $hit Cache entry.
+ * @param int $max_age_sec Freshness window.
+ * @return bool
+ */
+ public function is_fresh( $hit, $max_age_sec ) {
+ return isset( $hit['cached_at'] ) && ( time() - (int) $hit['cached_at'] ) < $max_age_sec;
+ }
+
+ /**
+ * Opens the circuit: no API calls until it expires. Serving falls back to
+ * stale cache or plain pass-through, so an outage never slows the site.
+ *
+ * @param int $ttl Seconds to keep the circuit open.
+ * @return void
+ */
+ public function trip_circuit( $ttl = self::CIRCUIT_TTL ) {
+ set_transient( 'citecue_circuit', time() + $ttl, $ttl );
+ }
+
+ /**
+ * Whether the circuit is open (API temporarily off-limits).
+ *
+ * @return bool
+ */
+ public function is_circuit_open() {
+ return (bool) get_transient( 'citecue_circuit' );
+ }
+
+ /**
+ * Flush all delivery caches by rotating the key salt.
+ *
+ * @return void
+ */
+ public function flush() {
+ update_option( 'citecue_cache_salt', substr( md5( (string) wp_rand() . microtime() ), 0, 8 ) );
+ delete_transient( 'citecue_circuit' );
+ }
+}
diff --git a/includes/class-citecue-crawlers.php b/includes/class-citecue-crawlers.php
new file mode 100644
index 0000000..092e86f
--- /dev/null
+++ b/includes/class-citecue-crawlers.php
@@ -0,0 +1,175 @@
+ isset( $stored['version'] ) ? (int) $stored['version'] : self::BUNDLED_VERSION,
+ 'count' => count( $this->get_tokens() ),
+ 'fetched_at' => isset( $stored['fetched_at'] ) ? (int) $stored['fetched_at'] : 0,
+ );
+ }
+
+ /**
+ * Matches a User-Agent against the token list. Case-insensitive substring
+ * match; when several tokens match, the longest wins (so "ChatGPT-User"
+ * is never shadowed by a shorter overlapping token). Mirrors CiteCue's
+ * matchDeliveryCrawler().
+ *
+ * @param string $user_agent Raw User-Agent header.
+ * @return string|null The matched token, or null for a non-crawler UA.
+ */
+ public function match( $user_agent ) {
+ $user_agent = strtolower( (string) $user_agent );
+ if ( '' === $user_agent ) {
+ return null;
+ }
+
+ $best = null;
+ foreach ( $this->get_tokens() as $token ) {
+ if ( false !== strpos( $user_agent, strtolower( $token ) ) ) {
+ if ( null === $best || strlen( $token ) > strlen( $best ) ) {
+ $best = $token;
+ }
+ }
+ }
+
+ /**
+ * Filters the matched crawler token for a request.
+ *
+ * @param string|null $best Matched token or null.
+ * @param string $user_agent Lowercased User-Agent.
+ */
+ return apply_filters( 'citecue_matched_crawler', $best, $user_agent );
+ }
+
+ /**
+ * Refreshes the token list from CiteCue's public registry feed
+ * (`GET /api/delivery/v1/crawlers`, keyless). Any failure keeps the
+ * current list, a registry version older than what we already have is
+ * rejected, and the bundled tokens are always merged back in — a
+ * truncated or downgraded feed can never remove a bundled crawler.
+ *
+ * @param Citecue_Api_Client $api API client.
+ * @return bool Whether a fresh registry was stored.
+ */
+ public function refresh( Citecue_Api_Client $api ) {
+ $data = $api->get_crawler_registry();
+ if ( is_wp_error( $data ) || empty( $data['tokens'] ) || ! is_array( $data['tokens'] ) ) {
+ return false;
+ }
+
+ $incoming_version = isset( $data['version'] ) ? (int) $data['version'] : 0;
+ if ( $incoming_version < max( self::BUNDLED_VERSION, $this->registry_info()['version'] ) ) {
+ return false;
+ }
+
+ // Union remote tokens with the bundled floor, case-insensitively deduped.
+ $tokens = array();
+ $seen = array();
+ $merged = array_merge( array_map( 'sanitize_text_field', array_map( 'strval', $data['tokens'] ) ), self::bundled_tokens() );
+ foreach ( $merged as $token ) {
+ $token = trim( $token );
+ if ( '' === $token || isset( $seen[ strtolower( $token ) ] ) ) {
+ continue;
+ }
+ $seen[ strtolower( $token ) ] = true;
+ $tokens[] = $token;
+ }
+
+ update_option(
+ self::OPTION,
+ array(
+ 'version' => $incoming_version,
+ 'tokens' => $tokens,
+ 'fetched_at' => time(),
+ )
+ );
+ return true;
+ }
+}
diff --git a/includes/class-citecue-ingest.php b/includes/class-citecue-ingest.php
new file mode 100644
index 0000000..70aa030
--- /dev/null
+++ b/includes/class-citecue-ingest.php
@@ -0,0 +1,593 @@
+
+ * X-Citecue-Signature: sha256=
+ * Body (JSON):
+ * external_id string required — stable id, dedupe/update key
+ * title string required
+ * content string required — post body HTML (sanitized with wp_kses_post)
+ * excerpt string optional — post excerpt / product short description
+ * slug string optional
+ * status string optional — draft|pending|publish, capped by the configured maximum
+ * type string optional — post|page|product (product requires WooCommerce; default from settings)
+ * categories array optional — category names, created if missing (posts: category; products: product_cat)
+ * tags array optional — tag names (posts: post_tag; products: product_tag)
+ * sku string optional — products only; also matches an existing product to adopt (with force)
+ * regular_price string optional — products only
+ * meta_description string optional — stored as _citecue_meta_description
+ * source string optional — provenance label (e.g. "content_brief:opp_123")
+ * force bool optional — overwrite even if the post was edited in WordPress
+ *
+ * GET /wp-json/citecue/v1/health — public, minimal install handshake.
+ *
+ * @package Citecue
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Content ingest REST controller.
+ */
+class Citecue_Ingest {
+
+ const REST_NAMESPACE = 'citecue/v1';
+ const TIMESTAMP_WINDOW = 300;
+
+ /**
+ * Plugin container.
+ *
+ * @var Citecue_Plugin
+ */
+ private $plugin;
+
+ /**
+ * Constructor.
+ *
+ * @param Citecue_Plugin $plugin Plugin container.
+ */
+ public function __construct( Citecue_Plugin $plugin ) {
+ $this->plugin = $plugin;
+ }
+
+ /**
+ * Hooks route registration and the meta-description output.
+ *
+ * @return void
+ */
+ public function register() {
+ add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+ add_action( 'wp_head', array( $this, 'output_meta_description' ), 1 );
+ }
+
+ /**
+ * Registers the REST routes.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/content',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'handle_content' ),
+ 'permission_callback' => array( $this, 'verify_request' ),
+ )
+ );
+
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/health',
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'handle_health' ),
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ /**
+ * Public install handshake: plugin presence + version, no secrets.
+ *
+ * @return WP_REST_Response
+ */
+ public function handle_health() {
+ return rest_ensure_response(
+ array(
+ 'plugin' => 'citecue',
+ 'version' => CITECUE_VERSION,
+ 'delivery' => (bool) ( $this->plugin->settings->get( 'serve_enabled' ) && $this->plugin->settings->is_delivery_configured() ),
+ 'ingest' => (bool) $this->plugin->settings->get( 'ingest_enabled' ),
+ 'woocommerce' => class_exists( 'WooCommerce' ),
+ )
+ );
+ }
+
+ /**
+ * Authenticates a push: ingest enabled, sane rate, fresh timestamp, and a
+ * valid HMAC over "{timestamp}.{raw_body}" with the shared secret.
+ *
+ * @param WP_REST_Request $request Request.
+ * @return true|WP_Error
+ */
+ public function verify_request( WP_REST_Request $request ) {
+ $settings = $this->plugin->settings;
+
+ if ( ! $settings->get( 'ingest_enabled' ) ) {
+ return new WP_Error( 'citecue_ingest_disabled', __( 'Content ingest is disabled in the CiteCue plugin settings.', 'citecue' ), array( 'status' => 403 ) );
+ }
+
+ $secret = (string) $settings->get( 'ingest_secret' );
+ if ( '' === $secret ) {
+ return new WP_Error( 'citecue_no_secret', __( 'No ingest secret is configured.', 'citecue' ), array( 'status' => 403 ) );
+ }
+
+ $timestamp = (int) $request->get_header( 'x-citecue-timestamp' );
+ if ( abs( time() - $timestamp ) > self::TIMESTAMP_WINDOW ) {
+ return new WP_Error( 'citecue_stale_timestamp', __( 'Missing or expired X-Citecue-Timestamp header.', 'citecue' ), array( 'status' => 401 ) );
+ }
+
+ $signature = (string) $request->get_header( 'x-citecue-signature' );
+ if ( 0 !== strpos( $signature, 'sha256=' ) ) {
+ return new WP_Error( 'citecue_bad_signature', __( 'Missing or malformed X-Citecue-Signature header.', 'citecue' ), array( 'status' => 401 ) );
+ }
+
+ $expected = hash_hmac( 'sha256', $timestamp . '.' . $request->get_body(), $secret );
+ if ( ! hash_equals( 'sha256=' . $expected, $signature ) ) {
+ return new WP_Error( 'citecue_bad_signature', __( 'Invalid request signature.', 'citecue' ), array( 'status' => 401 ) );
+ }
+
+ // Signatures are single-use: a captured request replayed within the
+ // timestamp window is rejected (it could re-apply a force overwrite).
+ // Legitimate retries recompute the timestamp, minting a new signature.
+ $replay_key = 'citecue_replay_' . md5( $signature );
+ if ( get_transient( $replay_key ) ) {
+ return new WP_Error( 'citecue_replayed', __( 'This signature was already used; sign each request freshly.', 'citecue' ), array( 'status' => 401 ) );
+ }
+ set_transient( $replay_key, 1, 2 * self::TIMESTAMP_WINDOW );
+
+ // After authentication on purpose: unsigned traffic can never consume
+ // the budget and lock out legitimate pushes.
+ if ( ! $this->within_rate_limit() ) {
+ return new WP_Error( 'citecue_rate_limited', __( 'Too many ingest requests; try again later.', 'citecue' ), array( 'status' => 429 ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Sliding-hour rate limit for the ingest endpoint.
+ *
+ * @return bool Whether this request is within the limit.
+ */
+ private function within_rate_limit() {
+ /**
+ * Filters the maximum ingest requests accepted per hour.
+ *
+ * @param int $limit Default 120.
+ */
+ $limit = max( 1, (int) apply_filters( 'citecue_ingest_rate_limit', 120 ) );
+ $count = (int) get_transient( 'citecue_ingest_rate' );
+ if ( $count >= $limit ) {
+ return false;
+ }
+ set_transient( 'citecue_ingest_rate', $count + 1, HOUR_IN_SECONDS );
+ return true;
+ }
+
+ /**
+ * Creates or updates a post from a signed CiteCue payload.
+ *
+ * @param WP_REST_Request $request Request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function handle_content( WP_REST_Request $request ) {
+ $params = $request->get_json_params();
+ if ( ! is_array( $params ) ) {
+ return new WP_Error( 'citecue_bad_json', __( 'Request body must be JSON.', 'citecue' ), array( 'status' => 400 ) );
+ }
+
+ $external_id = isset( $params['external_id'] ) ? $this->sanitize_external_id( $params['external_id'] ) : '';
+ $title = isset( $params['title'] ) ? sanitize_text_field( (string) $params['title'] ) : '';
+ $content_raw = isset( $params['content'] ) ? (string) $params['content'] : '';
+
+ if ( '' === $external_id || '' === $title || '' === trim( $content_raw ) ) {
+ return new WP_Error( 'citecue_missing_fields', __( 'external_id, title and content are required.', 'citecue' ), array( 'status' => 400 ) );
+ }
+
+ $content = wp_kses_post( $content_raw );
+
+ $settings = $this->plugin->settings;
+ $status = $this->resolve_status( isset( $params['status'] ) ? (string) $params['status'] : '' );
+
+ $requested_type = isset( $params['type'] ) ? (string) $params['type'] : '';
+ if ( 'product' === $requested_type && ! class_exists( 'WooCommerce' ) ) {
+ return new WP_Error( 'citecue_woocommerce_missing', __( 'This payload targets a WooCommerce product, but WooCommerce is not active on this site.', 'citecue' ), array( 'status' => 400 ) );
+ }
+ $allowed_types = array( 'post', 'page' );
+ if ( class_exists( 'WooCommerce' ) ) {
+ $allowed_types[] = 'product';
+ }
+ $post_type = in_array( $requested_type, $allowed_types, true ) ? $requested_type : (string) $settings->get( 'ingest_post_type' );
+ if ( ! in_array( $post_type, $allowed_types, true ) ) {
+ $post_type = 'post'; // Configured default is 'product' but WooCommerce got deactivated.
+ }
+
+ $existing_id = $this->find_by_external_id( $external_id );
+ $force = ! empty( $params['force'] );
+
+ // Products can also be matched by SKU, so pushes can enrich an
+ // existing catalog item. Adopting a product the plugin didn't create
+ // always requires force — its description is about to be replaced.
+ if ( ! $existing_id && 'product' === $post_type && isset( $params['sku'] ) && '' !== (string) $params['sku'] && function_exists( 'wc_get_product_id_by_sku' ) ) {
+ $sku_match = (int) wc_get_product_id_by_sku( wc_clean( (string) $params['sku'] ) );
+ if ( $sku_match > 0 ) {
+ if ( ! $force ) {
+ return new WP_Error(
+ 'citecue_sku_exists',
+ __( 'A product with this SKU already exists; send force=true to adopt and update it.', 'citecue' ),
+ array(
+ 'status' => 409,
+ 'post_id' => $sku_match,
+ )
+ );
+ }
+ $existing_id = $sku_match;
+ }
+ }
+
+ if ( $existing_id && get_post_type( $existing_id ) !== $post_type ) {
+ return new WP_Error(
+ 'citecue_type_conflict',
+ __( 'This external_id already exists with a different content type.', 'citecue' ),
+ array(
+ 'status' => 409,
+ 'post_id' => $existing_id,
+ )
+ );
+ }
+
+ // A trashed push stays trashed: the site owner rejected it, so it is
+ // neither resurrected nor duplicated.
+ if ( $existing_id && 'trash' === get_post_status( $existing_id ) ) {
+ return new WP_Error(
+ 'citecue_trashed',
+ __( 'A post with this external_id was trashed in WordPress; restore or delete it permanently first.', 'citecue' ),
+ array(
+ 'status' => 410,
+ 'post_id' => $existing_id,
+ )
+ );
+ }
+
+ if ( $existing_id && ! $force && $this->edited_locally( $existing_id ) ) {
+ return new WP_Error(
+ 'citecue_edited_locally',
+ __( 'This post was edited in WordPress after the last push; send force=true to overwrite.', 'citecue' ),
+ array(
+ 'status' => 409,
+ 'post_id' => $existing_id,
+ )
+ );
+ }
+
+ if ( 'product' === $post_type ) {
+ $post_id = $this->upsert_product( $existing_id, $params, $title, $content, $status );
+ if ( is_wp_error( $post_id ) ) {
+ return $post_id;
+ }
+ } else {
+ $postarr = array(
+ 'post_title' => $title,
+ 'post_content' => $content,
+ 'post_status' => $status,
+ 'post_type' => $post_type,
+ 'post_author' => $this->resolve_author(),
+ );
+ if ( isset( $params['excerpt'] ) ) {
+ $postarr['post_excerpt'] = sanitize_textarea_field( (string) $params['excerpt'] );
+ }
+ if ( isset( $params['slug'] ) && '' !== (string) $params['slug'] ) {
+ $postarr['post_name'] = sanitize_title( (string) $params['slug'] );
+ }
+ if ( $existing_id ) {
+ $postarr['ID'] = $existing_id;
+ }
+
+ /**
+ * Filters the post array before a CiteCue push is inserted/updated.
+ * Posts and pages only — products go through WooCommerce's CRUD.
+ *
+ * @param array $postarr wp_insert_post() arguments.
+ * @param array $params Raw (unsanitized) request payload.
+ */
+ $postarr = apply_filters( 'citecue_ingest_postarr', $postarr, $params );
+
+ $result = $existing_id ? wp_update_post( $postarr, true ) : wp_insert_post( $postarr, true );
+ if ( is_wp_error( $result ) ) {
+ $result->add_data( array( 'status' => 500 ) );
+ return $result;
+ }
+ $post_id = (int) $result;
+ }
+
+ if ( 'post' === $post_type || 'product' === $post_type ) {
+ $category_taxonomy = 'product' === $post_type ? 'product_cat' : 'category';
+ $tag_taxonomy = 'product' === $post_type ? 'product_tag' : 'post_tag';
+ if ( isset( $params['categories'] ) && is_array( $params['categories'] ) ) {
+ wp_set_post_terms( $post_id, $this->ensure_terms( $params['categories'], $category_taxonomy ), $category_taxonomy );
+ }
+ if ( isset( $params['tags'] ) && is_array( $params['tags'] ) ) {
+ wp_set_post_terms( $post_id, array_map( 'sanitize_text_field', array_map( 'strval', $params['tags'] ) ), $tag_taxonomy );
+ }
+ }
+
+ update_post_meta( $post_id, '_citecue_external_id', $external_id );
+ update_post_meta( $post_id, '_citecue_synced_at', time() );
+ if ( isset( $params['source'] ) ) {
+ update_post_meta( $post_id, '_citecue_source', sanitize_text_field( (string) $params['source'] ) );
+ }
+ if ( isset( $params['meta_description'] ) ) {
+ update_post_meta( $post_id, '_citecue_meta_description', sanitize_text_field( (string) $params['meta_description'] ) );
+ }
+
+ // Hash the content as stored (after WordPress filters), so the next
+ // push can tell "edited in WordPress" apart from "unchanged".
+ $stored = get_post( $post_id );
+ update_post_meta( $post_id, '_citecue_content_hash', md5( $stored ? $stored->post_content : '' ) );
+
+ $response = rest_ensure_response(
+ array(
+ 'created' => ! $existing_id,
+ 'updated' => (bool) $existing_id,
+ 'post_id' => $post_id,
+ 'status' => get_post_status( $post_id ),
+ 'permalink' => get_permalink( $post_id ),
+ 'edit_link' => add_query_arg(
+ array(
+ 'post' => $post_id,
+ 'action' => 'edit',
+ ),
+ admin_url( 'post.php' )
+ ),
+ )
+ );
+ $response->set_status( $existing_id ? 200 : 201 );
+ return $response;
+ }
+
+ /**
+ * Restricts external ids to a safe charset and length.
+ *
+ * @param mixed $raw Raw external id.
+ * @return string
+ */
+ private function sanitize_external_id( $raw ) {
+ $id = preg_replace( '/[^A-Za-z0-9:_\-\.]/', '', (string) $raw );
+ return substr( (string) $id, 0, 128 );
+ }
+
+ /**
+ * The effective post status: the requested one, capped by the configured
+ * maximum (draft < pending < publish). Pushed content can never be more
+ * visible than the site owner allowed.
+ *
+ * @param string $requested Requested status ('' for none).
+ * @return string
+ */
+ private function resolve_status( $requested ) {
+ $rank = array(
+ 'draft' => 0,
+ 'pending' => 1,
+ 'publish' => 2,
+ );
+ $cap = (string) $this->plugin->settings->get( 'ingest_post_status' );
+ if ( ! isset( $rank[ $cap ] ) ) {
+ $cap = 'draft';
+ }
+ if ( ! isset( $rank[ $requested ] ) ) {
+ return $cap;
+ }
+ return $rank[ $requested ] <= $rank[ $cap ] ? $requested : $cap;
+ }
+
+ /**
+ * The author for pushed posts: the configured user when valid, otherwise
+ * the oldest administrator.
+ *
+ * @return int User ID (0 when the site has no administrator).
+ */
+ private function resolve_author() {
+ $configured = (int) $this->plugin->settings->get( 'ingest_author' );
+ if ( $configured > 0 ) {
+ $user = get_userdata( $configured );
+ if ( $user && $user->has_cap( 'edit_posts' ) ) {
+ return $configured;
+ }
+ }
+
+ $admins = get_users(
+ array(
+ 'role' => 'administrator',
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'number' => 1,
+ 'fields' => 'ID',
+ )
+ );
+ return $admins ? (int) $admins[0] : 0;
+ }
+
+ /**
+ * Creates or updates a WooCommerce product through WooCommerce's CRUD API
+ * (which maintains lookup tables, caches and SKU uniqueness — raw
+ * wp_insert_post would not). New products are simple products; updates
+ * keep whatever type the product already has.
+ *
+ * @param int $existing_id Existing product ID, or 0 to create.
+ * @param array $params Raw request payload.
+ * @param string $title Sanitized title.
+ * @param string $content Sanitized description HTML.
+ * @param string $status Effective post status.
+ * @return int|WP_Error Product ID.
+ */
+ private function upsert_product( $existing_id, array $params, $title, $content, $status ) {
+ if ( ! function_exists( 'wc_get_product' ) ) {
+ return new WP_Error( 'citecue_woocommerce_missing', __( 'WooCommerce is not active on this site.', 'citecue' ), array( 'status' => 400 ) );
+ }
+
+ try {
+ $product = $existing_id ? wc_get_product( $existing_id ) : new WC_Product_Simple();
+ if ( ! $product ) {
+ return new WP_Error( 'citecue_product_load_failed', __( 'The existing product could not be loaded.', 'citecue' ), array( 'status' => 500 ) );
+ }
+
+ $product->set_name( $title );
+ $product->set_description( $content );
+ $product->set_status( $status );
+ if ( isset( $params['excerpt'] ) ) {
+ $product->set_short_description( wp_kses_post( (string) $params['excerpt'] ) );
+ }
+ if ( isset( $params['slug'] ) && '' !== (string) $params['slug'] ) {
+ $product->set_slug( sanitize_title( (string) $params['slug'] ) );
+ }
+ if ( isset( $params['sku'] ) && '' !== (string) $params['sku'] ) {
+ $product->set_sku( wc_clean( (string) $params['sku'] ) );
+ }
+ if ( isset( $params['regular_price'] ) && '' !== (string) $params['regular_price'] ) {
+ $product->set_regular_price( wc_format_decimal( (string) $params['regular_price'] ) );
+ }
+
+ $post_id = (int) $product->save();
+ } catch ( WC_Data_Exception $e ) {
+ // E.g. the SKU belongs to a different product.
+ return new WP_Error( 'citecue_product_invalid', $e->getMessage(), array( 'status' => 409 ) );
+ } catch ( Throwable $e ) {
+ return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue' ), array( 'status' => 500 ) );
+ }
+
+ if ( $post_id <= 0 ) {
+ return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue' ), array( 'status' => 500 ) );
+ }
+
+ // WC's CRUD does not take an author; attribute newly created products
+ // like other pushed content.
+ if ( ! $existing_id ) {
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_author' => $this->resolve_author(),
+ )
+ );
+ }
+
+ return $post_id;
+ }
+
+ /**
+ * Finds a previously pushed post by its external id.
+ *
+ * @param string $external_id External id.
+ * @return int Post ID, or 0.
+ */
+ private function find_by_external_id( $external_id ) {
+ $found = get_posts(
+ array(
+ 'post_type' => array( 'post', 'page', 'product' ),
+ 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private', 'trash' ),
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'meta_key' => '_citecue_external_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => $external_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ )
+ );
+ return $found ? (int) $found[0] : 0;
+ }
+
+ /**
+ * Whether the post's content changed in WordPress since our last push.
+ *
+ * @param int $post_id Post ID.
+ * @return bool
+ */
+ private function edited_locally( $post_id ) {
+ $last_hash = (string) get_post_meta( $post_id, '_citecue_content_hash', true );
+ if ( '' === $last_hash ) {
+ return false;
+ }
+ $post = get_post( $post_id );
+ return $post && md5( $post->post_content ) !== $last_hash;
+ }
+
+ /**
+ * Resolves term names to ids for a hierarchical taxonomy, creating
+ * missing terms.
+ *
+ * @param array $names Term names.
+ * @param string $taxonomy Taxonomy.
+ * @return int[]
+ */
+ private function ensure_terms( array $names, $taxonomy ) {
+ $ids = array();
+ foreach ( $names as $name ) {
+ $name = sanitize_text_field( (string) $name );
+ if ( '' === $name ) {
+ continue;
+ }
+ $existing = term_exists( $name, $taxonomy );
+ if ( $existing ) {
+ $ids[] = (int) ( is_array( $existing ) ? $existing['term_id'] : $existing );
+ continue;
+ }
+ $created = wp_insert_term( $name, $taxonomy );
+ if ( ! is_wp_error( $created ) ) {
+ $ids[] = (int) $created['term_id'];
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * Prints a meta description for pushed content on singular views, unless
+ * a dedicated SEO plugin is active (those own the description tag).
+ *
+ * @return void
+ */
+ public function output_meta_description() {
+ if ( ! is_singular() ) {
+ return;
+ }
+ $post_id = get_queried_object_id();
+ $description = (string) get_post_meta( $post_id, '_citecue_meta_description', true );
+ if ( '' === $description ) {
+ return;
+ }
+
+ $seo_plugin_active = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' );
+
+ /**
+ * Filters whether the plugin outputs the meta description tag for
+ * pushed content.
+ *
+ * @param bool $output Default: true unless an SEO plugin is active.
+ * @param int $post_id Queried post ID.
+ */
+ if ( ! apply_filters( 'citecue_output_meta_description', ! $seo_plugin_active, $post_id ) ) {
+ return;
+ }
+
+ echo '' . "\n";
+ }
+}
diff --git a/includes/class-citecue-llms-txt.php b/includes/class-citecue-llms-txt.php
new file mode 100644
index 0000000..7fad657
--- /dev/null
+++ b/includes/class-citecue-llms-txt.php
@@ -0,0 +1,154 @@
+plugin = $plugin;
+ }
+
+ /**
+ * Hooks the handler.
+ *
+ * @return void
+ */
+ public function register() {
+ add_action( 'template_redirect', array( $this, 'maybe_serve' ), 0 );
+ }
+
+ /**
+ * Serves llms.txt when this request targets it and CiteCue has a body.
+ *
+ * @return void
+ */
+ public function maybe_serve() {
+ if ( ! $this->is_llms_txt_request() ) {
+ return;
+ }
+
+ $settings = $this->plugin->settings;
+ if ( ! $settings->get( 'llms_txt_enabled' ) || ! $settings->is_delivery_configured() ) {
+ return;
+ }
+
+ $cache = $this->plugin->cache;
+ $cached = $cache->get_llms_txt();
+
+ // Fresh cached copy or open circuit: serve locally, no API call.
+ if ( $cached && ( $cache->is_fresh( $cached, self::FRESH_SECONDS ) || $cache->is_circuit_open() ) ) {
+ $this->serve( $cached['body'] );
+ }
+ if ( $cache->is_circuit_open() ) {
+ return;
+ }
+
+ $response = $this->plugin->api->get_llms_txt( $cached ? $cached['etag'] : '' );
+
+ if ( is_wp_error( $response ) ) {
+ $cache->trip_circuit();
+ if ( $cached ) {
+ $this->serve( $cached['body'] );
+ }
+ return;
+ }
+
+ switch ( $response['status'] ) {
+ case 200:
+ $cache->set_llms_txt( $response['body'], $response['etag'] );
+ $this->serve( $response['body'] );
+ return;
+
+ case 304:
+ if ( $cached ) {
+ $cache->set_llms_txt( $cached['body'], $cached['etag'] );
+ $this->serve( $cached['body'] );
+ }
+ return;
+
+ case 401:
+ update_option( 'citecue_auth_failed', time(), false );
+ $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL );
+ return;
+
+ case 404:
+ // llms.txt serving disabled on CiteCue: evict the cached copy
+ // (so it cannot resurface stale) and fall through to WordPress.
+ $cache->delete_llms_txt();
+ return;
+
+ default:
+ return;
+ }
+ }
+
+ /**
+ * Whether the current request targets llms.txt at the site root
+ * (or the WordPress subdirectory root for subdirectory installs).
+ *
+ * @return bool
+ */
+ private function is_llms_txt_request() {
+ if ( ! isset( $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) {
+ return false;
+ }
+
+ $path = (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH );
+
+ $home_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
+ $target = untrailingslashit( $home_path ) . '/llms.txt';
+
+ return $path === $target;
+ }
+
+ /**
+ * Emits llms.txt and ends the request. The `X-Citecue: llms-txt` header is
+ * what CiteCue's install verification probes for.
+ *
+ * @param string $body llms.txt content.
+ * @return void
+ */
+ private function serve( $body ) {
+ if ( ! defined( 'DONOTCACHEPAGE' ) ) {
+ define( 'DONOTCACHEPAGE', true );
+ }
+
+ status_header( 200 );
+ header( 'Content-Type: text/plain; charset=utf-8' );
+ header( 'Cache-Control: public, max-age=300' );
+ header( 'X-Citecue: llms-txt' );
+
+ echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text body served verbatim.
+ exit;
+ }
+}
diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php
new file mode 100644
index 0000000..2e2081c
--- /dev/null
+++ b/includes/class-citecue-plugin.php
@@ -0,0 +1,141 @@
+settings = new Citecue_Settings();
+ $this->crawlers = new Citecue_Crawlers();
+ $this->cache = new Citecue_Cache();
+ $this->activity = new Citecue_Activity_Log();
+ $this->api = new Citecue_Api_Client( $this->settings );
+
+ ( new Citecue_Llms_Txt( $this ) )->register();
+ ( new Citecue_Proxy( $this ) )->register();
+ ( new Citecue_Ingest( $this ) )->register();
+
+ if ( is_admin() ) {
+ ( new Citecue_Admin( $this ) )->register();
+ }
+
+ add_action( 'init', array( $this, 'on_init' ) );
+ add_action( self::CRON_HOOK, array( $this, 'daily_sync' ) );
+ }
+
+ /**
+ * Init: translations + cron self-heal.
+ *
+ * @return void
+ */
+ public function on_init() {
+ load_plugin_textdomain( 'citecue', false, dirname( plugin_basename( CITECUE_PLUGIN_FILE ) ) . '/languages' );
+
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK );
+ }
+ }
+
+ /**
+ * Daily sync: refresh the AI-crawler registry so new crawlers are served
+ * without a plugin update.
+ *
+ * @return void
+ */
+ public function daily_sync() {
+ $this->crawlers->refresh( $this->api );
+ }
+
+ /**
+ * Activation: schedule the sync and pre-generate the ingest secret.
+ *
+ * @return void
+ */
+ public static function activate() {
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK );
+ }
+
+ $settings = new Citecue_Settings();
+ $settings->ensure_ingest_secret();
+ }
+
+ /**
+ * Deactivation: clear the cron.
+ *
+ * @return void
+ */
+ public static function deactivate() {
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+ }
+}
diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php
new file mode 100644
index 0000000..deaae75
--- /dev/null
+++ b/includes/class-citecue-proxy.php
@@ -0,0 +1,315 @@
+plugin = $plugin;
+ }
+
+ /**
+ * Hooks the interceptor. Priority 0 so it runs before canonical redirects:
+ * crawlers get the exact URL they asked for, mirroring the CiteCue
+ * Cloudflare Worker's behavior.
+ *
+ * @return void
+ */
+ public function register() {
+ add_action( 'template_redirect', array( $this, 'maybe_serve' ), 0 );
+ }
+
+ /**
+ * Serves the optimized page when this is an AI-crawler request with
+ * servable content; returns silently otherwise.
+ *
+ * @return void
+ */
+ public function maybe_serve() {
+ if ( ! $this->is_eligible_request() ) {
+ return;
+ }
+
+ $settings = $this->plugin->settings;
+ if ( ! $settings->get( 'serve_enabled' ) || ! $settings->is_delivery_configured() ) {
+ return;
+ }
+
+ $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
+ $crawler = $this->plugin->crawlers->match( $user_agent );
+ if ( null === $crawler ) {
+ return;
+ }
+
+ $url = $this->current_url();
+ if ( '' === $url ) {
+ return;
+ }
+
+ /**
+ * Filters whether to serve optimized content for this crawler request.
+ *
+ * @param bool $should_serve Default true.
+ * @param string $crawler Matched UA token.
+ * @param string $url Absolute request URL.
+ */
+ if ( ! apply_filters( 'citecue_should_serve', true, $crawler, $url ) ) {
+ return;
+ }
+
+ $path = (string) wp_parse_url( $url, PHP_URL_PATH );
+ $cache = $this->plugin->cache;
+
+ // Recent miss for this URL: skip the API for a minute (mirrors the
+ // API's own max-age=60 on the miss sentinel).
+ if ( $cache->is_recent_miss( $url ) ) {
+ return;
+ }
+
+ $cached = $cache->get_page( $url );
+
+ // Circuit open (recent timeout/auth failure): no API calls. Serve the
+ // stale cached copy when we have one, otherwise pass through.
+ if ( $cache->is_circuit_open() ) {
+ if ( $cached ) {
+ $this->serve( $cached['body'], $cached['mode'], true );
+ }
+ return;
+ }
+
+ // Global lookup budget: a spoofed crawler UA spraying unique URLs
+ // cannot force unbounded outbound API calls. Exhausted budget degrades
+ // exactly like an open circuit.
+ if ( ! $this->consume_lookup_budget() ) {
+ if ( $cached ) {
+ $this->serve( $cached['body'], $cached['mode'], true );
+ }
+ return;
+ }
+
+ $response = $this->plugin->api->get_page( $url, $crawler, $cached ? $cached['etag'] : '' );
+
+ if ( is_wp_error( $response ) ) {
+ // Timeout / connection failure: open the circuit and degrade.
+ $cache->trip_circuit();
+ $this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' );
+ if ( $cached ) {
+ $this->serve( $cached['body'], $cached['mode'], true );
+ }
+ return;
+ }
+
+ switch ( $response['status'] ) {
+ case 200:
+ $cache->set_page( $url, $response['body'], $response['etag'], $response['mode'] );
+ $this->plugin->activity->record( $crawler, $path, 'served' );
+ $this->serve( $response['body'], $response['mode'], false );
+ return; // Unreachable (serve exits); defensive.
+
+ case 304:
+ // Our cached body is current; CiteCue already counted this as served.
+ if ( $cached ) {
+ $cache->touch_page( $url );
+ $this->plugin->activity->record( $crawler, $path, 'served' );
+ $this->serve( $cached['body'], $cached['mode'], false );
+ }
+ return;
+
+ case 401:
+ // Bad/revoked API key: remember it for the admin notice and
+ // back off for a while — retrying immediately cannot help.
+ update_option( 'citecue_auth_failed', time(), false );
+ $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL );
+ return;
+
+ case 404:
+ // Miss sentinel: CiteCue recorded the passthrough hit
+ // server-side. Evict any previously cached body — the page
+ // was removed/unapproved, so it must not resurface through
+ // the stale-on-error path.
+ delete_option( 'citecue_auth_failed' );
+ $cache->delete_page( $url );
+ $cache->set_miss( $url );
+ $this->plugin->activity->record( $crawler, $path, 'passthrough' );
+ return;
+
+ default:
+ // Unexpected server state: brief back-off, degrade gracefully.
+ $cache->trip_circuit();
+ $this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' );
+ if ( $cached ) {
+ $this->serve( $cached['body'], $cached['mode'], true );
+ }
+ return;
+ }
+ }
+
+ /**
+ * Whether this request is one the proxy may intercept: a plain frontend
+ * GET from an anonymous visitor. Everything else belongs to WordPress.
+ *
+ * @return bool
+ */
+ private function is_eligible_request() {
+ if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) {
+ return false;
+ }
+ if ( is_admin() || is_user_logged_in() ) {
+ return false;
+ }
+ if ( is_feed() || is_robots() || is_trackback() || is_preview() || is_embed() ) {
+ return false;
+ }
+ if ( is_customize_preview() ) {
+ return false;
+ }
+ if ( function_exists( 'is_favicon' ) && is_favicon() ) {
+ return false;
+ }
+ if ( '' !== (string) get_query_var( 'sitemap' ) ) {
+ return false;
+ }
+ if ( $this->is_excluded_woocommerce_request() ) {
+ return false;
+ }
+ if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
+ return false;
+ }
+ if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
+ return false;
+ }
+ if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
+ return false;
+ }
+ if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
+ return false;
+ }
+ if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Consumes one unit of the per-minute outbound-lookup budget. Bounds the
+ * delivery API calls an anonymous visitor can trigger by spoofing a
+ * crawler User-Agent across unique URLs; real crawl bursts beyond the
+ * budget just fall through to the normal page for the rest of the minute.
+ *
+ * @return bool Whether an API lookup may be made.
+ */
+ private function consume_lookup_budget() {
+ /**
+ * Filters the maximum delivery API lookups per minute.
+ *
+ * @param int $limit Default 120.
+ */
+ $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) );
+ $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS );
+ $count = (int) get_transient( $key );
+ if ( $count >= $limit ) {
+ return false;
+ }
+ set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS );
+ 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'] ) );
+ $uri = 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
+ * tells full-page cache plugins not to store this bot-only response.
+ *
+ * @param string $body Optimized HTML document.
+ * @param string $mode Optimization mode (enriched|rewrite).
+ * @param bool $stale Whether this body was served past its ETag window.
+ * @return void
+ */
+ private function serve( $body, $mode, $stale ) {
+ if ( ! defined( 'DONOTCACHEPAGE' ) ) {
+ define( 'DONOTCACHEPAGE', true );
+ }
+
+ status_header( 200 );
+ header( 'Content-Type: text/html; charset=utf-8' );
+ header( 'Cache-Control: private, no-store' );
+ header( 'X-Citecue: served' );
+ if ( '' !== $mode ) {
+ header( 'X-Citecue-Mode: ' . sanitize_key( $mode ) );
+ }
+ if ( $stale ) {
+ header( 'X-Citecue-Cache: stale' );
+ }
+
+ // Full HTML document generated by CiteCue for this site's own page —
+ // output verbatim by design (escaping would destroy the document).
+ echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ exit;
+ }
+}
diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php
new file mode 100644
index 0000000..610c67d
--- /dev/null
+++ b/includes/class-citecue-settings.php
@@ -0,0 +1,207 @@
+ self::DEFAULT_API_BASE,
+ 'api_key' => '',
+ 'public_key' => '',
+ 'project_domain' => '',
+ // Delivery.
+ 'serve_enabled' => true,
+ 'llms_txt_enabled' => true,
+ // Content ingest (CiteCue -> WordPress post creation).
+ 'ingest_enabled' => false,
+ 'ingest_secret' => '',
+ 'ingest_post_status' => 'draft',
+ 'ingest_post_type' => 'post',
+ 'ingest_author' => 0,
+ );
+ }
+
+ /**
+ * Full settings array merged over defaults.
+ *
+ * @return array
+ */
+ public function all() {
+ if ( null === $this->values ) {
+ $stored = get_option( self::OPTION, array() );
+ $this->values = wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() );
+ }
+ return $this->values;
+ }
+
+ /**
+ * One setting value.
+ *
+ * @param string $key Setting key.
+ * @return mixed
+ */
+ public function get( $key ) {
+ $all = $this->all();
+ return isset( $all[ $key ] ) ? $all[ $key ] : null;
+ }
+
+ /**
+ * Persist a partial update, merged over the current values.
+ *
+ * @param array $partial Key/value pairs to update.
+ * @return void
+ */
+ public function update( array $partial ) {
+ $merged = array_merge( $this->all(), $partial );
+ update_option( self::OPTION, $merged );
+ $this->values = $merged;
+ }
+
+ /**
+ * The API base with no trailing slash.
+ *
+ * @return string
+ */
+ public function api_base() {
+ $base = untrailingslashit( (string) $this->get( 'api_base' ) );
+ return '' !== $base ? $base : self::DEFAULT_API_BASE;
+ }
+
+ /**
+ * Whether the delivery proxy has everything it needs to serve.
+ *
+ * @return bool
+ */
+ public function is_delivery_configured() {
+ return '' !== (string) $this->get( 'api_key' ) && '' !== (string) $this->get( 'public_key' );
+ }
+
+ /**
+ * Ensures the ingest shared secret exists, generating one if missing.
+ *
+ * @return string
+ */
+ public function ensure_ingest_secret() {
+ $secret = (string) $this->get( 'ingest_secret' );
+ if ( '' === $secret ) {
+ $secret = 'cws_' . bin2hex( random_bytes( 20 ) );
+ $this->update( array( 'ingest_secret' => $secret ) );
+ }
+ return $secret;
+ }
+
+ /**
+ * Sanitize callback for register_setting(). Empty API key input keeps the
+ * stored key so re-saving the form never wipes credentials.
+ *
+ * @param mixed $input Raw form input.
+ * @return array
+ */
+ public function sanitize( $input ) {
+ $current = $this->all();
+ $input = is_array( $input ) ? $input : array();
+ $out = $current;
+
+ if ( isset( $input['api_base'] ) ) {
+ $base = esc_url_raw( trim( (string) $input['api_base'] ) );
+ $out['api_base'] = '' !== $base ? untrailingslashit( $base ) : self::DEFAULT_API_BASE;
+ }
+
+ if ( ! empty( $input['api_key_clear'] ) ) {
+ $out['api_key'] = '';
+ } elseif ( isset( $input['api_key'] ) && '' !== trim( (string) $input['api_key'] ) ) {
+ $out['api_key'] = sanitize_text_field( trim( (string) $input['api_key'] ) );
+ }
+
+ if ( isset( $input['public_key'] ) ) {
+ $public_key = sanitize_text_field( trim( (string) $input['public_key'] ) );
+ $out['public_key'] = $public_key;
+ // Keep the displayed project domain in sync with the chosen key.
+ $projects = get_option( 'citecue_projects_cache', array() );
+ if ( is_array( $projects ) ) {
+ foreach ( $projects as $project ) {
+ if ( isset( $project['publicKey'], $project['domain'] ) && $project['publicKey'] === $public_key ) {
+ $out['project_domain'] = (string) $project['domain'];
+ }
+ }
+ }
+ if ( '' === $public_key ) {
+ $out['project_domain'] = '';
+ }
+ }
+
+ $out['serve_enabled'] = ! empty( $input['serve_enabled'] );
+ $out['llms_txt_enabled'] = ! empty( $input['llms_txt_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 ) ) {
+ $out['ingest_post_status'] = $input['ingest_post_status'];
+ }
+
+ $allowed_types = array( 'post', 'page' );
+ if ( class_exists( 'WooCommerce' ) ) {
+ $allowed_types[] = 'product';
+ }
+ if ( isset( $input['ingest_post_type'] ) && in_array( $input['ingest_post_type'], $allowed_types, true ) ) {
+ $out['ingest_post_type'] = $input['ingest_post_type'];
+ }
+
+ if ( isset( $input['ingest_author'] ) ) {
+ $out['ingest_author'] = absint( $input['ingest_author'] );
+ }
+
+ // Internal-only fields. register_setting() routes EVERY
+ // update_option() for this option through this callback (via the
+ // sanitize_option_* filter), including the plugin's own update()
+ // calls — so these must pass through here or internal writes (e.g. a
+ // regenerated ingest secret) would be silently dropped. The settings
+ // form never posts these names.
+ if ( isset( $input['ingest_secret'] ) && is_string( $input['ingest_secret'] ) ) {
+ $out['ingest_secret'] = sanitize_text_field( $input['ingest_secret'] );
+ }
+ if ( isset( $input['project_domain'] ) && is_string( $input['project_domain'] ) ) {
+ $out['project_domain'] = sanitize_text_field( $input['project_domain'] );
+ }
+
+ // A changed key may fix a previous auth failure; let serving retry now.
+ if ( $out['api_key'] !== $current['api_key'] ) {
+ delete_option( 'citecue_auth_failed' );
+ delete_transient( 'citecue_circuit' );
+ }
+
+ $this->values = $out;
+ return $out;
+ }
+}
diff --git a/includes/index.php b/includes/index.php
new file mode 100644
index 0000000..49d255d
--- /dev/null
+++ b/includes/index.php
@@ -0,0 +1 @@
+