diff --git a/.gitattributes b/.gitattributes index 5b7fbba..be62f13 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,8 @@ # zip. `bin/build-plugin-zip.sh` builds with `git archive`, which honours these, # so this file is the single list of what is developer-only. /.gitattributes export-ignore +/.wordpress-org export-ignore +/assets-src export-ignore /.github export-ignore /.gitignore export-ignore /.phpcs.xml.dist export-ignore diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 8206064..f52cc34 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -65,12 +65,25 @@ Test files are not shipped: they legitimately reassign WordPress globals, declare unprefixed test classes, and carry docblocks that hold only PHPUnit annotations (@covers, @dataProvider). + + The local rig (`bin/rig/*`, driven by `bin/local-rig.sh`) is the same + category and then some. It is not shipped — `bin/` is export-ignored and + the zip guard refuses an archive containing it — and it is not plugin + code: these are standalone scripts that install a throwaway WordPress, + seed it and assert against rendered pages. Two of them do not run inside + WordPress at all. Prefixing a local `$url` in a test harness with + `citecue_` would be ceremony that makes the harness harder to read + without making anything safer. Everything the sniffs flag there that IS + worth fixing — missing docblocks, short ternaries — is fixed rather than + excused; only these four categories are waived. --> /tests/* + /bin/rig/* /tests/* + /bin/rig/* /tests/* @@ -83,6 +96,7 @@ /tests/* + /bin/rig/* diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 0000000..a661f6e Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 0000000..e356247 Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 0000000..c84446e Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png new file mode 100644 index 0000000..930d3c8 Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ diff --git a/.wordpress-org/icon.svg b/.wordpress-org/icon.svg new file mode 100644 index 0000000..392da32 --- /dev/null +++ b/.wordpress-org/icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/assets-src/banner.html b/assets-src/banner.html new file mode 100644 index 0000000..a85383b --- /dev/null +++ b/assets-src/banner.html @@ -0,0 +1,103 @@ + + + + +
+ +
+ + + + + + + + CiteCue. +
+ +
AI CRAWLERS · LLMS.TXT · METADATA
+ +
Answer AI crawlerswith your best page.
+ + +
+ GPTBot + ClaudeBot + PerplexityBot + GoogleOther +
+ +
+ + + + + + + +
diff --git a/assets-src/build-assets.sh b/assets-src/build-assets.sh new file mode 100755 index 0000000..474ca65 --- /dev/null +++ b/assets-src/build-assets.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Regenerates the WordPress.org directory assets in .wordpress-org/. +# +# These are the banner and icon the plugin directory shows above the readme. +# They are NOT part of the plugin: WordPress.org serves them from the `assets/` +# folder of the SVN repository, which sits beside `trunk/` and `tags/` and is +# never installed on a site. Both directories are export-ignored so they can +# never reach the distributed zip. +# +# Rendering is done by headless Chrome rather than a dedicated rasterizer +# because it is the one thing reliably present on a Mac that can lay out real +# webfont text, and --window-size with --force-device-scale-factor gives exact +# pixel dimensions. The 2x pass is a genuine re-render, not an upscale. +# +# Fonts come from the citecue2 site repository rather than being vendored +# here: they are the same faces the brand uses everywhere, and a second copy +# in a second repo is a second thing to update when they change. +# +# Usage: assets-src/build-assets.sh +# CITECUE_FONT_DIR override the font source directory +# CHROME override the Chrome binary + +set -euo pipefail + +ROOT=$(git rev-parse --show-toplevel) +SRC="$ROOT/assets-src" +OUT="$ROOT/.wordpress-org" + +FONT_DIR=${CITECUE_FONT_DIR:-"$HOME/Sites/citecue2/public/fonts"} +CHROME=${CHROME:-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"} + +if [ ! -x "$CHROME" ]; then + echo "error: Chrome not found at $CHROME (set CHROME=)" >&2 + exit 1 +fi + +for face in schibsted-grotesk schibsted-grotesk-italic instrument-sans jetbrains-mono; do + if [ ! -f "$FONT_DIR/$face.woff2" ]; then + echo "error: $FONT_DIR/$face.woff2 not found (set CITECUE_FONT_DIR=)" >&2 + exit 1 + fi +done + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Inline the faces. A relative @font-face would be a separate fetch that +# --screenshot does not wait for, and the banner would silently render in a +# fallback with different metrics. +python3 - "$SRC/banner.html" "$FONT_DIR" "$WORK/banner.html" <<'PY' +import base64, pathlib, sys + +template, font_dir, out = (pathlib.Path(p) for p in sys.argv[1:4]) +html = template.read_text() + +for token, face in ( + ("__SG__", "schibsted-grotesk"), + ("__SGI__", "schibsted-grotesk-italic"), + ("__IS__", "instrument-sans"), + ("__JB__", "jetbrains-mono"), +): + if token not in html: + sys.exit(f"error: {token} missing from {template}") + html = html.replace(token, base64.b64encode((font_dir / f"{face}.woff2").read_bytes()).decode()) + +out.write_text(html) +PY + +shot() { # shot + "$CHROME" --headless --disable-gpu --no-sandbox --hide-scrollbars \ + --force-device-scale-factor="$2" --window-size=772,250 \ + --screenshot="$1" "file://$WORK/banner.html" >/dev/null 2>&1 +} + +mkdir -p "$OUT" +shot "$OUT/banner-772x250.png" 1 +shot "$OUT/banner-1544x500.png" 2 + +# The icon is already vector; Chrome just rasterizes it at the two sizes the +# directory asks for. +cat > "$WORK/icon.html" < + + +HTML +"$CHROME" --headless --disable-gpu --no-sandbox --hide-scrollbars \ + --force-device-scale-factor=1 --window-size=256,256 \ + --screenshot="$OUT/icon-256x256.png" "file://$WORK/icon.html" >/dev/null 2>&1 +sips -Z 128 --out "$OUT/icon-128x128.png" "$OUT/icon-256x256.png" >/dev/null + +# A banner at the wrong size is rejected by the directory, and the failure is +# a silently missing header rather than an error, so the sizes are asserted. +check() { # check + local w h + w=$(sips -g pixelWidth "$1" | awk '/pixelWidth/ {print $2}') + h=$(sips -g pixelHeight "$1" | awk '/pixelHeight/ {print $2}') + if [ "$w" != "$2" ] || [ "$h" != "$3" ]; then + echo "error: $(basename "$1") is ${w}x${h}, expected $2x$3" >&2 + exit 1 + fi + echo " $(basename "$1") ${w}x${h}" +} + +echo "Built $OUT" +check "$OUT/banner-772x250.png" 772 250 +check "$OUT/banner-1544x500.png" 1544 500 +check "$OUT/icon-256x256.png" 256 256 +check "$OUT/icon-128x128.png" 128 128 diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 337d761..16003ee 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -78,7 +78,7 @@ if [ -n "$stray_root" ]; then exit 1 fi -stray_dev=$(printf '%s\n' "$entries" | grep -E "^$SLUG/(tests/|bin/|docs/|\.github/|composer\.|phpunit|\.phpcs|README\.md)" || true) +stray_dev=$(printf '%s\n' "$entries" | grep -E "^$SLUG/(tests/|bin/|docs/|assets-src/|\.wordpress-org/|\.github/|composer\.|phpunit|\.phpcs|README\.md)" || true) if [ -n "$stray_dev" ]; then echo "error: development files leaked into the archive:" >&2 printf '%s\n' "$stray_dev" >&2 diff --git a/bin/local-rig.sh b/bin/local-rig.sh new file mode 100755 index 0000000..43fff64 --- /dev/null +++ b/bin/local-rig.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Stands up a real WordPress with this plugin active, pointed at a stub CiteCue, +# and asserts that a page enhancement actually lands on a rendered page. +# +# The PHPUnit suite calls the injector directly or through a synthetic buffer. +# That covers the decisions but not the thing the decisions are for: whether the +# block reaches a browser, through a real theme, a real `wp_head()`/`wp_footer()` +# and whichever output-buffer mechanism this WordPress provides. On 6.9+ that is +# core's template enhancement filter, which no unit test exercises end to end. +# +# The stub stands in for app.citecue.com because the real acceptance test needs +# a deployed server, a CiteCue account and an approved block. This one needs +# none of those and can run on any branch, at any time. +# +# Usage: +# bin/local-rig.sh up build the rig and leave it running +# bin/local-rig.sh verify build it, assert, tear down, exit non-zero on failure +# bin/local-rig.sh down stop the servers +# +# RIG_DIR where to build it (default: a temp directory) +# RIG_DB_HOST MySQL/MariaDB host:port (default: 127.0.0.1:13306) +# RIG_DB_USER database user (default: root) +# +# To point at a REAL CiteCue instead of the stub — a local build, a staging +# origin, or app.citecue.com for the post-deploy acceptance test — set all three: +# +# RIG_API_BASE e.g. http://127.0.0.1:8787 +# RIG_API_KEY the org API key (ck_live_...) +# RIG_PUBLIC_KEY the project's public key (pk_...) +# +# The stub is then not started, and the checks that assert on what the plugin +# SENT are skipped — they read the stub's own request log, which a real server +# does not keep for us. + +set -euo pipefail + +ROOT=$(git rev-parse --show-toplevel) +RIG_DIR=${RIG_DIR:-"${TMPDIR:-/tmp}/citecue-rig"} +DB_HOST=${RIG_DB_HOST:-127.0.0.1:13306} +DB_USER=${RIG_DB_USER:-root} +API_PORT=13380 +WP_PORT=13390 + +REAL_API=${RIG_API_BASE:-} +API_KEY=${RIG_API_KEY:-ck_live_rig} +PUBLIC_KEY=${RIG_PUBLIC_KEY:-pk_rig} +API_BASE="http://127.0.0.1:$API_PORT" +if [ -n "$REAL_API" ]; then + API_BASE="$REAL_API" +fi + +WP_SRC="$ROOT/vendor/roots/wordpress-no-content" +CORE_VERSION_FILE="$WP_SRC/wp-includes/version.php" + +down() { + for port in $API_PORT $WP_PORT; do + pids=$(lsof -ti tcp:"$port" 2>/dev/null || true) + [ -n "$pids" ] && kill $pids 2>/dev/null || true + done + echo "rig stopped" +} + +if [ "${1:-up}" = "down" ]; then + down + exit 0 +fi + +if [ ! -f "$CORE_VERSION_FILE" ]; then + echo "error: WordPress core not found at $WP_SRC" >&2 + echo " run 'composer install' first — the rig borrows the core the tests use." >&2 + exit 1 +fi + +if ! mysqladmin --protocol=TCP --host="${DB_HOST%%:*}" --port="${DB_HOST##*:}" -u "$DB_USER" ping >/dev/null 2>&1; then + echo "error: no database reachable at $DB_HOST as '$DB_USER' (set RIG_DB_HOST / RIG_DB_USER)" >&2 + exit 1 +fi + +down >/dev/null 2>&1 || true +rm -rf "$RIG_DIR" +mkdir -p "$RIG_DIR/fake-api" + +mysql --protocol=TCP --host="${DB_HOST%%:*}" --port="${DB_HOST##*:}" -u "$DB_USER" \ + -e "DROP DATABASE IF EXISTS citecue_rig; CREATE DATABASE citecue_rig;" + +cp -R "$WP_SRC" "$RIG_DIR/wp" +mkdir -p "$RIG_DIR/wp/wp-content/themes/citecue-rig" "$RIG_DIR/wp/wp-content/plugins" +ln -sfn "$ROOT" "$RIG_DIR/wp/wp-content/plugins/citecue-ai-auto-fix" + +cp "$ROOT/bin/rig/theme-index.php" "$RIG_DIR/wp/wp-content/themes/citecue-rig/index.php" +printf '/*\nTheme Name: CiteCue Rig\nVersion: 1.0\n*/\n' > "$RIG_DIR/wp/wp-content/themes/citecue-rig/style.css" +cp "$ROOT/bin/rig/fake-citecue.php" "$RIG_DIR/fake-api/router.php" + +sed -e "s|__DB_HOST__|$DB_HOST|" -e "s|__DB_USER__|$DB_USER|" \ + -e "s|__API_BASE__|$API_BASE|" -e "s|__WP_PORT__|$WP_PORT|" \ + "$ROOT/bin/rig/wp-config.php.tpl" > "$RIG_DIR/wp/wp-config.php" + +if [ -z "$REAL_API" ]; then + ( cd "$RIG_DIR/fake-api" && nohup php -S "127.0.0.1:$API_PORT" router.php "$RIG_DIR/fake-api.log" 2>&1 & ) +fi +( cd "$RIG_DIR/wp" && nohup php -S "127.0.0.1:$WP_PORT" "$RIG_DIR/wp.log" 2>&1 & ) + +if [ -z "$REAL_API" ]; then + for _ in $(seq 1 20); do + curl -sf "$API_BASE/api/delivery/v1/crawlers" >/dev/null 2>&1 && break + sleep 0.5 + done +fi + +RIG_DIR="$RIG_DIR" RIG_API_KEY="$API_KEY" RIG_PUBLIC_KEY="$PUBLIC_KEY" php "$ROOT/bin/rig/seed.php" + +echo +echo "WordPress : http://127.0.0.1:$WP_PORT/protein-guide/" +if [ -n "$REAL_API" ]; then + echo "CiteCue : $API_BASE (real server)" +else + echo "Stub API : $API_BASE (requests logged to $RIG_DIR/fake-api/requests.log)" +fi +echo "Rig : $RIG_DIR" + +if [ "${1:-up}" = "verify" ]; then + echo + status=0 + RIG_DIR="$RIG_DIR" WP_PORT="$WP_PORT" RIG_API_BASE="$REAL_API" php "$ROOT/bin/rig/verify.php" || status=$? + down >/dev/null 2>&1 + exit $status +fi diff --git a/bin/rig/fake-citecue.php b/bin/rig/fake-citecue.php new file mode 100644 index 0000000..89149bb --- /dev/null +++ b/bin/rig/fake-citecue.php @@ -0,0 +1,122 @@ + $_SERVER['REQUEST_URI'], + 'method' => $_SERVER['REQUEST_METHOD'], + 'authorization' => $headers['Authorization'] ?? ( $headers['authorization'] ?? '' ), + 'channel' => $headers['X-Citecue-Channel'] ?? ( $headers['x-citecue-channel'] ?? '' ), + ) + ) . "\n", + FILE_APPEND +); + +/** + * JSON for the request log. Named for what it is: WordPress is not loaded + * here, so wp_json_encode() does not exist. + * + * @param mixed $value Value to encode. + * @return string + */ +function citecue_rig_json( $value ) { + return (string) json_encode( $value, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- WordPress is not loaded in this process. +} + +/** + * The block, in the shape CiteCue composes them: a marked section of grounded + * facts and FAQ entries, carrying its own FAQPage JSON-LD. The script element + * is the part any "sanitize the remote HTML" reflex would destroy, so it is + * here on purpose. + */ +function citecue_block() { + $faq = json_encode( + array( + '@context' => 'https://schema.org', + '@type' => 'FAQPage', + 'mainEntity' => array( + array( + '@type' => 'Question', + 'name' => 'What does Acme make?', + 'acceptedAnswer' => array( + '@type' => 'Answer', + 'text' => 'Protein bars, made in Leeds.', + ), + ), + ), + ), + JSON_UNESCAPED_SLASHES + ); + + return '
' + . '

