diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 594b66b..daba8e6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,9 +1,10 @@
name: Release
-# Publishes citecue.zip — an archive that unpacks to citecue/ and contains only
-# the runtime files — as a release asset, so that installing the plugin is
-# "Plugins → Add New → Upload" rather than "download the source, rename the
-# folder, delete the tests".
+# Publishes citecue-ai-auto-fix.zip — an archive that unpacks to
+# citecue-ai-auto-fix/ (the WordPress.org slug, so the two install channels
+# share one directory) and contains only the runtime files — as a release
+# asset, so that installing the plugin is "Plugins → Add New → Upload" rather
+# than "download the source, rename the folder, delete the tests".
on:
push:
@@ -45,7 +46,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: citecue-plugin-zip
- path: dist/citecue.zip
+ path: dist/citecue-ai-auto-fix.zip
if-no-files-found: error
- name: Attach the zip to the release
@@ -54,9 +55,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
- gh release upload "$GITHUB_REF_NAME" dist/citecue.zip --clobber
+ gh release upload "$GITHUB_REF_NAME" dist/citecue-ai-auto-fix.zip --clobber
else
- gh release create "$GITHUB_REF_NAME" dist/citecue.zip \
+ gh release create "$GITHUB_REF_NAME" dist/citecue-ai-auto-fix.zip \
--title "$GITHUB_REF_NAME" \
--generate-notes
fi
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
index bd754e9..e59049a 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -24,11 +24,16 @@
-
+
+
diff --git a/README.md b/README.md
index 05bb987..d2d1a78 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor
## Setup
-1. Download `citecue.zip` from the [latest release](https://github.com/citecue/wordpress-plugin/releases/latest), then install it under **Plugins → Add New → Upload Plugin** and activate it.
+1. Download `citecue-ai-auto-fix.zip` from the [latest release](https://github.com/citecue/wordpress-plugin/releases/latest), then install it under **Plugins → Add New → Upload Plugin** and activate it.
2. Open **Settings → CiteCue** and click **Connect to CiteCue**.
3. Confirm the project for this site in CiteCue. You are redirected back, and the plugin checks itself.
@@ -228,12 +228,12 @@ Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout
GitHub's **Download ZIP** button is not an install path: it produces `wordpress-plugin-main.zip`, which unpacks to `wordpress-plugin-main/` and carries the tests and Composer files with it. WordPress keys a plugin by its directory name, so installs have to come from the release asset instead.
```bash
-bin/build-plugin-zip.sh # writes dist/citecue.zip from HEAD
+bin/build-plugin-zip.sh # writes dist/citecue-ai-auto-fix.zip from HEAD
```
-The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue/` directory. CI runs the same script on every pull request.
+The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue-ai-auto-fix/` directory — the WordPress.org slug, so a site that switches between the two install channels upgrades one plugin rather than ending up with two. CI runs the same script on every pull request.
-To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue.zip` to the GitHub release.
+To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue-ai-auto-fix.zip` to the GitHub release.
### Tests
diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh
index a059a41..337d761 100755
--- a/bin/build-plugin-zip.sh
+++ b/bin/build-plugin-zip.sh
@@ -14,11 +14,18 @@
# in .gitattributes, so an untracked vendor/, .env or editor backup can never
# be swept into a release.
#
+# SLUG must stay equal to the WordPress.org slug. WordPress.org derives that
+# from the Plugin Name header and will not change it after approval, and it is
+# what the directory installs into. If this zip used a different name, a site
+# that installed from GitHub and later switched to the directory (or the
+# reverse) would end up running two copies of the plugin from two directories
+# rather than upgrading one.
+#
# Usage: bin/build-plugin-zip.sh [ref] (ref defaults to HEAD)
set -euo pipefail
-SLUG=citecue
+SLUG=citecue-ai-auto-fix
REF=${1:-HEAD}
ROOT=$(git rev-parse --show-toplevel)
diff --git a/citecue.php b/citecue.php
index 820b5f3..d47a27e 100644
--- a/citecue.php
+++ b/citecue.php
@@ -1,7 +1,7 @@
%s
',
+ esc_html(
+ sprintf(
+ /* translators: 1: plugin file that is running, e.g. citecue/citecue.php. 2: duplicate plugin file that did not load. */
+ __( 'CiteCue AI Auto-Fix is installed twice. WordPress is running %1$s, so the copy in %2$s did not load. Deactivate and delete whichever of the two you do not want to keep.', 'citecue-ai-auto-fix' ),
+ plugin_basename( CITECUE_PLUGIN_FILE ),
+ plugin_basename( __FILE__ )
+ )
+ )
+ );
+ }
+ );
+
+ return;
+}
+
define( 'CITECUE_VERSION', '1.0.0' );
define( 'CITECUE_PLUGIN_FILE', __FILE__ );
define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
diff --git a/composer.json b/composer.json
index 80a7f5e..78408fc 100644
--- a/composer.json
+++ b/composer.json
@@ -13,7 +13,7 @@
"wp-coding-standards/wpcs": "^3.1",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"wp-phpunit/wp-phpunit": "^7.0",
- "roots/wordpress-no-content": "^6.8",
+ "roots/wordpress-no-content": "^7.0",
"composer/installers": "^2.2"
},
"repositories": [
diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php
index fafaf32..d4a5d7f 100644
--- a/includes/class-citecue-admin.php
+++ b/includes/class-citecue-admin.php
@@ -63,8 +63,8 @@ public function register() {
*/
public function add_menu() {
add_options_page(
- __( 'CiteCue AI Auto-Fix', 'citecue' ),
- __( 'CiteCue', 'citecue' ),
+ __( 'CiteCue AI Auto-Fix', 'citecue-ai-auto-fix' ),
+ __( 'CiteCue', 'citecue-ai-auto-fix' ),
'manage_options',
'citecue',
array( $this, 'render_page' )
@@ -94,7 +94,7 @@ public function register_settings() {
* @return array
*/
public function action_links( $links ) {
- array_unshift( $links, '' . esc_html__( 'Settings', 'citecue' ) . '' );
+ array_unshift( $links, '' . esc_html__( 'Settings', 'citecue-ai-auto-fix' ) . '' );
return $links;
}
@@ -129,9 +129,9 @@ public function notices() {
}
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' ) . '
';
+ echo '
' . esc_html__( 'CiteCue:', 'citecue-ai-auto-fix' ) . ' '
+ . 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-ai-auto-fix' )
+ . ' ' . esc_html__( 'Open settings', 'citecue-ai-auto-fix' ) . '
';
}
if ( ! isset( $_GET['citecue_msg'] ) || ! isset( $_GET['page'] ) || 'citecue' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only feedback.
@@ -139,21 +139,21 @@ public function notices() {
}
$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' ) ),
- 'paired' => array( 'success', __( 'Connected. CiteCue now knows this site’s address and can serve optimized pages to AI crawlers.', 'citecue' ) ),
- 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue' ) ),
- 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue' ) ),
- 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue' ) ),
- 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s llms.txt.', 'citecue' ) ),
- 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue' ) ),
- 'verify_skip' => array( 'info', __( 'The check could not run. See the details below.', 'citecue' ) ),
- 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue' ) ),
- 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection 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' ) ),
+ 'connected' => array( 'success', __( 'Connected to CiteCue.', 'citecue-ai-auto-fix' ) ),
+ 'auto_selected' => array( 'success', __( 'Connected to CiteCue — the project matching this site was selected automatically.', 'citecue-ai-auto-fix' ) ),
+ 'paired' => array( 'success', __( 'Connected. CiteCue now knows this site’s address and can serve optimized pages to AI crawlers.', 'citecue-ai-auto-fix' ) ),
+ 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue-ai-auto-fix' ) ),
+ 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue-ai-auto-fix' ) ),
+ 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue-ai-auto-fix' ) ),
+ 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s llms.txt.', 'citecue-ai-auto-fix' ) ),
+ 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue-ai-auto-fix' ) ),
+ 'verify_skip' => array( 'info', __( 'The check could not run. See the details below.', 'citecue-ai-auto-fix' ) ),
+ 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue-ai-auto-fix' ) ),
+ 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection and try again.', 'citecue-ai-auto-fix' ) ),
+ 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue-ai-auto-fix' ) ),
+ 'crawlers_fail' => array( 'warning', __( 'Could not refresh the crawler registry; the current list stays active.', 'citecue-ai-auto-fix' ) ),
+ 'flushed' => array( 'success', __( 'Delivery cache flushed.', 'citecue-ai-auto-fix' ) ),
+ 'secret' => array( 'success', __( 'New ingest secret generated. Update it anywhere the old secret was used.', 'citecue-ai-auto-fix' ) ),
);
$code = sanitize_key( wp_unslash( $_GET['citecue_msg'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -220,7 +220,7 @@ public function maybe_claim_connect() {
*/
public function handle_connect_start() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_connect_start' );
@@ -237,7 +237,7 @@ public function handle_connect_start() {
*/
public function handle_disconnect() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_disconnect' );
@@ -252,7 +252,7 @@ public function handle_disconnect() {
*/
public function handle_verify_install() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_verify_install' );
@@ -273,7 +273,7 @@ public function handle_verify_install() {
*/
public function handle_test_connection() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_test_connection', 'citecue_test_nonce' );
@@ -339,7 +339,7 @@ public function handle_test_connection() {
*/
public function handle_refresh_crawlers() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_refresh_crawlers' );
@@ -354,7 +354,7 @@ public function handle_refresh_crawlers() {
*/
public function handle_flush_cache() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_flush_cache' );
@@ -369,7 +369,7 @@ public function handle_flush_cache() {
*/
public function handle_regen_secret() {
if ( ! current_user_can( 'manage_options' ) ) {
- wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) );
+ wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) );
}
check_admin_referer( 'citecue_regen_secret' );
@@ -388,7 +388,7 @@ public function render_page() {
}
?>
@@ -848,7 +848,7 @@ private function render_activity() {
diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php
index d73f486..46016fb 100644
--- a/includes/class-citecue-api-client.php
+++ b/includes/class-citecue-api-client.php
@@ -161,10 +161,10 @@ public function claim_connect_code( $code, array $site ) {
if ( 200 !== $result['status'] ) {
$reasons = array(
- 'invalid_code' => __( 'That connection link is not valid. Start the connection again from WordPress.', 'citecue' ),
- 'code_used' => __( 'That connection link has already been used. Start the connection again from WordPress.', 'citecue' ),
- 'code_expired' => __( 'That connection link expired. Start the connection again from WordPress.', 'citecue' ),
- 'site_mismatch' => __( 'CiteCue issued that link for a different site address than this one.', 'citecue' ),
+ 'invalid_code' => __( 'That connection link is not valid. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ),
+ 'code_used' => __( 'That connection link has already been used. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ),
+ 'code_expired' => __( 'That connection link expired. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ),
+ 'site_mismatch' => __( 'CiteCue issued that link for a different site address than this one.', 'citecue-ai-auto-fix' ),
);
$code_key = isset( $data['error'] ) ? (string) $data['error'] : '';
@@ -172,11 +172,11 @@ public function claim_connect_code( $code, array $site ) {
return new WP_Error( 'citecue_connect_' . $code_key, $reasons[ $code_key ] );
}
/* translators: %d: HTTP status code. */
- return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) );
+ return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $result['status'] ) );
}
if ( empty( $data['apiKey'] ) || empty( $data['publicKey'] ) ) {
- return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) );
+ return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) );
}
$connection = array(
@@ -206,16 +206,16 @@ public function get_config() {
}
if ( 401 === $result['status'] ) {
- return new WP_Error( 'citecue_invalid_key', __( 'CiteCue rejected the API key. Check it under CiteCue → Settings → API keys.', 'citecue' ) );
+ return new WP_Error( 'citecue_invalid_key', __( 'CiteCue rejected the API key. Check it under CiteCue → Settings → API keys.', 'citecue-ai-auto-fix' ) );
}
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'] ) );
+ return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $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 new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) );
}
return $data['projects'];
@@ -302,12 +302,12 @@ public function get_crawler_registry() {
}
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'] ) );
+ return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $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 new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) );
}
return $data;
diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php
index e2b0443..6c3c8ab 100644
--- a/includes/class-citecue-connect.php
+++ b/includes/class-citecue-connect.php
@@ -247,7 +247,7 @@ private function run_verification() {
false,
0,
'',
- __( 'This check asks for the site’s llms.txt, which is switched off below. Turn on “Serve llms.txt” to run it.', 'citecue' ),
+ __( 'This check asks for the site’s llms.txt, which is switched off below. Turn on “Serve llms.txt” to run it.', 'citecue-ai-auto-fix' ),
true
);
}
@@ -280,7 +280,7 @@ private function run_verification() {
if ( 200 !== $status ) {
/* translators: %d: HTTP status code. */
- return self::verdict( false, $status, $marker, sprintf( __( 'The site answered with HTTP %d.', 'citecue' ), $status ) );
+ return self::verdict( false, $status, $marker, sprintf( __( 'The site answered with HTTP %d.', 'citecue-ai-auto-fix' ), $status ) );
}
if ( '' === $marker ) {
@@ -288,7 +288,7 @@ private function run_verification() {
false,
$status,
$marker,
- __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue' )
+ __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue-ai-auto-fix' )
);
}
@@ -298,7 +298,7 @@ private function run_verification() {
$marker,
sprintf(
/* translators: 1: header value received, 2: header value expected. */
- __( 'Something other than the llms.txt handler answered — the response carried “x-citecue: %1$s” rather than “%2$s”. CiteCue most likely has no llms.txt for this project yet.', 'citecue' ),
+ __( 'Something other than the llms.txt handler answered — the response carried “x-citecue: %1$s” rather than “%2$s”. CiteCue most likely has no llms.txt for this project yet.', 'citecue-ai-auto-fix' ),
$marker,
self::VERIFY_MARKER
)
diff --git a/includes/class-citecue-ingest.php b/includes/class-citecue-ingest.php
index 70aa030..6d1d08f 100644
--- a/includes/class-citecue-ingest.php
+++ b/includes/class-citecue-ingest.php
@@ -125,27 +125,27 @@ 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 ) );
+ return new WP_Error( 'citecue_ingest_disabled', __( 'Content ingest is disabled in the CiteCue plugin settings.', 'citecue-ai-auto-fix' ), 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 ) );
+ return new WP_Error( 'citecue_no_secret', __( 'No ingest secret is configured.', 'citecue-ai-auto-fix' ), 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 ) );
+ return new WP_Error( 'citecue_stale_timestamp', __( 'Missing or expired X-Citecue-Timestamp header.', 'citecue-ai-auto-fix' ), 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 ) );
+ return new WP_Error( 'citecue_bad_signature', __( 'Missing or malformed X-Citecue-Signature header.', 'citecue-ai-auto-fix' ), 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 ) );
+ return new WP_Error( 'citecue_bad_signature', __( 'Invalid request signature.', 'citecue-ai-auto-fix' ), array( 'status' => 401 ) );
}
// Signatures are single-use: a captured request replayed within the
@@ -153,14 +153,14 @@ public function verify_request( WP_REST_Request $request ) {
// 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 ) );
+ return new WP_Error( 'citecue_replayed', __( 'This signature was already used; sign each request freshly.', 'citecue-ai-auto-fix' ), 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 new WP_Error( 'citecue_rate_limited', __( 'Too many ingest requests; try again later.', 'citecue-ai-auto-fix' ), array( 'status' => 429 ) );
}
return true;
@@ -195,7 +195,7 @@ private function within_rate_limit() {
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 ) );
+ return new WP_Error( 'citecue_bad_json', __( 'Request body must be JSON.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) );
}
$external_id = isset( $params['external_id'] ) ? $this->sanitize_external_id( $params['external_id'] ) : '';
@@ -203,7 +203,7 @@ public function handle_content( WP_REST_Request $request ) {
$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 ) );
+ return new WP_Error( 'citecue_missing_fields', __( 'external_id, title and content are required.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) );
}
$content = wp_kses_post( $content_raw );
@@ -213,7 +213,7 @@ public function handle_content( WP_REST_Request $request ) {
$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 ) );
+ return new WP_Error( 'citecue_woocommerce_missing', __( 'This payload targets a WooCommerce product, but WooCommerce is not active on this site.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) );
}
$allowed_types = array( 'post', 'page' );
if ( class_exists( 'WooCommerce' ) ) {
@@ -236,7 +236,7 @@ public function handle_content( WP_REST_Request $request ) {
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' ),
+ __( 'A product with this SKU already exists; send force=true to adopt and update it.', 'citecue-ai-auto-fix' ),
array(
'status' => 409,
'post_id' => $sku_match,
@@ -250,7 +250,7 @@ public function handle_content( WP_REST_Request $request ) {
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' ),
+ __( 'This external_id already exists with a different content type.', 'citecue-ai-auto-fix' ),
array(
'status' => 409,
'post_id' => $existing_id,
@@ -263,7 +263,7 @@ public function handle_content( WP_REST_Request $request ) {
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' ),
+ __( 'A post with this external_id was trashed in WordPress; restore or delete it permanently first.', 'citecue-ai-auto-fix' ),
array(
'status' => 410,
'post_id' => $existing_id,
@@ -274,7 +274,7 @@ public function handle_content( WP_REST_Request $request ) {
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' ),
+ __( 'This post was edited in WordPress after the last push; send force=true to overwrite.', 'citecue-ai-auto-fix' ),
array(
'status' => 409,
'post_id' => $existing_id,
@@ -444,13 +444,13 @@ private function resolve_author() {
*/
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 ) );
+ return new WP_Error( 'citecue_woocommerce_missing', __( 'WooCommerce is not active on this site.', 'citecue-ai-auto-fix' ), 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 ) );
+ return new WP_Error( 'citecue_product_load_failed', __( 'The existing product could not be loaded.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) );
}
$product->set_name( $title );
@@ -474,11 +474,11 @@ private function upsert_product( $existing_id, array $params, $title, $content,
// 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 ) );
+ return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) );
}
if ( $post_id <= 0 ) {
- return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue' ), array( 'status' => 500 ) );
+ return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) );
}
// WC's CRUD does not take an author; attribute newly created products
diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php
index 5f78449..1761897 100644
--- a/includes/class-citecue-plugin.php
+++ b/includes/class-citecue-plugin.php
@@ -107,7 +107,7 @@ private function __construct() {
* @return void
*/
public function on_init() {
- load_plugin_textdomain( 'citecue', false, dirname( plugin_basename( CITECUE_PLUGIN_FILE ) ) . '/languages' );
+ load_plugin_textdomain( 'citecue-ai-auto-fix', 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 );
@@ -118,9 +118,19 @@ public function on_init() {
* Daily sync: refresh the AI-crawler registry so new crawlers are served
* without a plugin update.
*
+ * Only for a site that has connected itself to CiteCue. Activating a
+ * plugin is not consent to talk to a third party, so an unconnected site
+ * must reach nothing on the network — and it loses nothing by staying
+ * quiet, because it is not serving crawlers either, and the bundled token
+ * list is what the registry would refresh.
+ *
* @return void
*/
public function daily_sync() {
+ if ( ! $this->settings->is_connected() ) {
+ return;
+ }
+
$this->crawlers->refresh( $this->api );
}
diff --git a/readme.txt b/readme.txt
index 4280601..08a0890 100644
--- a/readme.txt
+++ b/readme.txt
@@ -2,7 +2,7 @@
Contributors: citecue
Tags: ai, llms.txt, gptbot, ai-seo, woocommerce
Requires at least: 5.8
-Tested up to: 6.8
+Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPLv2 or later
@@ -19,17 +19,42 @@ CiteCue AI Auto-Fix connects your WordPress site to CiteCue:
* **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).
-Requires a CiteCue account.
+This plugin requires a CiteCue account (citecue.com) and does nothing until you connect one. See "External services" below for exactly what is sent where.
== Installation ==
-1. In WordPress, go to Plugins → Add New → Upload Plugin, choose `citecue.zip` and activate it.
+1. Install and activate the plugin from Plugins → Add New, or upload it under Plugins → Add New → Upload Plugin.
2. Go to Settings → CiteCue and click "Connect to CiteCue".
3. Confirm the project for this site in CiteCue. You are redirected back and the plugin checks itself.
4. Add and generate optimized pages on CiteCue's Auto-Fix page.
There is nothing to copy or paste: the connection brings the API key back to WordPress and hands CiteCue this site's address and content-push secret. Sites that cannot complete a browser round-trip to CiteCue can still connect with an organization API key — "Connect with an API key instead" on the settings screen.
+Until you complete step 2, the plugin makes no outbound requests at all.
+
+== External services ==
+
+This plugin is the WordPress end of CiteCue, a hosted service at https://citecue.com that generates AI-optimized versions of your pages. The optimized pages, your llms.txt and the pushed draft content are all produced by that service, so the plugin cannot work without it. Nothing below happens until an administrator connects the site.
+
+Terms of Service: https://citecue.com/terms
+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.
+
+**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.
+
+**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.
+
+**Verifying the installation** — when you connect, and whenever you click "Verify installation". The plugin requests your own site's `/llms.txt` over HTTP, identifying itself as an AI crawler, to confirm the plugin answers rather than a cache or CDN. This request goes to your site, not to CiteCue.
+
+Every outbound request identifies itself with a `CiteCue-WordPress/ (+)` User-Agent.
+
+In the other direction: when content pushes are enabled, CiteCue sends new content to this site's `citecue/v1` REST route. Each request is signed with the shared secret exchanged during connection, replayed signatures are rejected, and the content is created as a draft unless you raise that limit yourself.
+
== Frequently Asked Questions ==
= Do I need to create an API key by hand? =
@@ -75,3 +100,5 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never
* WooCommerce support: store-page exclusions for the middleware; product create/enrich through the ingest endpoint.
* Hardening: single-use ingest signatures (replay rejection), per-minute delivery lookup budget, CiteCue-compatible cache-key URL normalization, cache eviction on delivery misses, and crawler-registry downgrade rejection with the bundled token floor.
* One-click connect: a pairing handshake sets up the site without copying an API key in or a signing secret out, with a built-in "Verify installation" check. Connecting with an API key remains available as a fallback.
+* An install that has not been connected to CiteCue makes no outbound requests of any kind.
+* A second copy of the plugin, from a pre-directory release installed in another folder, stands down with an admin notice instead of taking the site down.
diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php
index 653dd3b..250d988 100644
--- a/tests/cases/test-lifecycle.php
+++ b/tests/cases/test-lifecycle.php
@@ -102,6 +102,7 @@ public function test_a_missing_cron_is_rescheduled_on_init() {
* @return void
*/
public function test_the_daily_sync_refreshes_the_crawler_registry() {
+ $this->configure_delivery();
$this->http->queue(
'crawlers',
200,
@@ -124,6 +125,7 @@ public function test_the_daily_sync_refreshes_the_crawler_registry() {
* @return void
*/
public function test_the_daily_sync_survives_an_outage() {
+ $this->configure_delivery();
$this->http->queue_error( 'crawlers' );
$this->plugin->daily_sync();
@@ -131,6 +133,75 @@ public function test_the_daily_sync_survives_an_outage() {
$this->assertSame( 'GPTBot', $this->plugin->crawlers->match( 'GPTBot/1.2' ) );
}
+ /**
+ * Activating a plugin is not consent to talk to a third party. Until the
+ * site has connected itself, the cron must reach nothing — the HTTP mock
+ * throws on any unqueued call, so an outbound request fails this test.
+ *
+ * @return void
+ */
+ public function test_the_daily_sync_is_silent_until_the_site_connects() {
+ $this->plugin->daily_sync();
+
+ $this->assertSame( 0, $this->http->count() );
+ $this->assertSame( Citecue_Crawlers::bundled_tokens(), $this->plugin->crawlers->get_tokens() );
+ }
+
+ /**
+ * A site that installed a pre-WordPress.org release has this plugin in a
+ * citecue/ directory, and the directory's copy installs alongside it
+ * rather than over it. Loading the second copy must be inert: without the
+ * guard it redeclares every class and the site fatals.
+ *
+ * The bootstrap has already loaded the plugin, so requiring the main file
+ * again puts us in exactly the state the second copy sees.
+ *
+ * @return void
+ */
+ public function test_a_second_copy_stands_down_with_a_notice() {
+ $this->assertTrue( defined( 'CITECUE_VERSION' ), 'The first copy should already be loaded.' );
+
+ $notice = $this->render_duplicate_notice_as( 'administrator' );
+
+ $this->assertStringContainsString( 'installed twice', $notice );
+ $this->assertStringContainsString( plugin_basename( CITECUE_PLUGIN_FILE ), $notice );
+ }
+
+ /**
+ * The notice names directories to delete, so it is only for someone who
+ * can act on it.
+ *
+ * @return void
+ */
+ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() {
+ $this->assertSame( '', $this->render_duplicate_notice_as( 'subscriber' ) );
+ }
+
+ /**
+ * Loads the main file a second time — which is the state the duplicate
+ * copy boots into — and renders what it hooked onto `admin_notices`.
+ *
+ * The hook is emptied first so that firing it runs only the guard's
+ * callback. Other plugins listen on `admin_notices` too, and with a real
+ * WooCommerce installed one of them reads `get_current_screen()`, which is
+ * null outside a genuine admin request. Isolating the hook keeps this a
+ * test of the guard rather than of whatever else happens to be active.
+ *
+ * @param string $role Role of the user viewing the admin screen.
+ * @return string Rendered notice markup.
+ */
+ private function render_duplicate_notice_as( $role ) {
+ remove_all_actions( 'admin_notices' );
+
+ require dirname( __DIR__, 2 ) . '/citecue.php';
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) );
+
+ ob_start();
+ do_action( 'admin_notices' );
+ return ob_get_clean();
+ }
+
/**
* Uninstall removes the plugin's own settings…
*
@@ -175,7 +246,7 @@ public function test_uninstall_keeps_pushed_content() {
*/
private function run_uninstall() {
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
- define( 'WP_UNINSTALL_PLUGIN', 'citecue/citecue.php' );
+ define( 'WP_UNINSTALL_PLUGIN', 'citecue-ai-auto-fix/citecue.php' );
}
require dirname( __DIR__, 2 ) . '/uninstall.php';