From 5a2e913cf854a136a00eda459e70ed5d8c36bf10 Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sun, 19 Jul 2026 11:46:25 +0100 Subject: [PATCH 1/2] docs: trim inline comments to why-only across src; move rationale to doc/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments-only change — no lines of code are touched (verified by comparing php -w output and espree token streams for every file against the previous commit). - PHPDoc/JSDoc blocks now carry a 1-line summary, with a secondary description (max 2 lines) only where the behaviour is genuinely non-obvious. All @since/@param/@return/@var tags are preserved. - Inline what-comments are removed; why-comments are kept and trimmed to 1-2 lines. - Functional comments are untouched (phpcs:ignore, translators, eslint globals, WP dependency headers, plugin header). - Long-form rationale that was worth keeping now lives in doc/: rest-meta-visibility.md, video-settings-payload.md, preselect-generate-audio.md, settings-internals.md, plus additions to wordpress-vip.md — all linked from the README doc index. Co-Authored-By: Claude Fable 5 --- README.md | 12 + doc/preselect-generate-audio.md | 63 +++++ doc/rest-meta-visibility.md | 49 ++++ doc/settings-internals.md | 30 +++ doc/video-settings-payload.md | 36 +++ doc/wordpress-vip.md | 15 ++ scripts/bump-version.sh | 32 +-- speechkit.php | 3 - src/api/class-client.php | 155 +++--------- src/compatibility/class-wpgraphql.php | 5 +- src/core/class-plugin.php | 33 +-- src/core/class-uninstaller.php | 51 +--- src/core/class-updater.php | 52 +--- src/core/class-urls.php | 5 +- src/core/class-utils.php | 13 +- src/editor/block/class-assets.php | 9 +- src/editor/block/document-setting/index.js | 5 +- src/editor/block/prepublish/index.js | 9 +- src/editor/classic/class-assets.php | 10 +- src/editor/classic/class-metabox.php | 35 +-- src/editor/components/add-player/index.js | 6 +- src/editor/components/add-player/tinymce.js | 2 - .../block-attributes/addAttributes.js | 1 - .../block-attributes/addControls.js | 3 +- .../class-block-attributes.php | 2 - .../isBeyondwordsSupportedBlock.js | 5 +- .../components/content-id/class-assets.php | 3 - .../content-id/class-content-id.php | 5 +- .../components/content-id/classic-metabox.js | 16 +- src/editor/components/content-id/index.js | 2 - .../components/error-notice/class-assets.php | 4 - .../generate-audio/class-generate-audio.php | 19 +- .../generate-audio/classic-metabox.js | 31 +-- src/editor/components/generate-audio/index.js | 36 +-- src/editor/components/icon/index.js | 9 +- .../components/inspect-panel/class-assets.php | 4 - .../inspect-panel/class-inspect-panel.php | 22 +- .../components/inspect-panel/helpers.js | 20 +- .../components/inspect-panel/helpers.test.js | 3 +- src/editor/components/inspect-panel/index.js | 19 +- .../components/inspect-panel/js/inspect.js | 3 +- src/editor/components/play-audio/check.js | 3 +- src/editor/components/play-audio/hooks.js | 24 +- src/editor/components/play-audio/index.js | 3 - src/editor/components/preview-panel/index.js | 9 +- .../components/select-voice/class-assets.php | 3 - .../select-voice/class-select-voice.php | 78 ++---- .../select-voice/classic-metabox.js | 113 ++++----- .../settings-fields/class-assets.php | 4 - .../settings-fields/class-settings-fields.php | 54 ++-- .../settings-fields/classic-metabox.js | 9 +- .../settings-panel/content-section.js | 3 +- .../settings-panel/format-section.js | 3 +- .../components/settings-panel/helpers.js | 35 +-- .../settings-panel/player-section.js | 21 +- .../settings-panel/voice-section.js | 57 ++--- .../components/sidebar/class-assets.php | 2 - src/editor/components/stack/index.js | 6 +- src/editor/components/toggle/index.js | 5 +- src/index.js | 12 +- src/player/class-config-builder.php | 11 +- src/player/class-player.php | 51 ++-- src/player/renderer/class-amp.php | 3 - src/player/renderer/class-base.php | 7 - src/player/renderer/class-javascript.php | 14 +- src/post/class-content.php | 129 +++------- src/post/class-head.php | 9 +- src/post/class-meta.php | 82 ++---- src/post/class-sync.php | 235 ++++-------------- src/posts-list/class-bulk-edit.php | 53 +--- src/posts-list/class-column.php | 12 +- src/posts-list/class-notices.php | 6 +- src/settings/class-fields.php | 15 +- src/settings/class-preselect.php | 81 ++---- src/settings/class-settings.php | 38 +-- src/settings/class-tabs.php | 10 +- src/settings/class-utils.php | 72 ++---- src/settings/index.js | 3 +- src/settings/preselect.js | 16 +- src/settings/store/index.js | 20 +- src/settings/store/index.test.js | 3 +- src/site-health/class-site-health.php | 31 +-- uninstall.php | 2 - 83 files changed, 694 insertions(+), 1490 deletions(-) create mode 100644 doc/preselect-generate-audio.md create mode 100644 doc/rest-meta-visibility.md create mode 100644 doc/settings-internals.md create mode 100644 doc/video-settings-payload.md diff --git a/README.md b/README.md index 927fe2f9..51e9c4b6 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ local development environment. 5. [Plugin features](./doc/plugin-features.md): An overview of the features of our plugin. 6. [wp-config.php](./doc/wp-config.md): Our *wp-config.php* settings. +7. [Async REST migration](./doc/async-rest-migration.md): API response caching +and background (cron) audio generation on WordPress VIP. +8. [Legacy meta migration](./doc/legacy-meta-migration.md): The v7 post-meta +cleanup and downgrade-safety guarantees. +9. [REST meta visibility](./doc/rest-meta-visibility.md): How BeyondWords post +meta is exposed (or hidden) over the WordPress REST API. +10. [Video settings payload](./doc/video-settings-payload.md): Why the plugin +sends a full `video_settings` object to the content endpoint. +11. [Preselect "Generate audio"](./doc/preselect-generate-audio.md): The +setting's stored format and how the editor and save path honour it. +12. [Settings internals](./doc/settings-internals.md): Settings-error +transport and the API connection check. ## Links diff --git a/doc/preselect-generate-audio.md b/doc/preselect-generate-audio.md new file mode 100644 index 00000000..051b00a5 --- /dev/null +++ b/doc/preselect-generate-audio.md @@ -0,0 +1,63 @@ +# Preselect "Generate audio" + +How the **Preselect ‘Generate audio’** setting decides whether the Generate +Audio toggle starts checked, and how that choice is honoured at save time +without dirtying the post. Implemented in +[src/settings/class-preselect.php](../src/settings/class-preselect.php), +[src/editor/components/generate-audio/](../src/editor/components/generate-audio/) +and [src/post/class-sync.php](../src/post/class-sync.php). + +## Stored format (`beyondwords_preselect` option) + +One entry per compatible post type, each with a `mode`: + +```php +[ + 'post' => [ 'mode' => 'all' ], // preselect for every post + 'page' => [ + 'mode' => 'terms', // gate by taxonomy terms + 'terms' => [ + 'category' => [ 12, 34 ], + 'genre' => [ 56 ], + ], + ], + // a missing post type (or mode 'none') means never preselect +] +``` + +### Legacy (pre-7.0.0) shapes + +`Updater::run()` migrates the old shapes on upgrade: + +- `'1'` for a post type meant "preselect the whole post type" → `mode: all`. +- A bare `[ taxonomy => [ term_ids ] ]` array meant term-gating → `mode: terms`. + +## Editor behaviour: derive, don't write + +The block editor does **not** write `beyondwords_generate_audio` meta to show a +preselected toggle — it *derives* the checked state from the Preselect setting. +An untouched post therefore stays clean (no dirty state, no meta write) until +the user actually changes something. + +The classic editor's checkbox writes an explicit `'1'`/`'0'` on save. Its +metabox JS only handles live changes (project/taxonomy edits while the screen +is open); the server renders the correct initial state, and the JS stops +adjusting once the user toggles the checkbox themselves so a deliberate choice +is never clobbered. + +## Save-time decision + +Because a preselected-but-untouched post has no meta, the generate decision +must come from the setting at save time: + +1. An explicit `beyondwords_generate_audio` value always wins + (`'1'` = generate, `'0'` = don't). +2. When unset, `Sync::should_generate_audio_for_post()` falls back to + `Preselect::should_preselect_for_post()` — but **only for editor/REST + saves** (`REST_REQUEST`). Programmatic and imported posts + (`wp_insert_post()`, WXR import, cron) keep the explicit-meta requirement so + a bulk import never unexpectedly generates audio. + +`Preselect::get()` applies an explicit default-value fallback rather than +relying on `register_setting` defaults (those only apply where the setting is +registered), keeping REST/cron decisions in step with what the editor displays. diff --git a/doc/rest-meta-visibility.md b/doc/rest-meta-visibility.md new file mode 100644 index 00000000..1e0254ee --- /dev/null +++ b/doc/rest-meta-visibility.md @@ -0,0 +1,49 @@ +# REST meta visibility + +How BeyondWords post meta is exposed (or hidden) over the WordPress REST API. +Implemented in [src/post/class-sync.php](../src/post/class-sync.php) +(`register_meta()`, `hide_private_meta_from_rest()`). + +## Registration model + +`Sync::register_meta()` registers **every** BeyondWords post-meta key, per +compatible post type, so that: + +- all writes are sanitised (`sanitize_text_field`, or the strict + `Meta::sanitize_content_id()` charset check for `beyondwords_content_id`, + whose value is interpolated into API URL paths); +- the legacy "Custom Fields" panel stays hidden via `is_protected_meta()` + (the block editor's panel can break when plugin meta renders there — + see [gutenberg#23078](https://github.com/WordPress/gutenberg/issues/23078)); +- only the keys the block editor actually reads/writes over REST get + `show_in_rest => true`. + +Secrets and internal data — the legacy `speechkit_access_key`, cached API +responses, player/voice config — are registered `show_in_rest => false` and +never reach the REST API. + +## Legacy keys still exposed (`Sync::REST_LEGACY_META_KEYS`) + +On a site upgraded from the legacy SpeechKit plugin, deprecated keys +(`speechkit_project_id`, `speechkit_podcast_id`, `_speechkit_link`, …) can hold +a post's only BeyondWords data until audio is regenerated under v7. The block +editor reads them in the authenticated `edit` context as a fallback — the error +notice, pending notice, play/preview controls, the "Generate audio" toggle and +the legacy player link all depend on them (see the components under +[src/editor/components/](../src/editor/components/)). + +## Hiding private meta from public REST (`Sync::REST_PRIVATE_META_KEYS`) + +A handful of REST-exposed keys hold secrets or internal data: BeyondWords API +error strings, the audio preview token, and the legacy player URL. + +`WP_REST_Meta_Fields` returns `show_in_rest` meta in the public `view` context +too, with **no capability check** — so an anonymous `GET /wp/v2/posts/:id` +would disclose them. Requesting the `edit` context is itself permission-gated +by the posts controller, so `Sync::hide_private_meta_from_rest()` keeps those +keys only for `edit` requests and strips them from every other response — +closing the anonymous disclosure while leaving the block editor unaffected. +The filter is registered on `rest_api_init` so it only loads for REST requests. + +See also: [legacy-meta-migration.md](./legacy-meta-migration.md) for how keys +moved between the `current` and `deprecated` sets. diff --git a/doc/settings-internals.md b/doc/settings-internals.md new file mode 100644 index 00000000..2214120b --- /dev/null +++ b/doc/settings-internals.md @@ -0,0 +1,30 @@ +# Settings internals + +Non-obvious plumbing behind the plugin settings screens, implemented in +[src/settings/class-utils.php](../src/settings/class-utils.php) and +[src/settings/class-tabs.php](../src/settings/class-tabs.php). + +## Settings-error transport (`Utils::add_settings_error_message()`) + +Error notices raised while saving settings must survive the Settings API's +`wp_redirect()` back to the settings page. They are carried in a **transient**, +not `wp_cache_*`: WordPress's default object cache is request-scoped, so on +hosts without a persistent Redis/Memcached drop-in a cache entry would be empty +after the redirect. Transients fall back to the options table, which does +persist. The 30-second TTL deliberately matches core's own `settings_errors` +transient. + +## API connection check (`Utils::validate_api_connection()`) + +`beyondwords_valid_api_connection` stores a **timestamp** recording the last +successful credential validation. It gates visibility of the Integration / +Preferences tabs via `Tabs::get_visible_tabs()`. + +- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT`, so a slow + or unreachable API cannot block admin rendering. +- Transient failures (timeout, DNS, 5xx, `WP_Error`) intentionally leave the + last known-good flag in place — a blip should not lock the operator out of + the settings tabs. +- Only authentication failures clear it: a 401 (in `Client::call_api()`) or a + 403 (in the validation itself), after which the settings page re-runs + validation. diff --git a/doc/video-settings-payload.md b/doc/video-settings-payload.md new file mode 100644 index 00000000..bd639dc0 --- /dev/null +++ b/doc/video-settings-payload.md @@ -0,0 +1,36 @@ +# Video settings payload + +Why `Content::get_video_settings_params()` sends a full `video_settings` object +instead of just a template ID. + +## Backend requirements + +Choosing "Video" (or "Audio + video") output opts a post into video generation, +overriding the project's default `video_settings.enabled`. However, the +BeyondWords backend **silently skips video generation** unless the payload +carries all of: + +- `enabled: true` +- a non-empty `variants` array +- a non-empty `sizes` array whose entries include `width`/`height` + +Earlier plugin versions sent only `template.id` and `sizes[].name`/`enabled`, +which left `variants` empty server-side — so no video was ever produced. + +## Approach: mirror the dashboard + +The BeyondWords dashboard sends the full object whenever a user customises +video, so the plugin does the same: + +1. Seed the payload from the project's default video settings + (`Client::get_video_settings()`, fetched once and cached). The defaults are + fetched for the project the post publishes to — which may be a per-post + override of the global project — so variants and size dimensions match the + project the content POST actually targets. +2. Layer the post's own choices on top: + - the chosen size becomes the only enabled size; + - the chosen video template overrides the project default (omitted when the + post has none, deferring to the project default). +3. Anything the post doesn't customise (`variants`, and the size dimensions) is + echoed straight from the project defaults. The plugin exposes no per-post + variant control, so the project default is the correct value to send. diff --git a/doc/wordpress-vip.md b/doc/wordpress-vip.md index 241310d3..d7093ca0 100644 --- a/doc/wordpress-vip.md +++ b/doc/wordpress-vip.md @@ -18,3 +18,18 @@ PHP, custom JavaScript, and SVG files. We do not review HTML, CSS, SASS, many popular third-party JavaScript libraries, or built JavaScript files. See [Developing with VIP](https://wpvip.com/documentation/developing-with-vip) + +## Object-cache notes + +On hosts with an external object cache (VIP uses Memcached), transients are not +stored as `_transient_*` rows in the options table, so they cannot be +enumerated or bulk-deleted. This is why: + +- the uninstaller's transient cleanup only deletes known keys — anything else + holds a TTL and self-expires; +- the API client salts its cache keys with the project ID + API key + (`Client::cache_key()`), so changing credentials invalidates the cache + implicitly with no flush step. + +See also [async-rest-migration.md](./async-rest-migration.md) for the +VIP-gated background (cron) audio generation. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 74eb672b..16d5a485 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -1,27 +1,12 @@ #!/usr/bin/env bash # -# bump-version.sh — set the plugin version in every place it lives, in one command. +# bump-version.sh — set the plugin version in every place it lives (see usage()). # -# Usage: -# scripts/bump-version.sh -# -# Example: -# scripts/bump-version.sh 7.0.0-beta.1 -# scripts/bump-version.sh 7.0.0 -# -# Updates, atomically-ish (all-or-nothing after validation): -# - speechkit.php plugin header `Version:` line -# - speechkit.php BEYONDWORDS__PLUGIN_VERSION constant -# - package.json "version" -# - package-lock.json root "version" and packages[""]."version" -# - readme.txt "Stable tag:" -# -# It does NOT touch the changelog headings (those are curated by hand) and does -# not create a git commit or tag — run it, review the diff, then commit. +# Changelog headings are curated by hand, and no git commit or tag is created — +# run it, review the diff, then commit. set -euo pipefail -# --- locate the repo root relative to this script, so it works from any cwd --- ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" @@ -45,7 +30,6 @@ EOF die() { echo "bump-version: error: $*" >&2; exit 1; } -# --- args ------------------------------------------------------------------- [ $# -eq 1 ] || usage NEW="$1" @@ -53,20 +37,18 @@ case "$NEW" in -h|--help) usage ;; esac -# Semver: MAJOR.MINOR.PATCH with optional -prerelease and +build (dot-separated -# alphanumeric identifiers). Rejects a leading "v", trailing spaces, etc. +# Strict semver; rejects a leading "v", trailing spaces, etc. if ! printf '%s' "$NEW" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$'; then die "'$NEW' is not a valid semver version (expected e.g. 7.0.0 or 7.0.0-beta.1)" fi -# --- preflight: required tools and files ------------------------------------ command -v node >/dev/null 2>&1 || die "node is required (used to edit the JSON files safely)" command -v perl >/dev/null 2>&1 || die "perl is required" for f in "$SPEECHKIT" "$PACKAGE_JSON" "$PACKAGE_LOCK" "$README"; do [ -f "$f" ] || die "expected file not found: $f (is the repo layout as expected?)" done -# --- current canonical version (from the plugin constant) ------------------- +# The plugin constant is the canonical current version. OLD="$(perl -ne "print \$1 if /BEYONDWORDS__PLUGIN_VERSION\x27\s*,\s*\x27([^\x27]*)\x27/" "$SPEECHKIT")" [ -n "$OLD" ] || die "could not read the current version from $SPEECHKIT" @@ -77,14 +59,11 @@ fi echo "bump-version: $OLD -> $NEW" -# --- edit speechkit.php: header line + constant ----------------------------- NEW="$NEW" perl -i -pe 's{^(\s*\*\s*Version:\s*)\S+}{$1$ENV{NEW}}' "$SPEECHKIT" NEW="$NEW" perl -i -pe 's{(BEYONDWORDS__PLUGIN_VERSION\x27\s*,\s*\x27)[^\x27]*}{$1$ENV{NEW}}' "$SPEECHKIT" -# --- edit readme.txt: Stable tag -------------------------------------------- NEW="$NEW" perl -i -pe 's{^(Stable tag:\s*)\S+}{$1$ENV{NEW}}' "$README" -# --- edit package.json + package-lock.json via node (targeted, keeps format) - # Editing the exact JSON paths avoids clobbering any dependency's "version". NEW="$NEW" node -e ' const fs = require("fs"); @@ -103,7 +82,6 @@ NEW="$NEW" node -e ' } ' -# --- verify every location now reads NEW ------------------------------------ declare -a checks=( "speechkit.php (header)|$(perl -ne 'print $1 if /^\s*\*\s*Version:\s*(\S+)/' "$SPEECHKIT")" "speechkit.php (constant)|$(perl -ne "print \$1 if /BEYONDWORDS__PLUGIN_VERSION\x27\s*,\s*\x27([^\x27]*)\x27/" "$SPEECHKIT")" diff --git a/speechkit.php b/speechkit.php index 8b3db927..9b4c617d 100755 --- a/speechkit.php +++ b/speechkit.php @@ -27,15 +27,12 @@ defined( 'ABSPATH' ) || exit; -// Composer autoload require_once plugin_dir_path( __FILE__ ) . 'vendor/autoload.php'; -// Define constants // phpcs:disable define('BEYONDWORDS__PLUGIN_VERSION', '7.0.0-beta.1'); define('BEYONDWORDS__PLUGIN_DIR', plugin_dir_path(__FILE__)); define('BEYONDWORDS__PLUGIN_URI', plugin_dir_url(__FILE__)); // phpcs:enable -// Bootstrap. BeyondWords\Core\Plugin::init(); diff --git a/src/api/class-client.php b/src/api/class-client.php index c82dabfc..408631e7 100644 --- a/src/api/class-client.php +++ b/src/api/class-client.php @@ -1,21 +1,13 @@ $args WordPress HTTP args. * @param string $url Outbound request URL. @@ -143,17 +120,11 @@ public static function filter_http_request_args( $args, $url ) { /** * GET /projects/:project/content/:content_id * - * Falls back to the global `beyondwords_project_id` option when no project - * ID is supplied. - * * @param string $content_id BeyondWords content ID. * @param int|string|null $project_id Optional project ID override. * - * @return array|\WP_Error|false Raw HTTP response array, a `\WP_Error` - * on transport-level failure (unreachable - * host, timeout) which the caller surfaces - * as a connection error, or false when the - * project/content ID is missing. + * @return array|\WP_Error|false Raw HTTP response, WP_Error on transport + * failure, or false when an ID is missing. */ public static function get_content( int|string $content_id, int|string|null $project_id = null ): array|\WP_Error|false { if ( ! $project_id ) { @@ -199,7 +170,7 @@ public static function create_audio( int $post_id ): array|null|false { * * @param int $post_id WordPress post ID. * - * @return array|null|false + * @return array|null|false Decoded response body, or false when an ID is missing. */ public static function update_audio( int $post_id ): array|null|false { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); @@ -219,9 +190,6 @@ public static function update_audio( int $post_id ): array|null|false { /** * DELETE /projects/:project/content/:content_id * - * Resolves the project + content IDs from the post's meta and delegates to - * `delete_audio_by_ids()`. - * * @param int $post_id WordPress post ID. * * @return array|null|false `false` when the request didn't return 204. @@ -237,11 +205,7 @@ public static function delete_audio( int $post_id ): array|null|false { * DELETE /projects/:project/content/:content_id using explicit IDs. * * Split out from `delete_audio()` so the deferred trash/delete cron job can - * delete audio from the project + content IDs captured before the post meta - * was wiped (see `\BeyondWords\Post\Sync::delete_audio_by_ids()`). Deletion - * runs on the synchronous trash/delete admin path and is best-effort (a - * timeout just leaves the audio in the BeyondWords dashboard), so the short - * default timeout applies. + * still delete after the post meta has been wiped. * * @since 7.0.0 * @@ -269,9 +233,7 @@ public static function delete_audio_by_ids( int|string|false $project_id, int|st /** * POST /projects/:project/content/batch_delete * - * Accepts a heterogeneous list of WordPress post IDs, groups them by - * BeyondWords project, and refuses cross-project batches (the API only - * supports one project per request). + * Refuses cross-project batches — the API only supports one project per request. * * @param int[] $post_ids WordPress post IDs. * @@ -322,17 +284,14 @@ public static function batch_delete_audio( array $post_ids ): array|false { $response_code = wp_remote_retrieve_response_code( $response ); - // 2xx means the API accepted the batch — return the IDs we sent so the - // caller can clear local meta. Anything else: refuse to clear meta so - // the operator can retry. + // On failure, return no IDs so the caller keeps local meta and can retry. return $response_code <= 299 ? $updated_post_ids : []; } /** * GET /projects/:project/player/by_source_id/:post_id * - * Magic Embed (client-side) bootstrap: tells BeyondWords to look up — or - * create — content for the given source URL, returning the player blob. + * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL. * * @param int $post_id WordPress post ID used as the source ID. * @@ -370,9 +329,6 @@ public static function get_languages(): array|null|false { /** * GET /organization/voices?filter[language.code]=… * - * Passes the longer `VOICES_REQUEST_TIMEOUT` — this endpoint is materially - * slower than the other editor dropdowns. - * * @param int|string $language_code BeyondWords language code (or numeric ID). * * @return array|null|false @@ -435,9 +391,6 @@ public static function get_video_settings( ?int $project_id = null ): array|null /** * GET /projects/:id * - * Returns the project, including its default `language` code. Editor scripts - * read it to pre-select the Language dropdown when "Customize" is enabled. - * * @since 7.0.0 * * @param int|null $project_id Optional override; falls back to the global option. @@ -461,9 +414,6 @@ public static function get_project( ?int $project_id = null ): array|null|false /** * GET /summarization_settings_templates * - * The list of script templates available to the organization. Editor - * scripts use this to populate the "Script template" dropdown. - * * @since 7.0.0 * * @return array|null|false @@ -477,9 +427,6 @@ public static function get_summarization_settings_templates(): array|null|false /** * GET /video_settings_templates * - * The list of video templates available to the organization. Editor - * scripts use this to populate the "Video template" dropdown. - * * @since 7.0.0 * * @return array|null|false @@ -491,19 +438,10 @@ public static function get_video_settings_templates(): array|null|false { } /** - * Make the API call, normalising errors into post meta when a post is - * supplied. + * Make the API call, normalising errors into post meta when a post is supplied. * - * For 401 responses we also clear the cached `beyondwords_valid_api_connection` - * so the settings page re-runs validation. - * - * Magic Embed (client-side) errors are not persisted because the SDK - * regenerates content lazily — surfacing those errors in the editor would - * be misleading. - * - * Headers `X-Api-Key` and `Content-Type` are injected automatically by the - * `http_request_args` filter registered in `init()` — pass `$headers` - * here for any *additional* per-request headers. + * A 401 also clears `beyondwords_valid_api_connection` so the settings page + * re-runs validation. * * @param string $method HTTP method. * @param string $url Absolute URL. @@ -540,15 +478,13 @@ public static function call_api( string $method, string $url, string $body = '', /** * Build the WordPress HTTP args for a BeyondWords API call. * - * Auth and Content-Type headers are *not* added here — they're injected - * by `filter_http_request_args()` so the same logic applies to any other - * code that calls `wp_remote_request()` against the BeyondWords API. + * Auth and Content-Type headers are added by `filter_http_request_args()`, + * not here, so they also apply to third-party calls against the API. * * @param string $method HTTP method. * @param string $body Request body. * @param array $headers Extra per-request headers. - * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT; - * the voices GET passes the longer VOICES_REQUEST_TIMEOUT. + * @param int $timeout Request timeout in seconds. * * @return array */ @@ -565,9 +501,8 @@ private static function build_args( string $method, string $body = '', array $he /** * Build a transient key for a cached GET. * - * Salted with the project ID + API key so changing either invalidates the - * cache implicitly — no explicit flush needed, which matters on object-cache - * hosts (e.g. VIP) where `_transient_*` rows can't be enumerated. + * Salted with the project ID + API key so changing either invalidates + * implicitly — no flush needed, which object-cache hosts can't do anyway. * * @since 7.0.0 * @@ -586,23 +521,14 @@ private static function cache_key( string $suffix ): string { /** * GET an editor-render-path endpoint, caching both hits and failures. * - * Successful 2xx array responses are cached for {@see CACHE_TTL}. Failures - * (WP_Error / timeout, 5xx, 4xx, non-JSON) are negative-cached for the much - * shorter {@see CACHE_TTL_ON_ERROR}, so a slow or unreachable API is probed - * at most once per interval instead of re-issuing a blocking request on - * every admin render. The request itself uses the short - * {@see DEFAULT_REQUEST_TIMEOUT} for the same reason; the slower voices - * endpoint passes its own {@see VOICES_REQUEST_TIMEOUT}. - * - * A 401 additionally self-heals: call_api() clears - * `beyondwords_valid_api_connection`, which ungates the metabox next load. + * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so + * an unreachable API is probed at most once per interval, not every render. * * @since 7.0.0 * * @param string $suffix Cache-key suffix (include any project/language id). * @param string $url Absolute endpoint URL. - * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT; - * the voices GET passes the longer VOICES_REQUEST_TIMEOUT. + * @param int $timeout Request timeout in seconds. * * @return array|null|false Decoded body on the fetching call; the cached * value ([] after a cached failure) thereafter. @@ -628,8 +554,6 @@ private static function cached_get( string $suffix, string $url, int $timeout = return $decoded; } - // Negative-cache the failure so the next render is served from the - // transient instead of blocking on another remote round-trip. set_transient( $key, [], self::CACHE_TTL_ON_ERROR ); return $decoded; @@ -653,9 +577,8 @@ public static function error_message_from_response( array|\WP_Error $response ): } $message = implode( ', ', $messages ); } elseif ( array_key_exists( 'message', $body ) ) { - // The API body is arbitrary JSON, so `message` may be null, a - // number, or a nested structure. Coerce to a string so the - // `: string` return type holds under strict_types. + // `message` is arbitrary JSON; coerce so the `: string` return + // type holds under strict_types. $message = is_string( $body['message'] ) ? $body['message'] : (string) wp_json_encode( $body['message'] ); diff --git a/src/compatibility/class-wpgraphql.php b/src/compatibility/class-wpgraphql.php index ad3259a4..3a61f62e 100644 --- a/src/compatibility/class-wpgraphql.php +++ b/src/compatibility/class-wpgraphql.php @@ -1,9 +1,6 @@ 0` lifts the default 100-site cap so no site is skipped on - // large networks. + // `number => 0` lifts the default 100-site cap. $site_ids = get_sites( [ 'fields' => 'ids', @@ -65,8 +52,7 @@ public static function run(): void { } /** - * Delete every BeyondWords option, transient and post-meta value for the - * current site. + * Delete every BeyondWords value for the current site. * * @since 7.0.0 * @@ -86,11 +72,8 @@ private static function cleanup_site(): void { public static function cleanup_plugin_transients(): int { global $wpdb; - // Each transient is stored as a pair of options — `_transient_` and - // `_transient_timeout_` — so both prefixes must be swept, otherwise - // the timeout rows are left orphaned. (On external-object-cache hosts, - // e.g. VIP, there are no option rows to delete; those entries hold a TTL - // and self-expire.) + // Transients are stored as `_transient_` + `_transient_timeout_` + // option pairs; sweep both prefixes or the timeout rows are orphaned. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $count = $wpdb->query( "DELETE FROM $wpdb->options @@ -104,9 +87,6 @@ public static function cleanup_plugin_transients(): int { /** * Delete every BeyondWords plugin option (current + deprecated). * - * Iterates the list from `Utils::get_options( 'all' )` so this stays - * in sync with new keys without uninstall code changes. - * * @return int Options deleted. */ public static function cleanup_plugin_options(): int { @@ -114,19 +94,13 @@ public static function cleanup_plugin_options(): int { $total = 0; foreach ( $options as $option ) { - // Every option is stored per-site via `update_option()`, so - // `delete_option()` is the correct call on both single-site and - // multisite. The previous `delete_site_option()`-only branch swept - // `wp_sitemeta` rows that were never written and left every real - // option — including the `beyondwords_api_key` secret — in place on - // multisite. + // Options are stored per-site via `update_option()`, so `delete_option()` + // is the correct call on both single-site and multisite. if ( delete_option( $option ) ) { ++$total; } - // Defensive: a legacy install may have stored a matching network - // (site) option. This is network-global and idempotent, so it is a - // harmless no-op when nothing was stored. + // Defensive: a legacy install may have stored a matching network option. if ( is_multisite() ) { delete_site_option( $option ); } @@ -138,9 +112,8 @@ public static function cleanup_plugin_options(): int { /** * Delete every BeyondWords post-meta value. * - * Done one meta_id at a time to keep individual queries fast on sites with - * many posts — the alternative `DELETE … WHERE meta_key IN (…)` can lock - * the postmeta table for unacceptably long. + * Deletes by meta_id in per-key batches — a single `DELETE … WHERE meta_key + * IN (…)` can lock the postmeta table for unacceptably long. * * @return int Meta rows deleted. */ diff --git a/src/core/class-updater.php b/src/core/class-updater.php index d5c4c070..20b405df 100644 --- a/src/core/class-updater.php +++ b/src/core/class-updater.php @@ -1,10 +1,6 @@ [ term ids ] ]` (term-gated) per post type. 7.0.0 stores - * `[ 'mode' => 'all' ]` or `[ 'mode' => 'terms', 'terms' => [...] ]`. - * - * Reuses the tolerant readers on `Preselect`, so it is idempotent: already - * mode-based values pass through unchanged. Pre-release builds compare - * `< 7.0.0`, so this can re-run once on each dev-version bump — idempotency - * keeps those re-runs safe. + * Reuses the tolerant readers on `Preselect`, so it is idempotent — which + * keeps the re-runs pre-release builds trigger (`< 7.0.0` forever) safe. * * @since 7.0.0 */ @@ -116,10 +98,7 @@ public static function migrate_preselect_format(): void { /** * v7.0.0: remove every option marked deprecated in `Utils::get_options()`. * - * Targets the player styling, voice, and project options that v7.0.0 dropped - * (audio/video output now sourced from the BeyondWords project, not WP), as - * well as long-stale pre-v3 `speechkit_*` options. Multisite-aware because - * legacy installs may have stored these as site options. + * Multisite-aware because legacy installs may have stored these as site options. * * @since 7.0.0 */ @@ -134,13 +113,10 @@ public static function delete_deprecated_options(): void { } /** - * v7.0.0: the per-post "Display player" opt-out (`beyondwords_disabled`) is - * replaced by the Embed dropdown's "None" option. + * v7.0.0: migrate the legacy `beyondwords_disabled` opt-out to Embed "None". * - * Carry the flag forward so previously-hidden players stay hidden, then drop - * the legacy key. Only posts that explicitly disabled the player carry the - * flag, so the set is small; we still batch to keep memory bounded. We never - * overwrite an Embed value the post already has. + * Carries the flag forward so previously-hidden players stay hidden; never + * overwrites an existing Embed value, and batches to keep memory bounded. * * @since 7.0.0 */ @@ -182,8 +158,6 @@ public static function migrate_disabled_to_embed_none(): void { /** * v3.0.0: migrate `speechkit_settings.*` array values to top-level options. - * - * Skipped when `speechkit_settings` is empty (already migrated or never set). */ public static function migrate_settings(): void { $old_settings = get_option( 'speechkit_settings', [] ); @@ -210,8 +184,7 @@ public static function migrate_settings(): void { } /** - * Build a v3 preselect array from the v2 `speechkit_select_post_types` + - * `speechkit_selected_categories` fields. + * Build a v3 preselect array from the v2 post-type + category settings. * * @return array|false `false` when the legacy `speechkit_settings` option is missing. */ @@ -250,8 +223,9 @@ public static function construct_preselect_setting(): array|false { } /** - * v3.7.0: copy `speechkit_*` option keys to `beyondwords_*` while leaving - * the originals in place so plugin downgrades remain safe. + * v3.7.0: copy `speechkit_*` option keys to `beyondwords_*`. + * + * Originals are left in place so plugin downgrades remain safe. */ public static function rename_plugin_settings(): void { $api_key = get_option( 'speechkit_api_key' ); diff --git a/src/core/class-urls.php b/src/core/class-urls.php index b287f9e3..d53868bb 100644 --- a/src/core/class-urls.php +++ b/src/core/class-urls.php @@ -1,9 +1,6 @@ . +// Suppress the default plugin icon rendered after the title (a generic "plug") — +// the brand icon already sits before the label via . const NoIcon = () => null; export default class PrepublishPanel extends Component { - // Matches the Document Settings panel: only the "Generate audio" control. - // The Voice (Customize/Language/Voice) and Player (Embed) settings live in - // the plugin sidebar. + // Only the "Generate audio" control; Voice and Player settings live in the plugin sidebar. render() { return ( fields. \BeyondWords\Editor\Components\SettingsFields::nonce(); - // Player section: player/errors, Embed, Generate audio. self::heading( __( 'Player', 'speechkit' ) ); self::errors( $post ); @@ -100,27 +99,22 @@ public static function render_meta_box_content( $post ) { } } - // The Embed dropdown ("None" = no player) replaces the old Display - // player checkbox. + // The Embed dropdown ("None" = no player) replaces the old Display player checkbox. \BeyondWords\Editor\Components\SettingsFields::render_player_section( $post ); ( new \BeyondWords\Editor\Components\GenerateAudio() )::element( $post ); - // Content section: Source + Script template. echo '
'; self::heading( __( 'Content', 'speechkit' ) ); \BeyondWords\Editor\Components\SettingsFields::render_content_section( $post ); - // Format section: Output + Video template + Video size. echo '
'; self::heading( __( 'Format', 'speechkit' ) ); \BeyondWords\Editor\Components\SettingsFields::render_format_section( $post ); - // Voice section: Language + Voice + Model. echo '
'; self::heading( __( 'Voice', 'speechkit' ) ); ( new \BeyondWords\Editor\Components\SelectVoice() )::element( $post ); - // Data section: Content ID + Fetch button. echo '
'; self::heading( __( 'Data', 'speechkit' ) ); \BeyondWords\Editor\Components\ContentId::element( $post ); @@ -132,10 +126,6 @@ public static function render_meta_box_content( $post ) { /** * Print a settings-section heading. * - * The sections mirror the block editor's Player/Content/Format/Voice panels. - * A heading gives each group a label because the individual field labels - * (Source, Output, Embed…) aren't self-explanatory on their own. - * * @since 7.0.0 * * @param string $title The section heading. @@ -148,12 +138,10 @@ public static function heading( $title ) { } /** - * The "Pending review" message, shown instead of the audio player - * if the post status in WordPress is "pending". + * The "Pending review" message for posts with "pending" status. * - * This message is displayed instead of the player because the player - * cannot be rendered for audio which has been created - * with { published: false }. + * Shown instead of the player, which cannot render audio created with + * { published: false }. * * @since 3.7.0 * @since 6.0.0 Make static. @@ -219,19 +207,8 @@ public static function player_embed( $post = null ) { $content_id = \BeyondWords\Post\Meta::get_content_id( $post->ID ); $preview_token = \BeyondWords\Post\Meta::get_preview_token( $post->ID ); - /* - * Build the player SDK config as a PHP array, then JSON-encode it for the - * inline `onload` handler instead of concatenating the values by hand. - * - * The Content ID is editable post meta (Meta::get_content_id) and the - * preview token is supplied by the REST API, so both are untrusted in this - * output context. The HEX flags escape ' " < > & inside every string value - * as \uXXXX, so a value cannot break out of the JS string literal, the - * single-quoted attribute, or the surrounding '; /** @@ -24,10 +23,8 @@ public function init() do_action('wp_loaded'); - // Actions $this->assertEquals(10, has_action('init', array(Player::class, 'register_shortcodes'))); - // Filters $this->assertEquals(1000000, has_filter('the_content', array(Player::class, 'auto_prepend_player'))); $this->assertEquals(10, has_filter('newsstand_the_content', array(Player::class, 'auto_prepend_player'))); } @@ -123,12 +120,10 @@ public function auto_prepend_player() $output = Player::auto_prepend_player($content); - // We are now is_singular() so player should be prepended $this->assertSame(sprintf(self::PLAYER_HTML_FORMAT, 'auto') . $content, $output); - // Player UI = Disabled makes is_enabled() false, so auto_prepend_player() - // now short-circuits (before the has_custom_player() DOM scan) and returns - // the content unchanged — no player is prepended. + // Player UI = Disabled makes is_enabled() false, so auto_prepend_player() short-circuits + // (before the has_custom_player() DOM scan) and returns the content unchanged. update_option(Fields::OPTION_PLAYER_UI, Fields::PLAYER_UI_DISABLED); $this->assertSame($content, Player::auto_prepend_player($content)); delete_option(Fields::OPTION_PLAYER_UI); @@ -168,7 +163,6 @@ public function replace_legacy_custom_player($content, $expected) $output = Player::replace_legacy_custom_player($content); - // We are now is_singular() so player div should be replaced with player shortcode $this->assertSame($expected, $output); wp_reset_postdata(); @@ -274,10 +268,8 @@ public function replace_legacy_custom_player_provider() "