About Acme

' + . '
  • Founded 2019
  • Made in Leeds
' + . '
What does Acme make?

Protein bars, made in Leeds.

' + . '' + . '
'; +} + +header( 'Cache-Control: private, max-age=300' ); + +if ( '/api/delivery/v2/seo-head' === $path ) { + header( 'Content-Type: application/json; charset=utf-8' ); + echo json_encode( + array( + 'head' => '', + 'body' => citecue_block(), + 'dedupe' => array( + 'ogProperties' => array( 'og:title' ), + 'jsonLdTypes' => array( 'FAQPage' ), + ), + ), + JSON_UNESCAPED_SLASHES + ); + exit; +} + +if ( '/api/delivery/v2/config' === $path ) { + header( 'Content-Type: application/json; charset=utf-8' ); + echo json_encode( + array( + 'projects' => array( + array( + 'publicKey' => 'pk_localtest', + 'domain' => '127.0.0.1', + 'enabled' => true, + 'serveLlmsTxt' => true, + 'contentPush' => true, + ), + ), + ) + ); + exit; +} + +if ( '/api/delivery/v1/crawlers' === $path ) { + header( 'Content-Type: application/json; charset=utf-8' ); + echo json_encode( + array( + 'version' => 1, + 'tokens' => array( 'GPTBot', 'ClaudeBot' ), + ) + ); + exit; +} + +http_response_code( 404 ); +echo 'not_optimized'; diff --git a/bin/rig/seed.php b/bin/rig/seed.php new file mode 100644 index 0000000..0b3edc5 --- /dev/null +++ b/bin/rig/seed.php @@ -0,0 +1,60 @@ +settings->update( + array( + 'api_key' => '' !== (string) getenv( 'RIG_API_KEY' ) ? (string) getenv( 'RIG_API_KEY' ) : 'ck_live_rig', + 'public_key' => '' !== (string) getenv( 'RIG_PUBLIC_KEY' ) ? (string) getenv( 'RIG_PUBLIC_KEY' ) : 'pk_rig', + 'project_domain' => '127.0.0.1', + 'seo_head_enabled' => true, + 'seo_head_reported' => true, + 'capabilities_reported' => $plugin->settings->active_delivery_capabilities(), + ) +); + +if ( ! get_page_by_path( 'protein-guide' ) ) { + wp_insert_post( + array( + 'post_title' => 'Protein guide', + 'post_name' => 'protein-guide', + 'post_content' => '

Everything we know about protein bars, from sourcing to storage.

', + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); +} + +flush_rewrite_rules( true ); + +printf( "WordPress %s, theme citecue-rig, plugin %s\n", get_bloginfo( 'version' ), CITECUE_VERSION ); +printf( "declared capabilities: %s\n", implode( ', ', $plugin->settings->active_delivery_capabilities() ) ); diff --git a/bin/rig/theme-index.php b/bin/rig/theme-index.php new file mode 100644 index 0000000..893c3fd --- /dev/null +++ b/bin/rig/theme-index.php @@ -0,0 +1,28 @@ + + +> + + + + +> +
+' . esc_html( get_the_title() ) . ''; + the_content(); +} +?> +
+ + + diff --git a/bin/rig/verify.php b/bin/rig/verify.php new file mode 100644 index 0000000..a41a9e0 --- /dev/null +++ b/bin/rig/verify.php @@ -0,0 +1,188 @@ + true, + CURLOPT_TIMEOUT => 15, + ) + ); + $html = curl_exec( $ch ); + curl_close( $ch ); + return (string) $html; +} + +/** + * Runs the plugin's due background jobs; the rig disables real WP-Cron. + * + * The cache drop is load-bearing. This process bootstrapped WordPress before + * the cold page view scheduled anything, so its options cache still holds the + * cron array from before — and reading that would find no jobs and silently + * verify nothing. The page view happened in the server's process, not this one. + */ +function run_jobs() { + wp_cache_delete( 'cron', 'options' ); + wp_cache_delete( 'alloptions', 'options' ); + + foreach ( (array) _get_cron_array() as $ts => $hooks ) { + foreach ( $hooks as $hook => $events ) { + if ( 0 !== strpos( $hook, 'citecue' ) ) { + continue; + } + foreach ( $events as $event ) { + do_action_ref_array( $hook, (array) $event['args'] ); + wp_unschedule_event( $ts, $hook, (array) $event['args'] ); + } + } + } +} + +echo "Verifying against $url\n"; + +// A visitor never waits on CiteCue: the first view of an uncached URL injects +// nothing and queues the fetch instead. +$cold = render( $url ); +check( 'cold view injects nothing', false === strpos( $cold, 'data-citecue="page-enhancement"' ) ); + +run_jobs(); + +$warm = render( $url ); +$sections = preg_match_all( '#]*data-citecue="page-enhancement"#i', $warm ); +check( 'warm view injects the block exactly once', 1 === $sections, "found $sections" ); + +$head_end = stripos( $warm, '' ); +$body_end = strripos( $warm, '' ); +$at = stripos( $warm, '
', $at > $head_end && $at < $body_end ); +// Conditional on what the server actually sent. The two halves are +// independent, and a project with a block but no enriched page is a legitimate +// answer — `head: ''` with a populated `body` is exactly what the endpoint +// returns then. Asserting a head unconditionally would fail the plugin for the +// server's correct behaviour. +$cached = Citecue_Plugin::instance()->cache->get_seo_head( $url ); +$sent_head = is_array( $cached ) ? (string) $cached['block'] : ''; +if ( '' === $sent_head ) { + echo " SKIP head half (this fixture's project has no enriched page, so the server sent head: '')\n"; +} else { + check( 'head half injected too', false !== strpos( $warm, 'og:title' ) ); +} + +// The FAQ payload is the GEO point of the block, and the first casualty of any +// reflex to sanitize the response. +preg_match( '##s', $warm, $m ); +$ld = isset( $m[1] ) ? json_decode( $m[1], true ) : null; +check( 'FAQPage JSON-LD survives intact', is_array( $ld ) && 'FAQPage' === ( $ld['@type'] ?? '' ) ); + +// Repeat views must not accumulate copies. +$again = preg_match_all( '#]*data-citecue="page-enhancement"#i', render( $url ) ); +check( 'a second view does not add a second copy', 1 === $again, "found $again" ); + +// A page that already carries the section keeps exactly the one it has. +$page = get_page_by_path( 'protein-guide' ); +$kept = $page->post_content; +wp_update_post( + array( + 'ID' => $page->ID, + 'post_content' => '

