Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
static:
name: Lint & coding standards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
coverage: none
tools: composer

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Syntax check every file
run: composer run lint

- name: WordPress coding standards
run: composer run phpcs

test:
name: PHP ${{ matrix.php }} / WP ${{ matrix.wp }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
# The plugin header claims PHP 7.4+; both ends of that range are
# tested against current WordPress, plus two older WordPress versions.
#
# WordPress 5.9 rather than the declared 5.8 floor: WordPress's own
# test library only gained PHPUnit 9 support in 5.9, and 5.8 hard-fails
# with "compatible with PHPUnit up to 7.x". 5.8 support is therefore
# verified by the PHPCompatibility/manual checks, not by this suite.
php: ['7.4', '8.2', '8.4']
wp: ['latest']
include:
- php: '7.4'
wp: '5.9.*'
- php: '8.3'
wp: '6.5.*'

services:
mysql:
image: mariadb:10.11
env:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
MARIADB_DATABASE: wordpress_test
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=6

env:
WP_TESTS_DB_HOST: 127.0.0.1:3306
WP_TESTS_DB_NAME: wordpress_test
WP_TESTS_DB_USER: root
WP_TESTS_DB_PASSWORD: ''

steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mysqli
coverage: none
tools: composer

- name: Install dependencies
run: composer install --prefer-dist --no-progress

# WordPress core and the test library are separate packages that share
# WordPress's version numbers — they must be pinned together or the
# library boots against a core it does not match.
- name: Pin WordPress to ${{ matrix.wp }}
if: matrix.wp != 'latest'
run: >-
composer require --dev --update-with-dependencies --no-progress
"roots/wordpress-no-content:${{ matrix.wp }}"
"wp-phpunit/wp-phpunit:${{ matrix.wp }}"

- name: Run the test suite
run: composer run test:core

- name: Run the test suite with the WooCommerce store rules active
run: composer run test:woocommerce

test-woocommerce:
name: WooCommerce integration
runs-on: ubuntu-latest

services:
mysql:
image: mariadb:10.11
env:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
MARIADB_DATABASE: wordpress_test
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=6

env:
WP_TESTS_DB_HOST: 127.0.0.1:3306
WP_TESTS_DB_NAME: wordpress_test
WP_TESTS_DB_USER: root
WP_TESTS_DB_PASSWORD: ''
CITECUE_WITH_WOOCOMMERCE: '1'

steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mysqli
coverage: none
tools: composer

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Install WooCommerce
run: composer require --dev --no-progress wpackagist-plugin/woocommerce

- name: Run the product-push tests against a real WooCommerce
run: composer run test:core
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
.DS_Store
node_modules/
vendor/
composer.lock
.phpunit.result.cache
phpunit.xml
.phpcs.xml
95 changes: 95 additions & 0 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?xml version="1.0"?>
<ruleset name="CiteCue">
<description>WordPress coding standards for the CiteCue plugin.</description>

<file>.</file>
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>

<arg name="extensions" value="php"/>
<arg name="basepath" value="."/>
<arg name="colors"/>
<arg name="parallel" value="4"/>
<arg value="sp"/>

<rule ref="WordPress-Core"/>
<rule ref="WordPress-Docs"/>

<!-- The plugin ships as plain PHP with a 7.4 floor. -->
<config name="testVersion" value="7.4-"/>
<rule ref="PHPCompatibilityWP"/>

<config name="minimum_wp_version" value="5.8"/>

<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="citecue"/>
</property>
</properties>
</rule>

<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="citecue"/>
<element value="CITECUE"/>
</property>
</properties>
</rule>

<!--
DONOTCACHEPAGE is the cross-plugin convention page caches look for; the
point is that other plugins recognise it, so it cannot be prefixed.
-->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound">
<exclude-pattern>/includes/class-citecue-proxy\.php</exclude-pattern>
<exclude-pattern>/includes/class-citecue-llms-txt\.php</exclude-pattern>
</rule>

<!-- "llms.txt" is a filename and stays lowercase in prose. -->
<rule ref="Generic.Commenting.DocComment.ShortNotCapital">
<exclude-pattern>/includes/class-citecue-llms-txt\.php</exclude-pattern>
</rule>

<!-- The standard directory-listing guard file. -->
<rule ref="Squiz.Commenting.FileComment.WrongStyle">
<exclude-pattern>/includes/index\.php</exclude-pattern>
</rule>

<!--
Test files are not shipped: they legitimately reassign WordPress
globals, declare unprefixed test classes, and carry docblocks that hold
only PHPUnit annotations (@covers, @dataProvider).
-->
<rule ref="WordPress.WP.GlobalVariablesOverride">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<rule ref="WordPress.PHP.DevelopmentFunctions">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<rule ref="Generic.Commenting.DocComment.MissingShort">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<rule ref="Squiz.Commenting.FunctionComment.Missing">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<rule ref="WordPress.Files.FileName">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<!-- PHPUnit calls assertion helpers in camelCase. -->
<rule ref="WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid">
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
<!--
The sniff reads a docblock that directly precedes a require as
documentation for that statement rather than for the file. The bootstrap
has to require the autoloader first, so it can never satisfy both.
-->
<rule ref="Squiz.Commenting.FileComment.Missing">
<exclude-pattern>/tests/bootstrap\.php</exclude-pattern>
</rule>
</ruleset>
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor
- **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.
- **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately.
- **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update.
- **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.

Expand Down Expand Up @@ -123,11 +123,26 @@ With WooCommerce active:
| `citecue_matched_crawler` | filter | Override per-request crawler matching |
| `citecue_should_serve` | filter | Veto serving for a specific request |
| `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) |
| `citecue_lookup_budget` | filter | Max delivery API lookups per minute (default 120); beyond it, crawler requests pass through |
| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler and llms.txt paths combined (default 120); beyond it, requests are answered from cache or passed through |
| `citecue_ingest_postarr` | filter | Adjust the post array before insert/update |
| `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) |
| `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content |

## Performance

The delivery API is only ever called for an AI-crawler request or for `/llms.txt`. A human page view does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read).

For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download.

- **A persistent object cache is recommended.** Cached bodies are transients. With Redis or Memcached they never touch the database. Without one they are rows in `wp_options` — full HTML documents, one per crawled URL, for up to 24 h. They are not autoloaded, so they cost nothing per request, but a heavily crawled site can hold tens of megabytes there until WordPress's twice-daily transient cleanup runs.
- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups and llms.txt together, so no mix of traffic can exceed it.

## What happens if CiteCue is unavailable

Nothing that a visitor can see. The first failing request waits at most 3 s and then opens the circuit for 60 s (10 minutes if the API key was rejected). While it is open the plugin makes no API calls at all: it serves the cached body if it has one — marked `X-Citecue-Cache: stale` — and otherwise falls straight through to normal theme output. Because bodies are kept for 24 h, crawlers keep receiving optimized pages through a short outage.

The worst case for an AI crawler is one 3 s wait per minute. For a human visitor there is no worst case: the API is never consulted on their behalf.

## 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.
Expand All @@ -137,4 +152,46 @@ With WooCommerce active:

## Development

Plain PHP ≥ 7.4, no build step. Repo root is the plugin root. `php -l` every file; WordPress coding standards style.
Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout can be symlinked straight into `wp-content/plugins/`.

### Tests

The suite is WordPress integration tests: real options, transients, REST requests and query conditionals, with the CiteCue API faked at the `wp_remote_get` layer (`tests/includes/class-citecue-http-mock.php`) so no test ever touches the network. WordPress core and its test library both come from Composer — there is nothing to download by hand.

```bash
composer install
mysqladmin create wordpress_test -uroot # any empty database will do
composer test
```

Point it at a different database with `WP_TESTS_DB_NAME`, `WP_TESTS_DB_USER`, `WP_TESTS_DB_PASSWORD`, `WP_TESTS_DB_HOST` (see `tests/wp-tests-config.php`).

`composer test` runs the suite twice, because whether WooCommerce exists is a process-wide fact rather than something a single test can toggle:

| Command | Covers |
|---|---|
| `composer test:core` | Everything, with no WooCommerce present |
| `composer test:woocommerce` | Adds a minimal WooCommerce stand-in so the store-page exclusion rules are exercised |
| `CITECUE_WITH_WOOCOMMERCE=1 composer test:core` | Product pushes against a real WooCommerce — needs `composer require --dev wpackagist-plugin/woocommerce` |

Other commands:

```bash
composer lint # php -l over every file
composer phpcs # WordPress coding standards + PHP 7.4 compatibility
composer phpcbf # auto-fix what phpcs can
```

CI runs the static checks plus the suite on PHP 7.4/8.2/8.4 against current WordPress, on PHP 7.4/8.3 against WordPress 5.9/6.5, and a separate job against a real WooCommerce. WordPress 5.9 is the oldest version the automated suite can cover — WordPress's own test library only supports PHPUnit 9 from 5.9 onwards — so the declared 5.8 floor rests on the PHPCompatibility checks and manual verification.

### Testing an install by hand

```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
curl -s https://your-site.com/wp-json/citecue/v1/health # plugin/version/delivery/ingest/woocommerce
```

### Structure notes

`Citecue_Proxy` and `Citecue_Llms_Txt` each split into a `decide()` that returns what should happen and a `serve()` that emits headers and calls `exit`. All the branching lives in `decide()`, which is what the tests drive; `serve()` stays deliberately trivial because nothing can assert against a request that has already ended.
52 changes: 52 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "citecue/wordpress-plugin",
"description": "CiteCue AI Auto-Fix — WordPress plugin",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"yoast/phpunit-polyfills": "^2.0",
"squizlabs/php_codesniffer": "^3.9",
"wp-coding-standards/wpcs": "^3.1",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"wp-phpunit/wp-phpunit": "^7.0",
"roots/wordpress-no-content": "^6.8",
"composer/installers": "^2.2"
},
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org",
"only": [
"wpackagist-plugin/*"
]
}
],
"extra": {
"installer-paths": {
"vendor/wpackagist-plugin/{$name}/": [
"type:wordpress-plugin"
]
}
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"composer/installers": true
}
},
"scripts": {
"lint": "find citecue.php uninstall.php includes tests -name '*.php' -print0 | xargs -0 -n1 -P4 php -l > /dev/null",
"phpcs": "phpcs",
"phpcbf": "phpcbf",
"test": [
"@test:core",
"@test:woocommerce"
],
"test:core": "phpunit",
"test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit"
}
}
Loading
Loading