Before

\n
\n

After

", "

Before

\n
\n

After

", ], - // Note: HTML comments are NOT handled specially by the regex. - // This is a known limitation but is acceptable because: - // 1. It's extremely unlikely someone would put a player div in a comment - // 2. Even if replaced, the shortcode in a comment won't render (invisible to users) + // HTML comments are NOT handled specially by the regex — an acceptable known + // limitation: a shortcode inside a comment never renders anyway. 'HTML comment with player div - known limitation' => [ "

Before

\n\n

After

", "

Before

\n\n

After

", @@ -365,7 +357,6 @@ public function render_player_with_filter() $crawler = new Crawler($html); - //
$wrapper = $crawler->filter('#wrapper'); $this->assertCount(1, $wrapper); $this->assertSame("$post->ID", $wrapper->attr('data-post-id')); @@ -440,13 +431,11 @@ public function content_provider() 'Legacy player with contenteditable attribute' => [true, '

Before.

After.

'], 'New player shortcode' => [true, '

Before.

[beyondwords_player]

After.

'], 'New player shortcode with project_id attribute' => [true, '

Before.

[beyondwords_player project_id="1234"]

After.

'], - // SDK

After.

'], - // The SDK URL appearing as plain text passes the cheap substring guard - // but must still resolve to false via the XPath — proving the guard is a - // pre-filter, not a replacement for the parse. + // The SDK URL as plain text passes the cheap substring guard but must still + // resolve to false via the XPath — the guard is a pre-filter, not the parse. 'SDK URL in text only, no script tag' => [false, '

Listen via ' . $sdkUrl . '

'], ]; } diff --git a/tests/phpunit/post/test-content.php b/tests/phpunit/post/test-content.php index 6992109e..dc1c2b31 100644 --- a/tests/phpunit/post/test-content.php +++ b/tests/phpunit/post/test-content.php @@ -8,12 +8,10 @@ class ContentTest extends TestCase { public function setUp(): void { - // Before... parent::setUp(); - // A project id is required for Client::get_video_settings() (used when - // building the video_settings payload) to resolve a project and return - // the mocked defaults rather than false. + // Client::get_video_settings() needs a project id to resolve a project and + // return the mocked defaults rather than false. update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); } @@ -23,7 +21,6 @@ public function tearDown(): void delete_option('beyondwords_api_key'); delete_option('beyondwords_project_id'); - // Then... parent::tearDown(); } @@ -107,9 +104,8 @@ public function get_content_without_excluded_blocks($content, $expect) 'post_content' => $content, ]); - // Newer Gutenberg adds class="wp-block-paragraph" to rendered - // paragraphs; strip it so the assertion tracks block-exclusion logic, - // not core markup churn. + // Newer Gutenberg adds class="wp-block-paragraph"; strip it so the assertion + // tracks block-exclusion logic, not core markup churn. $actual = str_replace(' class="wp-block-paragraph"', '', Content::get_content_without_excluded_blocks($post)); $this->assertSame($expect, $actual); @@ -145,12 +141,9 @@ public function get_content_without_excluded_blocks_provider() /** * @test * - * Passing a post ID (int) must behave identically to passing the WP_Post - * object — the method resolves the argument internally like its siblings. - * - * Regression test: previously an int argument fell straight through to - * `$post->post_content`, emitting a PHP warning ("Attempt to read property - * on int") and returning an empty string instead of the post content. + * Passing a post ID (int) must behave identically to passing the WP_Post object. + * Regression: an int fell through to `$post->post_content`, emitting a PHP + * warning and returning an empty string instead of the content. */ public function get_content_without_excluded_blocks_accepts_post_id() { @@ -194,7 +187,7 @@ public function get_post_body_with_invalid_post_id() */ public function get_post_body($postType, $postContent, $expected) { - // A shortcode which tests both attribues and content + // A shortcode which tests both attributes and content add_shortcode('shortcode_test', function($atts, $content="") { return sprintf('%s, %s, %s', strtolower($atts['to_lower']), strtoupper($atts['to_upper']), $content); }); @@ -314,19 +307,15 @@ public function get_post_body_with_post_excerpt_provider() ]; } - /** - * - */ public function exported_data_helper($path) { $handle = fopen($path, 'r'); $output = []; - // Ignore first line of CSV + // Skip the CSV header line. fgetcsv($handle, 0, ',', '"', "\0"); - // Process remaining lines while (($data = fgetcsv($handle, 0, ',', '"', "\0")) !== false) { // Only test Posts with a state of "Processed" if (strtolower($data[11]) == 'processed') { @@ -337,9 +326,6 @@ public function exported_data_helper($path) return $output; } - /** - * - */ public function has_generate_audio_provider() { return [ @@ -362,7 +348,6 @@ public function has_generate_audio_provider() **/ public function get_content_params() { - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => 'Jane Smith', @@ -502,12 +487,10 @@ public function get_content_params_enables_video_when_output_includes_video() $this->assertArrayHasKey('video_settings', $body); $this->assertTrue($body['video_settings']['enabled']); - // The backend skips generation unless `variants` is non-empty, so we - // echo the project defaults. + // The backend skips generation unless `variants` is non-empty, so we echo the project defaults. $this->assertSame(['article', 'summary'], $body['video_settings']['variants']); - // The full project `sizes` are sent (each carrying width/height), with - // only the chosen size enabled. + // Full project `sizes` are sent (with width/height); only the chosen size is enabled. $this->assertSame( [ ['name' => 'landscape', 'width' => 1920, 'height' => 1080, 'enabled' => true], @@ -571,11 +554,8 @@ public function get_content_params_video_settings_echo_project_defaults_when_no_ * * @group getContentParams * - * Regression test: the plugin previously sent a partial payload (only - * `enabled` + `template.id` + `sizes[].name`/`enabled`) which left `variants` - * empty server-side, so the BeyondWords backend silently skipped video - * generation. The full object must always carry non-empty `variants` and - * `sizes` whose entries include `width`/`height`. + * Regression: a partial payload left `variants` empty server-side, so the backend + * silently skipped video generation. Must carry non-empty variants and full sizes. **/ public function get_content_params_video_settings_always_carry_variants_and_sized_entries() { @@ -639,7 +619,6 @@ public function get_content_params_sends_video_template_when_set() **/ public function get_content_params_for_pending_review_status() { - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => 'Jane Smith', @@ -672,13 +651,9 @@ public function get_content_params_for_pending_review_status() $this->assertArrayHasKey('published', $body); $this->assertFalse($body['published']); - /* - * Posts with "Pending Review" status will not have a `publish_date` date because - * get_post_time() returns `false` for posts which are "Pending Review". - */ + // No `publish_date`: get_post_time() returns false for "Pending Review" posts. $this->assertArrayNotHasKey('publish_date', $body); - // Set auto-publish to false update_option('beyondwords_project_auto_publish_enabled', false); $body = Content::get_content_params($postId); @@ -707,10 +682,8 @@ public function get_post_body_params_filter_test() ]); $filter = function($params, $postId) { - // Custom body $params['body'] = '[POST ID: ' . $postId. ']' . $params['body'] . '[ADDED AFTER]'; - // Custom metadata $params['metadata']->custom = $postId; return $params; @@ -763,13 +736,11 @@ public function get_all_taxonomies_and_terms() $flatTaxonomy = 'flat'; $hierarchicalTaxonomy = 'hierarchical'; - // Create flat taxonomy & terms (Tag-like) register_taxonomy($flatTaxonomy, 'post'); foreach (['flat1', 'flat2', 'flat3'] as $term) { wp_insert_term($term, $flatTaxonomy); } - // Create hierarchical taxonomy & terms (Category-like) register_taxonomy($hierarchicalTaxonomy, 'post', ['hierarchical' => true]); $hierarchicalTerms = []; foreach (['hier1', 'hier2', 'hier3'] as $term) { @@ -782,7 +753,6 @@ public function get_all_taxonomies_and_terms() wp_set_current_user($editor); - // Create a post with selected terms $postId = self::factory()->post->create([ 'post_title' => 'Testing Content::get_all_taxonomies_and_terms()', 'post_status' => 'publish', @@ -818,7 +788,6 @@ public function get_author_name() { $name = 'Jane Smith'; - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => $name, diff --git a/tests/phpunit/post/test-head.php b/tests/phpunit/post/test-head.php index 7debd830..c9ebbe2c 100644 --- a/tests/phpunit/post/test-head.php +++ b/tests/phpunit/post/test-head.php @@ -15,7 +15,6 @@ public function setUp(): void { parent::setUp(); - // Create a test post $this->postId = self::factory()->post->create([ 'post_title' => 'Test Post Title', 'post_author' => 1, @@ -54,7 +53,6 @@ public function init_registers_wp_head_hook(): void */ public function add_meta_tags_does_nothing_on_non_singular_page(): void { - // Simulate archive page $this->go_to('/'); $html = $this->capture_output(function () { @@ -69,11 +67,9 @@ public function add_meta_tags_does_nothing_on_non_singular_page(): void */ public function add_meta_tags_does_nothing_without_project_id(): void { - // Ensure no project ID is set delete_post_meta($this->postId, 'beyondwords_project_id'); delete_option('beyondwords_project_id'); - // Go to singular post $this->go_to(get_permalink($this->postId)); $html = $this->capture_output(function () { @@ -136,7 +132,6 @@ public function add_meta_tags_outputs_author_meta_tag(): void $this->assertStringContainsString('name="beyondwords-author"', $html); $this->assertStringContainsString('data-beyondwords-author=', $html); - // Should contain the author's display name $this->assertNotEmpty($html); } @@ -155,7 +150,7 @@ public function add_meta_tags_outputs_publish_date_meta_tag(): void $this->assertStringContainsString('name="beyondwords-publish-date"', $html); $this->assertStringContainsString('data-beyondwords-publish-date=', $html); - // Date should be in ISO 8601 format (contains 'T' for time separator) + // ISO 8601 expected — the 'T' is the date/time separator. $this->assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}T/', $html); } @@ -250,9 +245,8 @@ public function add_meta_tags_escapes_output_properly(): void Head::add_meta_tags(); }); - // Should escape HTML entities (WordPress may use smart quotes “ instead of literal quotes) + // No exact-string match: WordPress may swap in smart quotes (“) while escaping. $this->assertStringNotContainsString('