Ours.

Already here

', + ) +); +$owned = render( $url ); +check( + 'a page that already has the block is left alone', + 1 === preg_match_all( '#]*data-citecue="page-enhancement"#i', $owned ) + && false !== strpos( $owned, 'Already here' ) + && false === strpos( $owned, 'About Acme' ), + // Guards against passing vacuously: this only means anything while the + // block is cached and would otherwise have been placed. + 'cached block present: ' . ( false !== strpos( $warm, 'About Acme' ) ? 'yes' : 'NO — check is vacuous' ) +); + +// A page that merely QUOTES the marker has not been given a block, and must +// still get one. +wp_update_post( + array( + 'ID' => $page->ID, + 'post_content' => '

Add <section data-citecue="page-enhancement"> to your template.

', + ) +); +$quoted = render( $url ); +check( + 'a page quoting the marker still gets its block', + 1 === preg_match_all( '#]*data-citecue="page-enhancement"#i', $quoted ) + && false !== strpos( $quoted, 'About Acme' ) +); + +wp_update_post( + array( + 'ID' => $page->ID, + 'post_content' => $kept, + ) +); + +// The request the plugin actually made, not just what it did with the answer. +// Only the stub keeps a log for us, so these are skipped rather than failed +// when the rig is pointed at a real CiteCue. +if ( getenv( 'RIG_API_BASE' ) ) { + echo " SKIP request-log checks (real CiteCue keeps no log for us)\n"; +} else { + $log = @file_get_contents( $rig . '/fake-api/requests.log' ); + check( 'delivery read carried the org key as a Bearer token', false !== strpos( (string) $log, 'Bearer ck_live_rig' ) ); + // Built with strtolower() rather than written as a literal. The channel is a + // protocol value the plugin sends lowercased, and phpcbf's prose sniff + // rewrites a bare "WordPress" literal into "WordPress" — which silently + // turns this into an assertion that fails against correct behaviour. + $expected_channel = strtolower( 'WordPress' ); + $sent_channel = ''; + foreach ( explode( "\n", trim( (string) $log ) ) as $line ) { + $entry = json_decode( $line, true ); + if ( is_array( $entry ) && false !== strpos( (string) ( $entry['path'] ?? '' ), '/seo-head' ) ) { + $sent_channel = (string) ( $entry['channel'] ?? '' ); + } + } + check( 'delivery read identified the channel', $expected_channel === $sent_channel, "sent '$sent_channel'" ); +} + +echo "\n"; +if ( $failures ) { + printf( "%d check(s) failed: %s\n", count( $failures ), implode( '; ', $failures ) ); + exit( 1 ); +} +echo "all checks passed\n"; diff --git a/bin/rig/wp-config.php.tpl b/bin/rig/wp-config.php.tpl new file mode 100644 index 0000000..ad2f183 --- /dev/null +++ b/bin/rig/wp-config.php.tpl @@ -0,0 +1,34 @@ + /dev/null", + "lint": "find citecue.php uninstall.php includes tests bin -name '*.php' -print0 | xargs -0 -n1 -P4 php -l > /dev/null", "phpcs": "phpcs", "phpcbf": "phpcbf", "test": [ diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..4d64023 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,63 @@ +# Releasing to WordPress.org + +The directory serves the plugin from Subversion, not from this repository. +`https://plugins.svn.wordpress.org/citecue-ai-auto-fix` has three folders and +they mean different things: + +| Folder | Contents | Installed on a site? | +|---|---|---| +| `trunk/` | the current source | no — the directory installs from a tag | +| `tags//` | one frozen copy per release | yes, the one named by `Stable tag` | +| `assets/` | banner, icon, screenshots | no — directory page art only | + +`Stable tag:` in `readme.txt` is what decides which tag is served. Trunk is +not it; a release that updates trunk and forgets the tag serves the old +version, and one that tags without moving `Stable tag` serves nothing new. + +## Before you start + +`bin/build-plugin-zip.sh ` refuses to build when the three version +strings disagree (`citecue.php` header, `CITECUE_VERSION`, `readme.txt` +`Stable tag`), so run it first — it is the cheapest check that a release is +coherent, and it prints exactly what will ship. + +## Publishing a version + +```bash +# 1. A working copy. Empty folders, so this is fast. +svn checkout https://plugins.svn.wordpress.org/citecue-ai-auto-fix /tmp/citecue-svn + +# 2. Trunk, from the tag being released — never from the working tree. +# `git archive` honours .gitattributes export-ignore, so tests, CI config +# and Composer files cannot be swept in by accident. +rm -rf /tmp/citecue-svn/trunk/* +git archive --format=tar v1.2.0 | tar -x -C /tmp/citecue-svn/trunk + +# 3. Directory page art. Only when it has changed. +cp .wordpress-org/* /tmp/citecue-svn/assets/ + +# 4. Stage, then freeze a tag from trunk. +cd /tmp/citecue-svn +svn add --force trunk assets +svn copy trunk tags/1.2.0 + +# 5. One commit for both. Prompts for the wordpress.org password. +svn commit -m "Release 1.2.0" --username citecue +``` + +Commit trunk and the tag together. A trunk-only commit points `Stable tag` at +a tag that does not exist yet, and the directory serves an error to everyone +updating in the window between the two. + +`svn` is not installed on macOS by default: `brew install subversion`. + +## Regenerating the directory art + +`assets-src/build-assets.sh` rebuilds `.wordpress-org/` from +`assets-src/banner.html` and `.wordpress-org/icon.svg`. It needs headless +Chrome and the brand fonts from the citecue2 site repository — see the +script's header for the two overrides. + +Both folders are `export-ignore`d, and `bin/build-plugin-zip.sh` fails if +either ever appears inside the archive: they belong beside the plugin in SVN, +never inside it. diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index 78e0986..222206c 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -241,10 +241,22 @@ private function seo_head_reconnect_notice() { return; } - $enabled = (bool) $this->plugin->settings->get( 'seo_head_enabled' ); - $message = $enabled - ? __( 'this site can now add CiteCue’s enriched title, description, OpenGraph and structured data to your live pages, but CiteCue does not know that yet — until you reconnect, it will keep reporting that your fixes do not reach human visitors.', 'citecue-ai-auto-fix' ) - : __( 'enriched page metadata is switched off here, but CiteCue still expects this site to add it. Reconnect so CiteCue stops reporting metadata it is not getting.', 'citecue-ai-auto-fix' ); + switch ( $this->plugin->settings->seo_head_reconnect_reason() ) { + case 'disabled': + $message = __( 'enriched page metadata is switched off here, but CiteCue still expects this site to add it. Reconnect so CiteCue stops reporting metadata it is not getting.', 'citecue-ai-auto-fix' ); + break; + + case 'capabilities': + // The upgrade case, and the common one: the metadata setting has + // not moved, so saying anything about metadata would send an + // administrator looking for a fault that is not there. + $message = __( 'this version can place CiteCue page enhancements — the facts-and-FAQ sections you approve — on your pages, but CiteCue does not know that yet. Until you reconnect, it will hold on to every enhancement you approve instead of sending it here.', 'citecue-ai-auto-fix' ); + break; + + default: + $message = __( 'this site can now add CiteCue’s enriched title, description, OpenGraph and structured data to your live pages, but CiteCue does not know that yet — until you reconnect, it will keep reporting that your fixes do not reach human visitors.', 'citecue-ai-auto-fix' ); + break; + } ?>

@@ -450,16 +462,29 @@ public function handle_test_connection() { if ( ! is_array( $project ) || empty( $project['publicKey'] ) ) { continue; } - $clean[] = array( + $entry = 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'] ), ); + + // Copied across only when it was actually sent. Defaulting it to + // false would make a CiteCue that predates the field look like one + // withdrawing consent, and switch pushes off on every site at once. + if ( array_key_exists( 'contentPush', $project ) ) { + $entry['contentPush'] = ! empty( $project['contentPush'] ); + } + + $clean[] = $entry; } update_option( 'citecue_projects_cache', $clean, false ); update_option( 'citecue_last_config_at', time(), false ); + // The same reconcile the daily sync runs, off the list already in hand + // rather than a second request. + $this->plugin->connect->reconcile_content_push( $clean ); + // Auto-select by host when no project is chosen yet. $settings = $this->plugin->settings; if ( '' === (string) $settings->get( 'public_key' ) ) { @@ -654,6 +679,7 @@ private function render_connected() {

+

+ get( 'content_push_revoked_at' ); ?> + 0 ) : ?> +

+ +

+ @@ -880,7 +918,7 @@ private function render_status_card() { get( 'seo_head_enabled' ) ) : ?> - needs_seo_head_reconnect() ) : ?> + seo_head_reconnect_reason(), array( 'enabled', 'disabled' ), true ) ) : ?> @@ -888,6 +926,19 @@ private function render_status_card() { + + + + get( 'seo_head_enabled' ) ) : ?> + + seo_head_reconnect_reason() ) : ?> + + + + + + + diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php index 24a3f43..c1db545 100644 --- a/includes/class-citecue-api-client.php +++ b/includes/class-citecue-api-client.php @@ -262,7 +262,13 @@ public function get_page( $url, $crawler_token, $etag = '' ) { } /** - * GET /api/delivery/v2/seo-head — the enriched head block for one URL. + * GET /api/delivery/v2/seo-head — the enriched head block for one URL, and + * the page-enhancement block that belongs in its body. + * + * The two halves are independent. `body` arrives only for a connection that + * declared the `body_blocks` capability, and either half may be empty while + * the other is not — the endpoint answers 204 only when BOTH are, so a 200 + * with an empty `head` is a page whose block is all there is to inject. * * CiteCue has three distinct empty answers here and they are not * interchangeable, so they are passed up untouched rather than collapsed: @@ -272,7 +278,7 @@ public function get_page( $url, $crawler_token, $etag = '' ) { * uses, and 401 is a rejected key. A 204 carries no body at all. * * @param string $url Absolute URL of the page being rendered. - * @return array|WP_Error {status:int, head:string} + * @return array|WP_Error {status:int, head:string, body:string} */ public function get_seo_head( $url ) { $endpoint = add_query_arg( @@ -289,16 +295,26 @@ public function get_seo_head( $url ) { } $head = ''; + $body = ''; if ( 200 === $result['status'] ) { $data = json_decode( $result['body'], true ); - if ( is_array( $data ) && isset( $data['head'] ) && is_string( $data['head'] ) ) { - $head = $data['head']; + if ( is_array( $data ) ) { + if ( isset( $data['head'] ) && is_string( $data['head'] ) ) { + $head = $data['head']; + } + // Read independently of `head`: the endpoint returns 200 when + // EITHER half is non-empty, so a page with a body block and no + // enriched head of its own is a normal answer, not a partial one. + if ( isset( $data['body'] ) && is_string( $data['body'] ) ) { + $body = $data['body']; + } } } return array( 'status' => $result['status'], 'head' => $head, + 'body' => $body, ); } diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index 26dc9de..e059659 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -255,28 +255,47 @@ private function seo_head_key( $url ) { } /** - * Cached SEO head block for a URL, or null. + * Cached SEO head block and page-enhancement block for a URL, or null. + * + * `body` is defaulted rather than required, because an entry written by an + * earlier version of the plugin carries only `block` — and those entries + * outlive the upgrade by up to BODY_TTL. Treating a missing key as "no + * block" keeps them readable instead of discarding a day of warm cache on + * every site the moment it updates. * * @param string $url Absolute page URL. - * @return array{block:string,cached_at:int}|null + * @return array{block:string,body:string,cached_at:int}|null */ public function get_seo_head( $url ) { $hit = get_transient( $this->seo_head_key( $url ) ); - return ( is_array( $hit ) && isset( $hit['block'] ) ) ? $hit : null; + if ( ! is_array( $hit ) || ! isset( $hit['block'] ) ) { + return null; + } + if ( ! isset( $hit['body'] ) ) { + $hit['body'] = ''; + } + return $hit; } /** - * Stores a URL's SEO head block. + * Stores a URL's SEO head block and page-enhancement block. + * + * Both halves share one entry because they arrive in one response and are + * evicted by the same events: keying them apart would let a page keep a + * block from before its audience was switched off, which is the exact + * failure delete_seo_head() exists to prevent. * * @param string $url Absolute page URL. * @param string $block Head markup. + * @param string $body Page-enhancement markup for before ``. * @return void */ - public function set_seo_head( $url, $block ) { + public function set_seo_head( $url, $block, $body = '' ) { set_transient( $this->seo_head_key( $url ), array( 'block' => (string) $block, + 'body' => (string) $body, 'cached_at' => time(), ), self::BODY_TTL diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php index a0af591..ebf6b62 100644 --- a/includes/class-citecue-connect.php +++ b/includes/class-citecue-connect.php @@ -143,22 +143,30 @@ public function verify_state( $state ) { public function claim( $code ) { $settings = $this->plugin->settings; - // The capability, not the ambition: CiteCue uses this to decide whether + // The capability, not the ambition: CiteCue uses these to decide whether // `seoAudience: 'all'` is a promise this channel can keep, and a site // that has injection switched off keeps it no better than a plugin that // cannot inject at all. Reporting the live setting is what stops the // app badging a fix "Live" over a head nothing writes to. - $seo_head = (bool) $settings->get( 'seo_head_enabled' ); + // + // The set is built by Citecue_Settings::declared_capabilities(), which + // the reconnect prompt reads too — see needs_seo_head_reconnect(). Every + // name in it gates something CiteCue would otherwise not send, and + // absence always reads as "cannot", so an old plugin is never handed + // markup it would not place. + $seo_head = (bool) $settings->get( 'seo_head_enabled' ); + $capabilities = $settings->declared_capabilities(); $result = $this->plugin->api->claim_connect_code( $code, - array( - 'site_url' => home_url( '/' ), - 'rest_url' => rest_url( 'citecue/v1/' ), - 'ingest_secret' => $settings->ensure_ingest_secret(), - 'plugin_version' => CITECUE_VERSION, - 'woocommerce' => class_exists( 'WooCommerce' ), - 'seo_head' => $seo_head, + array_merge( + array( + 'site_url' => home_url( '/' ), + 'rest_url' => rest_url( 'citecue/v1/' ), + 'ingest_secret' => $settings->ensure_ingest_secret(), + 'plugin_version' => CITECUE_VERSION, + ), + $capabilities ) ); @@ -167,21 +175,43 @@ public function claim( $code ) { } $update = array( - 'api_key' => $result['apiKey'], - 'public_key' => $result['publicKey'], - 'project_domain' => $result['domain'], + 'api_key' => $result['apiKey'], + 'public_key' => $result['publicKey'], + 'project_domain' => $result['domain'], // Only after the exchange succeeded: a failed claim wrote nothing // on CiteCue's side, so recording it here would silence the // reconnect prompt for a capability the app never learned about. - 'seo_head_reported' => $seo_head, + 'seo_head_reported' => $seo_head, + // Same rule, for the set as a whole. Recorded from the map that was + // actually sent, so the two can never drift apart here. + 'capabilities_reported' => $settings->active_delivery_capabilities(), ); // CiteCue's connect screen is where the customer is told that content // can be pushed into their site, so it is the only place that may turn - // ingest on. A response that omits the flag leaves the opt-in exactly - // as it was — silence never grants write access. - if ( isset( $result['ingest'] ) ) { - $update['ingest_enabled'] = (bool) $result['ingest']; + // ingest on. Silence still never grants write access — an absent flag + // cannot make this true. + // + // But absence HERE also revokes, which it does nowhere else. This is + // the plugin's own claim: it sent an ingest secret and asked, and the + // wire contract omits the flag entirely rather than sending false, so + // on this one response absence is a definite "the customer did not tick + // the box" rather than "not mentioned". Leaving the switch as it was + // would keep a site reading "Accepted" after a reconnect that withdrew + // consent and cleared the secret CiteCue signs with. + $had_ingest = (bool) $settings->get( 'ingest_enabled' ); + $has_ingest = ! empty( $result['ingest'] ); + $update['ingest_enabled'] = $has_ingest; + + // Written here rather than left to sanitize(), which only runs where + // register_setting() has — not on every path that reaches this. + if ( $has_ingest ) { + $update['content_push_revoked_at'] = 0; + } elseif ( $had_ingest ) { + // Only on a real withdrawal. A first connection that never had + // pushes on has nothing to explain, and stamping one would put a + // message about a change on a screen where nothing changed. + $update['content_push_revoked_at'] = time(); } $settings->update( $update ); @@ -212,15 +242,18 @@ public function disconnect() { // flag — but sanitize() only runs once register_setting() has, // and the empty value is what a write that bypasses the filter // has to see. Either path must end up with no key. - 'api_key' => '', - 'api_key_clear' => 1, - 'public_key' => '', - 'project_domain' => '', - 'ingest_enabled' => false, + 'api_key' => '', + 'api_key_clear' => 1, + 'public_key' => '', + 'project_domain' => '', + 'ingest_enabled' => false, // Back to "never reported": the next connection mints a new key // with its own capabilities, and what the old one recorded says // nothing about it. - 'seo_head_reported' => null, + 'seo_head_reported' => null, + 'capabilities_reported' => null, + // No connection, nothing to explain about its push channel. + 'content_push_revoked_at' => 0, ) ); @@ -230,6 +263,110 @@ public function disconnect() { $this->plugin->cache->flush(); } + /** + * Fetches the delivery config and reconciles content-push consent from it. + * + * Runs on the daily sync. Consent can be withdrawn at CiteCue — by + * reconnecting with the box unticked, which clears the stored signing + * secret — and nothing tells this site when it happens, so without a + * periodic read the switch here would keep reading "Accepted" over a + * channel that can no longer deliver anything. + * + * Costs a request only on a site that actually has pushes switched on. A + * site with the default (off) has nothing to reconcile and spends nothing, + * which is most of them. + * + * @return bool Whether the switch was turned off. + */ + public function refresh_content_push() { + if ( ! $this->plugin->settings->get( 'ingest_enabled' ) ) { + return false; + } + + $projects = $this->plugin->api->get_config(); + if ( is_wp_error( $projects ) ) { + return false; + } + + return $this->reconcile_content_push( $projects ); + } + + /** + * Turns the local "accept pushed content" switch off when CiteCue no longer + * holds a secret it could sign a push with. + * + * **Revoke only.** A `contentPush` of true never turns the switch ON, and + * the tempting argument for letting it is worth answering here, because + * CiteCue only gained a real consent gate at the same time as this field: + * since a secret is now stored only where the customer ticked the box, + * `true` really does imply they consented once. It is still not a grant. It + * reports that CiteCue holds a signable secret at this instant — a fact + * about CiteCue's storage — whereas this switch is the administrator's + * standing decision about their own site, and they may have closed it here + * on purpose afterwards. Re-opening it from a remote read would hand write + * access back to a site whose owner had refused it. A remote grant belongs + * on the connect screen, where the customer is actually told what they are + * agreeing to. This only ever closes. + * + * **Absent is not false.** The key is read with array_key_exists() rather + * than a truthiness test. This guard will look redundant against the + * current API, and it is not: `contentPush` is a required boolean on every + * project entry, so any CiteCue that has the field always sends it, and a + * reader checking the schema will conclude the branch is dead. What it + * defends is the deployment that does NOT have the field — a rollback, a + * staging origin on an older build, a self-hosted app behind on releases. + * Against one of those a truthiness test reads every project as an explicit + * withdrawal and switches content pushes off on every site at once, which + * is the only way this reconcile could do real damage. Only a `contentPush` + * that is present and false revokes; anything else leaves the switch alone. + * + * Anything ambiguous is also left alone — a transport failure (handled by + * the caller), a project list this site's own key is not in. Each is a + * reason to know nothing, and acting on nothing would revoke a working + * connection over a bad afternoon on the network. + * + * @param array $projects Project list as returned by GET /config. + * @return bool Whether the switch was turned off. + */ + public function reconcile_content_push( $projects ) { + $settings = $this->plugin->settings; + + if ( ! $settings->get( 'ingest_enabled' ) || ! is_array( $projects ) ) { + return false; + } + + $public_key = (string) $settings->get( 'public_key' ); + if ( '' === $public_key ) { + return false; + } + + foreach ( $projects as $project ) { + if ( ! is_array( $project ) || ! isset( $project['publicKey'] ) ) { + continue; + } + if ( $public_key !== (string) $project['publicKey'] ) { + continue; + } + if ( ! array_key_exists( 'contentPush', $project ) || ! empty( $project['contentPush'] ) ) { + return false; + } + + // Both in one write: the timestamp exists to explain the switch + // beside it, and sanitize() clears it whenever pushes are on, so a + // stale explanation can never outlive the state it explains. + $settings->update( + array( + 'ingest_enabled' => false, + 'content_push_revoked_at' => time(), + ) + ); + + return true; + } + + return false; + } + /** * Runs the check the README used to ask customers to run by hand: request * this site's own llms.txt as an AI crawler and look for the marker header diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index f660d3b..2da5451 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -193,6 +193,11 @@ public function daily_sync() { } $this->crawlers->refresh( $this->api ); + + // Consent can be withdrawn at CiteCue, and nothing tells this site when + // it happens. Costs a request only where pushes are actually switched + // on, which the default is not. + $this->connect->refresh_content_push(); } /** diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php index 40a1f0e..a1f4158 100644 --- a/includes/class-citecue-seo-head.php +++ b/includes/class-citecue-seo-head.php @@ -1,13 +1,18 @@ `. That block is the only markup this class puts where a reader can + * see it, and it arrives already sanitized and size-capped (see + * self::inject_body()). CiteCue only serves a block for `enriched` pages in `all` * audience mode, which is what keeps the two apart: enriched markup is * content-parity and additive, so adding it is not cloaking, whereas serving a * rewritten document to a human would be. @@ -84,6 +89,44 @@ class Citecue_Seo_Head { */ const CAPTURE_PRIORITY = PHP_INT_MAX; + /** + * The attribute pair CiteCue marks a page-enhancement section with, matched + * loosely enough to recognise the section however it was quoted. + * + * This is a "somebody already placed it" check, not a parse: on a site whose + * origin is fronted by CiteCue's Worker, or whose post content already + * carries the section, the block is in the document before this plugin sees + * it — and a second copy is the one failure mode a visitor actually notices. + * + * Scoped to a real opening tag, and to the whole attribute value, for the + * reason this class already learned once about the head (see enhance()): a + * page that quotes markup in a code sample contains whatever it quotes. A + * bare substring search reads `<section data-citecue="page-enhancement">` + * in a documentation page as a block already placed, and silently withholds + * that page's enhancement forever. Requiring `]#i'; + + /** + * Largest page-enhancement block that may be injected, in bytes. Mirrors + * the delivery API's own MAX_BLOCK_BYTES, which already refuses to send a + * larger one. + * + * Enforcing it again here is not distrust of the endpoint: a composed block + * is a handful of facts and FAQ entries and cannot legitimately approach + * 32 KB, so a block that does is a generation bug — and this is the + * difference between that bug costing one page and it costing every page + * the cache has warmed. + */ + const MAX_BLOCK_BYTES = 32768; + /** * `` relations that may be injected, and `` values are escaped * into a tag we build ourselves. Anything else in the block is dropped: @@ -216,22 +259,26 @@ public function finish_capture( $output, $phase ) { } /** - * The rendered document with CiteCue's tags added to its head — the one - * place the injection happens, shared by both mechanisms above. + * The rendered document with CiteCue's markup added to it — the one place + * the injection happens, shared by both mechanisms above. + * + * Two halves land here, in two places, under two different rules: the head + * tags immediately before ``, and the page-enhancement block + * immediately before the last ``. * * Everything before `` is what the slot check reads, and the tags go - * immediately before it. Scoping both to the head is not tidiness: an + * immediately before it. Scoping the check to the head is not tidiness: an * inline SVG in the body carries a ``, `<meta itemprop>` is legal in * body content, and a page that quotes markup in a code sample contains * whatever it quotes — so a document-wide scan would read slots as occupied * that no browser or crawler ever reads as page metadata, and CiteCue would * silently stop filling them. * - * A response with no `</head>` is returned exactly as it arrived: a JSON or - * CSV export served from a page URL, a fragment, a document another plugin - * replaced wholesale. There is no head to fill gaps in, and guessing where - * one would have gone is how a plugin corrupts a response it did not - * understand. + * A response missing either close is returned unchanged at that half: a + * JSON or CSV export served from a page URL, a fragment, a document another + * plugin replaced wholesale. There is nothing to fill gaps in, and guessing + * where the close would have gone is how a plugin corrupts a response it + * did not understand. * * @param string $html Rendered document. * @return string @@ -249,12 +296,34 @@ public function enhance( $html ) { return $html; } + // Independent halves, deliberately. A document can have a head and no + // body close (a fragment), or a body and a head another plugin already + // filled, and neither is a reason to withhold the other — they answer + // to different rules and land in different places. + $html = self::inject_head( $html, $decision['block'] ); + $html = self::inject_body( $html, $decision['body'] ); + + return $html; + } + + /** + * The document with CiteCue's gap-filling tags spliced into its head. + * + * @param string $html Rendered document. + * @param string $block CiteCue head block. + * @return string + */ + private static function inject_head( $html, $block ) { + if ( '' === $block ) { + return $html; + } + if ( ! preg_match( '#</head\s*>#i', $html, $match, PREG_OFFSET_CAPTURE ) ) { return $html; } $at = (int) $match[0][1]; - $tags = self::merge( substr( $html, 0, $at ), $decision['block'] ); + $tags = self::merge( substr( $html, 0, $at ), $block ); if ( ! $tags ) { return $html; @@ -268,12 +337,63 @@ public function enhance( $html ) { . substr( $html, $at ); } + /** + * The document with CiteCue's page-enhancement block spliced in before its + * closing `</body>`. + * + * The block is passed through as it arrived, and that is the contract + * rather than an oversight: unlike the head tags — which are rebuilt from + * parsed values because they are assembled from many small elements whose + * shapes must be constrained — the body block is a single section + * sanitized at composition on CiteCue's side and capped on the wire. + * Re-escaping it would print its markup as visible text; running it through + * `wpautop` or the content filters would let a shortcode inside a quoted + * FAQ answer execute. So it is spliced verbatim, and the trust is placed + * where it can be reasoned about: the size cap and the marker check below, + * plus the fact that it only ever arrives over an authenticated request to + * the site's own configured project. + * + * The LAST `</body>` is the document's own. A page that quotes markup in a + * code sample contains an earlier one, and splicing at that would put the + * section inside the sample. + * + * @param string $html Rendered document. + * @param string $body CiteCue page-enhancement block. + * @return string + */ + private static function inject_body( $html, $body ) { + if ( '' === $body ) { + return $html; + } + + // strlen() is bytes, which is what the cap is stated in — the block is + // UTF-8 and a character count would under-count it by up to 4x. + if ( strlen( $body ) > self::MAX_BLOCK_BYTES ) { + return $html; + } + + // Already placed — by the Worker in front of this site, or in the post + // content itself. Injecting now would show the customer two. + if ( preg_match( self::BLOCK_MARKER_PATTERN, $html ) ) { + return $html; + } + + if ( ! preg_match_all( '#</body\s*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { + return $html; + } + + $last = end( $matches[0] ); + $at = (int) $last[1]; + + return substr( $html, 0, $at ) . $body . "\n" . substr( $html, $at ); + } + /** * Whether this request should be injected into, and with what — reading the * cache only, never the network. The testable counterpart of the capture, * mirroring the decide()/serve() split in Citecue_Proxy. * - * @return array{inject:bool,block:string,reason:string} + * @return array{inject:bool,block:string,body:string,reason:string} */ public function decide() { $settings = $this->plugin->settings; @@ -290,7 +410,11 @@ public function decide() { } /** - * Filters whether to inject CiteCue's SEO head into this page. + * Filters whether to inject CiteCue's markup into this page. + * + * Governs BOTH halves — the head tags and the page-enhancement block — + * because both arrive in the one response this decision fetches, and a + * page somebody has excluded is excluded from all of it. * * @param bool $should_inject Default true. * @param string $url URL the block is looked up by. @@ -303,7 +427,7 @@ public function decide() { $cached = $cache->get_seo_head( $url ); if ( $cached && $cache->is_fresh( $cached, self::FRESH_SECONDS ) ) { - return self::block( $cached['block'], 'cached' ); + return self::block( $cached['block'], $cached['body'], 'cached' ); } // CiteCue recently said it has nothing for this URL. Unlike the crawler @@ -318,7 +442,7 @@ public function decide() { // Stale-while-revalidate: a day-old block is still this page's own // metadata, and withholding it while the refresh runs would blank the // tags on every page during a CiteCue outage. - return $cached ? self::block( $cached['block'], 'stale' ) : self::skip( 'no-cache' ); + return $cached ? self::block( $cached['block'], $cached['body'], 'stale' ) : self::skip( 'no-cache' ); } /** @@ -356,15 +480,20 @@ public function refresh( $url ) { switch ( $response['status'] ) { case 200: - if ( '' === $response['head'] ) { - // A 200 carrying no block is a payload we do not understand. - // Treat it as "nothing to inject" rather than caching an - // empty string that would read as a valid block. + // Either half alone is a complete answer. The endpoint sends + // 204 when it has neither, so a 200 with an empty `head` is a + // page whose enhancement block is all there is to inject — + // testing `head` alone here would throw that block away and + // cache a miss over it. + if ( '' === $response['head'] && '' === $response['body'] ) { + // A 200 carrying nothing at all is a payload we do not + // understand. Treat it as "nothing to inject" rather than + // caching empty strings that would read as a valid block. $cache->delete_seo_head( $url ); $cache->set_seo_head_miss( $url ); return 'empty'; } - $cache->set_seo_head( $url, $response['head'] ); + $cache->set_seo_head( $url, $response['head'], $response['body'] ); return 'fresh'; case 204: @@ -818,6 +947,7 @@ private static function skip( $reason ) { return array( 'inject' => false, 'block' => '', + 'body' => '', 'reason' => $reason, ); } @@ -825,14 +955,23 @@ private static function skip( $reason ) { /** * An "inject this block" decision. * + * `inject` is what arranges the capture, so it asks whether there is + * anything at all to place — either half alone is reason enough to buffer, + * and neither means the page streams untouched. + * * @param string $block CiteCue head block. + * @param string $body CiteCue page-enhancement block. * @param string $reason Where the block came from (diagnostic only). - * @return array{inject:bool,block:string,reason:string} + * @return array{inject:bool,block:string,body:string,reason:string} */ - private static function block( $block, $reason ) { + private static function block( $block, $body, $reason ) { + $block = (string) $block; + $body = (string) $body; + return array( - 'inject' => true, - 'block' => (string) $block, + 'inject' => ( '' !== $block || '' !== $body ), + 'block' => $block, + 'body' => $body, 'reason' => $reason, ); } diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php index 1061071..44ce4bd 100644 --- a/includes/class-citecue-settings.php +++ b/includes/class-citecue-settings.php @@ -37,26 +37,37 @@ class Citecue_Settings { public static function defaults() { return array( // Connection. - 'api_base' => self::DEFAULT_API_BASE, - 'api_key' => '', - 'public_key' => '', - 'project_domain' => '', + 'api_base' => self::DEFAULT_API_BASE, + 'api_key' => '', + 'public_key' => '', + 'project_domain' => '', // Delivery. - 'serve_enabled' => true, - 'llms_txt_enabled' => true, - 'seo_head_enabled' => true, + 'serve_enabled' => true, + 'llms_txt_enabled' => true, + 'seo_head_enabled' => true, // The value of seo_head_enabled last reported to CiteCue, or null // if this site has never reported one. CiteCue records the // capability on the API key at connect time and has no other way to // learn it, so this is how the settings screen knows to ask for a // reconnect — see needs_seo_head_reconnect(). - 'seo_head_reported' => null, + 'seo_head_reported' => null, + // The capability names last reported to CiteCue, or null if this + // site has never reported any. Kept alongside seo_head_reported + // rather than replacing it: that one tracks a SETTING the customer + // can toggle, this one tracks what this BUILD of the plugin is able + // to do, and only one of them changes when the plugin updates. + 'capabilities_reported' => null, // Content ingest (CiteCue -> WordPress post creation). - 'ingest_enabled' => false, - 'ingest_secret' => '', - 'ingest_post_status' => 'draft', - 'ingest_post_type' => 'post', - 'ingest_author' => 0, + 'ingest_enabled' => false, + // When CiteCue was last found to hold no signing secret for this + // site while pushes were still switched on here, so the settings + // screen can say why the toggle moved. 0 when that has not + // happened, or since been resolved. + 'content_push_revoked_at' => 0, + 'ingest_secret' => '', + 'ingest_post_status' => 'draft', + 'ingest_post_type' => 'post', + 'ingest_author' => 0, ); } @@ -208,14 +219,119 @@ public function is_delivery_configured() { * @return bool */ public function needs_seo_head_reconnect() { + return '' !== $this->seo_head_reconnect_reason(); + } + + /** + * WHY a reconnect is wanted, or '' for a connection that agrees with + * CiteCue — so the prompt can say what has actually gone stale. + * + * The three answers ask for the same reconnect but are not the same + * message, and telling a customer the wrong one is worse than saying + * nothing: a site whose metadata setting has not moved, being told its + * metadata is not reaching CiteCue, will go looking for a fault that is not + * there. + * + * - `enabled` — this site injects metadata and CiteCue thinks it cannot. + * - `disabled` — the reverse: it has been switched off since connecting. + * - `capabilities` — the metadata setting agrees, but this build can do + * something the connection never announced. What every + * site upgrading into a new capability reports. + * + * Consent is deliberately NOT among them. Whether the customer allows + * content pushes gates the ingest endpoint and nothing on this path — page + * enhancements arrive through the same authenticated `/seo-head` read as + * the metadata — so prompting for it here would be asking an administrator + * to grant write access to their site for a feature that does not use it. + * "Content from CiteCue" reports that state, on its own, where it belongs. + * + * @return string One of 'enabled', 'disabled', 'capabilities', or ''. + */ + public function seo_head_reconnect_reason() { if ( ! $this->is_connected() ) { - return false; + return ''; } $reported = $this->get( 'seo_head_reported' ); $known = null === $reported ? false : (bool) $reported; + $enabled = (bool) $this->get( 'seo_head_enabled' ); + + if ( $enabled !== $known ) { + return $enabled ? 'enabled' : 'disabled'; + } + + $declared = $this->get( 'capabilities_reported' ); + $active = $this->active_delivery_capabilities(); + + // Never reported a set at all: an install connected before this plugin + // declared capabilities by name. It is under-claiming everything it can + // now do, so it needs a reconnect the moment there is anything to + // claim — and needs no reconnect when there is not. + if ( ! is_array( $declared ) ) { + return array() === $active ? '' : 'capabilities'; + } + + return $active === $declared ? '' : 'capabilities'; + } + + /** + * The capability flags sent to CiteCue on the connect claim. + * + * One definition, read by the claim that reports them and by the drift + * check above that notices when they have gone stale. Two lists would + * disagree eventually, and the failure would be silent in the worst + * direction: a capability announced and then never re-announced reads to + * CiteCue as one the plugin still has. + * + * The three delivery capabilities all ride `seo_head_enabled`, because they + * all arrive in the one `/seo-head` response that setting governs — a site + * with injection switched off fetches nothing, so it can place neither a + * head tag nor a body block, and claiming otherwise is exactly the + * over-claim the capability exists to prevent. + * + * `seo_head_baseline` is safe for this plugin to ask for: it prints no + * `Organization` node of its own anywhere, and the head merge drops + * CiteCue's JSON-LD outright when the page already carries any, so the + * site-wide identity block can never become a second competing one. + * + * @return array<string,bool> + */ + public function declared_capabilities() { + $seo_head = (bool) $this->get( 'seo_head_enabled' ); + + return array( + 'woocommerce' => class_exists( 'WooCommerce' ), + 'seo_head' => $seo_head, + 'body_blocks' => $seo_head, + 'seo_head_baseline' => $seo_head, + ); + } + + /** + * The names of the declared capabilities that gate what CiteCue SENDS, + * sorted, for comparison against what was last reported. + * + * `woocommerce` is deliberately not among them. It gates nothing on the + * delivery path — CiteCue records it for reporting only — so a customer who + * installs WooCommerce after connecting has stale information on their key, + * not a broken feature, and prompting them to reconnect over it would be + * nagging about nothing. + * + * @return string[] + */ + public function active_delivery_capabilities() { + $declared = $this->declared_capabilities(); + $names = array(); + + foreach ( array( 'seo_head', 'body_blocks', 'seo_head_baseline' ) as $name ) { + if ( ! empty( $declared[ $name ] ) ) { + $names[] = $name; + } + } - return (bool) $this->get( 'seo_head_enabled' ) !== $known; + sort( $names ); + + return $names; } /** @@ -276,6 +392,15 @@ public function sanitize( $input ) { $out['llms_txt_enabled'] = ! empty( $input['llms_txt_enabled'] ); $out['seo_head_enabled'] = ! empty( $input['seo_head_enabled'] ); $out['ingest_enabled'] = ! empty( $input['ingest_enabled'] ); + // Not a form field: written only by the consent reconcile, and cleared + // the moment pushes are switched back on, so a stale explanation can + // never outlive the state it explains. + if ( array_key_exists( 'content_push_revoked_at', $input ) ) { + $out['content_push_revoked_at'] = (int) $input['content_push_revoked_at']; + } + if ( ! empty( $out['ingest_enabled'] ) ) { + $out['content_push_revoked_at'] = 0; + } 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']; @@ -310,6 +435,19 @@ public function sanitize( $input ) { if ( array_key_exists( 'seo_head_reported', $input ) ) { $out['seo_head_reported'] = null === $input['seo_head_reported'] ? null : (bool) $input['seo_head_reported']; } + // Same tri-state, and normalized to a sorted list of names so that the + // comparison in needs_seo_head_reconnect() cannot be tripped by key + // order alone. + if ( array_key_exists( 'capabilities_reported', $input ) ) { + $reported = $input['capabilities_reported']; + if ( null === $reported ) { + $out['capabilities_reported'] = null; + } else { + $names = array_values( array_filter( array_map( 'strval', (array) $reported ), 'strlen' ) ); + sort( $names ); + $out['capabilities_reported'] = $names; + } + } // A changed key may fix a previous auth failure; let serving retry now. if ( $out['api_key'] !== $current['api_key'] ) { diff --git a/readme.txt b/readme.txt index ecce623..9d9f9a7 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: ai, ai-crawlers, gptbot, ai-seo, woocommerce Requires at least: 5.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.1.2 +Stable tag: 1.2.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -16,6 +16,7 @@ CiteCue AI Auto-Fix is the WordPress end of CiteCue. It decides, per request, wh * **Per-request delivery to AI crawlers** — when GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User or any other agent in the crawler registry requests a page, the plugin returns the CiteCue-optimized version of that URL. Human visitors always see your normal site, and optimized responses are never cached for regular traffic. Any miss, timeout or outage passes straight through to the normal page. * **Gap-filling page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: it reads what your theme, WordPress and your SEO plugin actually printed into `<head>` and adds only what none of them emitted, so there is never a second title or canonical. +* **Page enhancements** — where you have approved one in CiteCue, adds a short facts-and-FAQ section to the end of a page you already have, built from your own answered facts and grounded FAQ entries rather than generated. It is placed only if the page does not already carry one, and it is the only thing this plugin adds that your visitors can see. * **llms.txt** — serves the llms.txt file CiteCue maintains for your brand at your site root, refreshed from CiteCue rather than regenerated here. * **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). @@ -118,6 +119,9 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never == Upgrade Notice == += 1.2.0 = +Adds page enhancements: a facts-and-FAQ section CiteCue composes for pages you already have, placed on the live page. Existing connections need one reconnect before CiteCue will send them — Settings → CiteCue will ask. + = 1.1.2 = Changes how page metadata is added to the response: no output buffer is left open, and WordPress 6.9's own template output buffer is used where there is one. Nothing to reconfigure. @@ -132,6 +136,15 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin == Changelog == += 1.2.0 = +* New: page enhancements. CiteCue composes a collapsed facts-and-FAQ section for a page you already have — built from your answered facts and grounded FAQ entries, never generated by a model — and this release places it on that page, immediately before `</body>`. +* The block is placed exactly as CiteCue composed it. It is sanitized before it is sent and capped at 32 KB on the wire, and this plugin checks the cap again before placing it; nothing is re-escaped or run through the content filters, either of which would break the markup or run a shortcode quoted inside an answer. +* Never placed twice. A page that already carries the `data-citecue="page-enhancement"` marker — because CiteCue's Worker fronts the site, or because the section is already in the post — is left exactly as it is. +* The block rides the `/seo-head` response the plugin already fetches for metadata, so an enhanced page costs no extra request, and the same rules apply: cache-only on the render path, so a visitor never waits on CiteCue, and nothing at all is fetched on a site with metadata injection switched off. +* Existing connections need one reconnect. CiteCue records what a plugin can do when it connects and withholds anything it never announced, so a site connected before this release is sent no blocks until it reconnects — Settings → CiteCue now says so, where before it only noticed a changed metadata setting. +* "Accept pushed content" now corrects itself. If CiteCue no longer holds a signing secret for this site — because you reconnected without allowing content pushes — the switch here turns off and says so, instead of reporting a channel that cannot deliver anything. It is never switched back on remotely: that stays your decision, on this screen. +* The plugin also now asks for CiteCue's site-wide identity metadata on pages that have none of their own. It is gap-filling like everything else in the head: if your theme or SEO plugin already prints structured data, CiteCue's is dropped rather than added beside it. + = 1.1.2 = * The metadata layer no longer holds an output buffer of its own open across a request. On WordPress 6.9 and later it uses core's template enhancement output buffer, so the plugin opens no buffer at all; below that it opens one in the form PHP finalizes by itself, and closes nothing. The previous shape opened a buffer on one hook and closed it on another, which left one open on any page whose `wp_head` did not run to the end — and a buffer left open is one the next plugin's `ob_get_clean()` can take by mistake. * The tags now go immediately before `</head>` rather than at the end of `wp_head`, and the check for what is already there reads the head only. Markup in the body — a `<title>` inside an inline SVG, a `<meta>` quoted in page content — no longer counts as a slot somebody else has filled, so pages carrying either get their metadata again. diff --git a/tests/cases/test-connect.php b/tests/cases/test-connect.php index f8dad68..50785db 100644 --- a/tests/cases/test-connect.php +++ b/tests/cases/test-connect.php @@ -219,6 +219,404 @@ public function test_a_failed_claim_reports_no_capability() { $this->assertNull( $this->plugin->settings->get( 'seo_head_reported' ) ); } + /** + * The block half of the delivery channel is gated on `body_blocks`, and + * CiteCue reads its absence as "cannot place a block" — so a plugin that + * injects one and never says so is sent `body: ''` forever. + * + * Declared together with the injection that honours it, deliberately: the + * capability is what makes CiteCue start sending real block markup, so a + * release that announced it without placing it would put a block on a + * customer's live page that nothing renders. + * + * @return void + */ + public function test_a_claim_declares_the_body_block_capability() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $sent = json_decode( $this->http->last( 'connect' )['args']['body'], true ); + $this->assertTrue( $sent['body_blocks'] ); + $this->assertTrue( $sent['seo_head_baseline'] ); + } + + /** + * All three delivery capabilities ride the one setting that governs the + * response they arrive in: a site that fetches nothing can place nothing, + * and claiming otherwise is the over-claim the capability exists to stop. + * + * @return void + */ + public function test_switching_injection_off_withdraws_every_delivery_capability() { + $this->plugin->settings->update( array( 'seo_head_enabled' => false ) ); + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $sent = json_decode( $this->http->last( 'connect' )['args']['body'], true ); + $this->assertFalse( $sent['seo_head'] ); + $this->assertFalse( $sent['body_blocks'] ); + $this->assertFalse( $sent['seo_head_baseline'] ); + $this->assertSame( array(), $this->plugin->settings->get( 'capabilities_reported' ) ); + } + + /** + * An install connected by an EARLIER build declared `seo_head` and nothing + * else, so its `seo_head_reported` agrees and the old check stays quiet — + * while CiteCue still believes the site cannot place a block and withholds + * every one. Without this, the feature would reach nobody who was already + * a customer, and nothing would say why. + * + * @return void + */ + public function test_a_connection_predating_the_block_capability_asks_for_a_reconnect() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'capabilities_reported' => null, + ) + ); + + $this->assertTrue( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * A claim records the set it actually sent, so the same site stops being + * asked the moment it has reconnected. + * + * @return void + */ + public function test_a_claim_records_the_declared_capability_set() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $this->assertSame( + array( 'body_blocks', 'seo_head', 'seo_head_baseline' ), + $this->plugin->settings->get( 'capabilities_reported' ) + ); + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * Installing WooCommerce after connecting leaves stale information on the + * key, not a broken feature — it gates nothing CiteCue sends — so it must + * not raise a prompt that asks the customer to fix something that is not + * wrong. + * + * @return void + */ + public function test_a_presentational_capability_alone_never_asks_for_a_reconnect() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'capabilities_reported' => array( 'body_blocks', 'seo_head', 'seo_head_baseline' ), + ) + ); + + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * Disconnecting forgets the set: the next connection mints a new key with + * its own capabilities, and what the old one recorded says nothing about it. + * + * @return void + */ + public function test_disconnecting_forgets_the_declared_capability_set() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'capabilities_reported' => array( 'seo_head' ) ) ); + + $this->connect->disconnect(); + + $this->assertNull( $this->plugin->settings->get( 'capabilities_reported' ) ); + } + + /** + * The prompt has to say what actually went stale. A site upgrading into a + * new capability has not touched its metadata setting, and telling it that + * its metadata is not reaching CiteCue sends an administrator looking for a + * fault that is not there. + * + * @return void + */ + public function test_a_stale_capability_set_is_not_reported_as_a_metadata_problem() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'seo_head_enabled' => true, + 'capabilities_reported' => array( 'seo_head' ), + ) + ); + + $this->assertSame( 'capabilities', $this->plugin->settings->seo_head_reconnect_reason() ); + } + + /** + * A moved metadata setting still reports itself as one, in both directions. + * + * @return void + */ + public function test_a_moved_metadata_setting_reports_itself() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => false, + 'seo_head_enabled' => true, + 'capabilities_reported' => array( 'body_blocks', 'seo_head', 'seo_head_baseline' ), + ) + ); + $this->assertSame( 'enabled', $this->plugin->settings->seo_head_reconnect_reason() ); + + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'seo_head_enabled' => false, + ) + ); + $this->assertSame( 'disabled', $this->plugin->settings->seo_head_reconnect_reason() ); + } + + /** + * Consent is never a reason to reconnect for this. Content pushes gate the + * ingest endpoint and nothing on the delivery path — page enhancements + * arrive through the same authenticated read as the metadata — so a site + * that has not allowed pushes is fully configured for them, and prompting + * it would be asking an administrator to grant write access to their site + * for a feature that does not use it. + * + * @return void + */ + public function test_withheld_content_push_consent_is_never_a_reconnect_reason() { + $this->configure_delivery(); + $this->plugin->settings->update( + array( + 'seo_head_reported' => true, + 'seo_head_enabled' => true, + 'capabilities_reported' => array( 'body_blocks', 'seo_head', 'seo_head_baseline' ), + 'ingest_enabled' => false, + 'ingest_secret' => '', + ) + ); + + $this->assertSame( '', $this->plugin->settings->seo_head_reconnect_reason() ); + $this->assertFalse( $this->plugin->settings->needs_seo_head_reconnect() ); + } + + /** + * A project list that says CiteCue holds no signing secret for this site + * closes the switch here, so the screen stops promising a channel that + * cannot deliver. + * + * @return void + */ + public function test_withdrawn_consent_switches_pushes_off() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + + $revoked = $this->plugin->connect->reconcile_content_push( $this->projects( array( 'contentPush' => false ) ) ); + + $this->assertTrue( $revoked ); + $this->assertFalse( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + $this->assertGreaterThan( 0, (int) $this->plugin->settings->get( 'content_push_revoked_at' ) ); + } + + /** + * A CiteCue that still holds the secret changes nothing. + * + * @return void + */ + public function test_held_consent_leaves_pushes_on() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + + $this->assertFalse( $this->plugin->connect->reconcile_content_push( $this->projects( array( 'contentPush' => true ) ) ) ); + $this->assertTrue( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * The switch is never turned ON from a remote read, even though CiteCue's + * consent gate means `contentPush: true` now implies the customer did once + * tick the box. That makes the grant reading tempting and still wrong: the + * field reports what CiteCue holds at this instant, while the switch is the + * administrator's standing decision about their own site — and they may + * have closed it here on purpose since. Re-opening it from a remote read + * would hand write access back to a site whose owner had refused it. + * + * @return void + */ + public function test_a_remote_read_never_switches_pushes_on() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => false ) ); + + $this->plugin->connect->reconcile_content_push( $this->projects( array( 'contentPush' => true ) ) ); + + $this->assertFalse( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * A response from a CiteCue that predates the field must not read as a + * withdrawal. Defaulting the absent key to false would switch pushes off on + * every site at once, which is the one way this reconcile could do real + * damage. + * + * This test looks redundant against the current API and is not. + * `contentPush` is a required boolean on every project entry, so a reader + * checking the schema will find no way to reach the case at all. The case + * it covers is the deployment WITHOUT the field — a rollback, a staging + * origin on an older build, a self-hosted app behind on releases — which no + * schema describes and which arrives without warning. + * + * @return void + */ + public function test_an_absent_field_is_not_a_withdrawal() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + + $this->assertFalse( $this->plugin->connect->reconcile_content_push( $this->projects() ) ); + $this->assertTrue( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * A list this site's own project is not in says nothing about this site, + * and acting on nothing would revoke a working connection. + * + * @return void + */ + public function test_a_list_without_this_project_changes_nothing() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + + $others = array( + array( + 'publicKey' => 'pk_somebody_else', + 'domain' => 'other.example', + 'contentPush' => false, + ), + ); + + $this->assertFalse( $this->plugin->connect->reconcile_content_push( $others ) ); + $this->assertTrue( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * A bad afternoon on the network is not a withdrawal of consent. + * + * @return void + */ + public function test_a_transport_failure_never_switches_pushes_off() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + $this->http->queue_error( 'config' ); + + $this->assertFalse( $this->plugin->connect->refresh_content_push() ); + $this->assertTrue( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * A site with pushes switched off has nothing to reconcile and spends no + * request doing it — which is most sites, since off is the default. + * + * @return void + */ + public function test_a_site_without_pushes_spends_no_request() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => false ) ); + + $this->assertFalse( $this->plugin->connect->refresh_content_push() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * The daily sync is where a withdrawal made at CiteCue is noticed, so a + * stale switch corrects itself within a day instead of waiting for somebody + * to press Test connection. + * + * @return void + */ + public function test_the_daily_sync_reconciles_consent() { + $this->configure_delivery(); + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 1, + 'tokens' => array( 'GPTBot' ), + ) + ) + ); + $this->http->queue( 'config', 200, wp_json_encode( array( 'projects' => $this->projects( array( 'contentPush' => false ) ) ) ) ); + + $this->plugin->daily_sync(); + + $this->assertFalse( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * The plugin's OWN claim is the one response where an omitted `ingest` is a + * definite "the customer did not tick the box" rather than "not mentioned": + * it sent a secret and asked, and the contract omits the flag rather than + * sending false. Leaving the switch alone would keep a site reading + * "Accepted" after a reconnect that withdrew consent. + * + * @return void + */ + public function test_a_claim_that_omits_consent_switches_pushes_off() { + $this->plugin->settings->update( array( 'ingest_enabled' => true ) ); + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $this->assertFalse( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * And a claim that grants consent turns it back on, clearing the + * explanation left by any earlier withdrawal. + * + * @return void + */ + public function test_a_claim_that_grants_consent_switches_pushes_on() { + $this->plugin->settings->update( + array( + 'ingest_enabled' => false, + 'content_push_revoked_at' => time() - DAY_IN_SECONDS, + ) + ); + $this->http->queue( 'connect', 200, $this->claim_payload( array( 'ingest' => true ) ) ); + + $this->connect->claim( 'one-time-code' ); + + $this->assertTrue( (bool) $this->plugin->settings->get( 'ingest_enabled' ) ); + $this->assertSame( 0, (int) $this->plugin->settings->get( 'content_push_revoked_at' ) ); + } + + /** + * A project list carrying this site's own key, for the consent reconcile. + * + * @param array $extra Extra keys on the entry (omit contentPush entirely to + * stand in for a CiteCue that predates the field). + * @return array + */ + private function projects( array $extra = array() ) { + return array( + array_merge( + array( + 'publicKey' => (string) $this->plugin->settings->get( 'public_key' ), + 'domain' => 'example.org', + 'enabled' => true, + ), + $extra + ), + ); + } + /** * An install that connected before this release injects enriched metadata * while CiteCue still reports the channel as unable to. That disagreement diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php index 193bbdc..4d62bf3 100644 --- a/tests/cases/test-seo-head-delivery.php +++ b/tests/cases/test-seo-head-delivery.php @@ -14,6 +14,9 @@ class Test_Citecue_Seo_Head_Delivery extends Citecue_Test_Case { const BLOCK = '<meta data-citecue="og" property="og:title" content="Acme" />'; + /** A page-enhancement block, in the shape CiteCue composes them. */ + const BODY_BLOCK = '<section data-citecue="page-enhancement"><h2>About Acme</h2><details><summary>What is Acme?</summary><p>A company.</p></details></section>'; + /** * A JSON body of the shape the delivery endpoint returns. * @@ -683,6 +686,302 @@ public function test_the_block_is_injected_once() { $this->assertSame( $this->document(), $injector->enhance( $this->document() ) ); } + /** + * The page-enhancement block lands immediately before `</body>`, verbatim. + * + * Verbatim is the assertion that matters. The block is sanitized on + * CiteCue's side and carries real markup — a `<section>`, `<details>`, + * headings — so anything that escaped it would print tags as visible text + * on the customer's page, and anything that ran it through `wpautop` or the + * content filters would let a shortcode quoted inside an FAQ answer + * execute. + * + * @return void + */ + public function test_the_body_block_is_injected_before_the_closing_body_tag() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK, self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $delivered = $injector->enhance( $this->document() ); + + $this->assertStringContainsString( self::BODY_BLOCK, $delivered, 'The block must be placed exactly as it arrived.' ); + $this->assertMatchesRegularExpression( + '#' . preg_quote( self::BODY_BLOCK, '#' ) . '\s*</body>#', + $delivered, + 'The block belongs immediately before the closing body tag.' + ); + } + + /** + * Both halves of one response land in one render, in their own places. + * + * @return void + */ + public function test_head_and_body_are_injected_together() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK, self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $delivered = $injector->enhance( $this->document() ); + $head = substr( $delivered, 0, (int) stripos( $delivered, '</head>' ) ); + + $this->assertStringContainsString( 'og:title', $head ); + $this->assertStringNotContainsString( self::BODY_BLOCK, $head, 'The body block must not be placed in the head.' ); + $this->assertStringContainsString( self::BODY_BLOCK, $delivered ); + } + + /** + * A page that already carries the marker is left alone. On a site fronted + * by CiteCue's Worker — or one whose post content already holds the + * section — the block is in the document before this plugin ever sees it, + * and a second copy is the one failure a reader actually notices. + * + * @return void + */ + public function test_a_page_that_already_carries_the_marker_is_not_injected_into() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, '', self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $already = $this->document( '', '<p>Hello</p>' . self::BODY_BLOCK ); + $delivered = $injector->enhance( $already ); + + $this->assertSame( $already, $delivered ); + $this->assertSame( 1, substr_count( $delivered, 'page-enhancement' ), 'Exactly one section, however it got there.' ); + } + + /** + * The marker is recognised however it was quoted, so a single-quoted or + * loosely-spaced copy of the section still suppresses a second one. + * + * @return void + */ + public function test_the_marker_is_recognised_whatever_its_quoting() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, '', self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $already = $this->document( '', "<section data-citecue = 'page-enhancement'>Theirs</section>" ); + $delivered = $injector->enhance( $already ); + + $this->assertSame( $already, $delivered ); + } + + /** + * A page that QUOTES the marker in a code sample has not been given a + * block, and must still get one. This is the lesson enhance() already + * records about the head, applied to the body: escaped markup in page + * content is content, not markup, and a substring search cannot tell the + * difference — it would withhold that page's enhancement forever. + * + * @return void + */ + public function test_a_page_quoting_the_marker_still_gets_its_block() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, '', self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $sample = '<p>Add <code><section data-citecue="page-enhancement"></code> to your template.</p>'; + $delivered = $injector->enhance( $this->document( '', $sample ) ); + + $this->assertStringContainsString( self::BODY_BLOCK, $delivered ); + } + + /** + * The FAQ payload CiteCue composes INSIDE the block carries + * `data-citecue="page-enhancement-faq"`. It is a sibling value, not the + * section marker, so on its own it must not read as a block already placed. + * + * @return void + */ + public function test_the_faq_sibling_value_is_not_the_section_marker() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, '', self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $faq_only = '<script type="application/ld+json" data-citecue="page-enhancement-faq">{}</script>'; + $delivered = $injector->enhance( $this->document( '', $faq_only ) ); + + $this->assertStringContainsString( self::BODY_BLOCK, $delivered ); + } + + /** + * A block over the wire cap is dropped rather than placed. The endpoint + * refuses to send one this large, so a block that arrives anyway is a + * generation bug — and the cap is what keeps that bug costing one page + * rather than every page the cache has warmed. + * + * @return void + */ + public function test_an_oversized_body_block_is_not_injected() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $huge = '<section data-citecue="page-enhancement"><p>' + . str_repeat( 'x', Citecue_Seo_Head::MAX_BLOCK_BYTES ) + . '</p></section>'; + $this->plugin->cache->set_seo_head( $url, self::BLOCK, $huge ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $delivered = $injector->enhance( $this->document() ); + + $this->assertStringNotContainsString( 'page-enhancement', $delivered ); + // The head half is unaffected: the two are independent, and one + // oversized block is no reason to withhold the page's metadata. + $this->assertStringContainsString( 'og:title', $delivered ); + } + + /** + * The document's own `</body>` is the last one. A page that quotes markup + * in a code sample carries an earlier one, and splicing there would put the + * section inside the sample. + * + * @return void + */ + public function test_the_block_goes_before_the_last_closing_body_tag() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, '', self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $sample = '<pre><body>…</body></pre><code></body></code>'; + $delivered = $injector->enhance( $this->document( '', $sample ) ); + + $this->assertMatchesRegularExpression( + '#' . preg_quote( self::BODY_BLOCK, '#' ) . '\s*</body></html>$#', + $delivered, + 'The section belongs before the document\'s own closing tag, not the quoted one.' + ); + } + + /** + * A response with no closing body tag is returned unchanged — and its head + * half still lands. The two are independent. + * + * @return void + */ + public function test_a_document_without_a_body_close_keeps_its_head_injection() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK, self::BODY_BLOCK ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $delivered = $injector->enhance( '<html><head></head><p>fragment</p>' ); + + $this->assertStringNotContainsString( 'page-enhancement', $delivered ); + $this->assertStringContainsString( 'og:title', $delivered ); + } + + /** + * A 200 whose `head` is empty but whose `body` is not is a complete answer, + * not a broken one: the endpoint sends 204 when it has neither half, so a + * page whose enhancement block is all it has must be cached and served. + * + * @return void + */ + public function test_a_body_only_response_is_cached_and_injected() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->http->queue( + 'seo_head', + 200, + wp_json_encode( + array( + 'head' => '', + 'body' => self::BODY_BLOCK, + ) + ) + ); + + $this->assertSame( 'fresh', $this->seo_head()->refresh( $url ) ); + + $injector = $this->seo_head(); + $this->arrange_capture( $injector ); + + $this->assertStringContainsString( self::BODY_BLOCK, $injector->enhance( $this->document() ) ); + } + + /** + * A 200 carrying neither half is still the payload we do not understand, + * and is recorded as a miss rather than cached as an empty block. + * + * @return void + */ + public function test_a_response_with_neither_half_is_a_miss() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->http->queue( + 'seo_head', + 200, + wp_json_encode( + array( + 'head' => '', + 'body' => '', + ) + ) + ); + + $this->assertSame( 'empty', $this->seo_head()->refresh( $url ) ); + $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + } + + /** + * An entry written before the body half existed still reads. Those outlive + * the upgrade by up to the cache TTL, and discarding them would throw away + * a day of warm cache on every site the moment it updates. + * + * @return void + */ + public function test_a_cache_entry_from_before_the_body_half_still_reads() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + + // Write through the cache first, so the key's salt exists and is + // discoverable, then overwrite the entry with exactly what the previous + // version stored: no `body` key at all. + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + $key = 'citecue_sh_' . md5( get_option( 'citecue_cache_salt' ) . '|' . Citecue_Cache::normalize_url( $url ) ); + $this->assertNotFalse( get_transient( $key ), 'The key derivation must match the cache\'s own.' ); + + set_transient( + $key, + array( + 'block' => self::BLOCK, + 'cached_at' => time(), + ), + HOUR_IN_SECONDS + ); + + $cached = $this->plugin->cache->get_seo_head( $url ); + + $this->assertIsArray( $cached ); + $this->assertSame( '', $cached['body'] ); + } + /** * A rendered page, as the injection receives it. *