From 6bdf6182da418c5ef8c70a419abdb99f145a71fc Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sun, 19 Jul 2026 13:31:19 +0100 Subject: [PATCH 1/3] docs: refresh stale content in doc/ against the v7 codebase An audit of every doc/*.md against the current code found content that no longer matches the plugin. Each finding was verified against the source before rewriting. Substantially stale: - wp-config.md documented BEYONDWORDS_AUTO_SYNC_SETTINGS, which no longer exists anywhere in the plugin (the settings-sync feature was removed in v7). Removed it, and documented the six URL-override constants from Core\Urls that do work, including the empty-string strlen() guard. - plugin-features.md predated the v7 rewrite: it listed the "Display player" control that Embed replaced, and omitted most of the v7 surface. Corrections: - async-rest-migration.md claimed failed reads are not cached; they are negative-cached for CACHE_TTL_ON_ERROR. It also named a Client method that does not exist and listed shipped work as deferred. - wordpress-vip.md described the uninstaller's transient cleanup as deleting known keys; it is a SQL prefix sweep of the options table. - running-tests.md pointed at .wp-env.override.json for the tests env (correct file is .wp-env.tests.override.json), and had no Jest section. - legacy-meta-migration.md's downgrade guarantee did not note that the migration deletes beyondwords_disabled, and described play-audio/ and select-voice/ as removed when both still exist. - settings-internals.md omitted the 5-minute credential-fingerprinted validation throttle. - Smaller accuracy fixes in preselect-generate-audio.md (mode is 'off', not 'none'; documents the DEFAULT_VALUE preselect), rest-meta-visibility.md and video-settings-payload.md. Co-Authored-By: Claude Opus 4.8 --- doc/async-rest-migration.md | 110 +++++++++++++++++++++++--------- doc/legacy-meta-migration.md | 56 ++++++++++++---- doc/plugin-features.md | 24 +++++-- doc/preselect-generate-audio.md | 38 +++++++++-- doc/rest-meta-visibility.md | 36 +++++++---- doc/running-tests.md | 65 +++++++++++++++++-- doc/settings-internals.md | 29 +++++++-- doc/video-settings-payload.md | 8 ++- doc/wordpress-vip.md | 9 ++- doc/wp-config.md | 45 ++++++++++--- 10 files changed, 334 insertions(+), 86 deletions(-) diff --git a/doc/async-rest-migration.md b/doc/async-rest-migration.md index bebf55c0..d037cab4 100644 --- a/doc/async-rest-migration.md +++ b/doc/async-rest-migration.md @@ -3,28 +3,45 @@ Goal: keep blocking BeyondWords API requests off the hot paths — page render and the save request. -## Done (this PR) +## How it works ### Read caching -The editor-dropdown reads (`Client::get_languages()`, `get_voices()`, -`get_summarization_settings()`, `get_summarization_settings_templates()`, -`get_video_settings()`, `get_video_settings_templates()`) now cache successful -responses in transients (`Client::cached_get()`, 15-min TTL). These power both -the block-editor REST proxies and the classic-editor render, so both get fewer -and more resilient API calls. +The editor-dropdown reads go through `Client::cached_get()`, which stores the +decoded response in a transient: +[src/api/class-client.php](../src/api/class-client.php). + +- `Client::get_languages()` +- `Client::get_voices()` (and `get_voice()`, which filters that list) +- `Client::get_project()` +- `Client::get_video_settings()` +- `Client::get_summarization_settings_templates()` +- `Client::get_video_settings_templates()` + +These power both the block-editor REST proxies and the classic-editor render, so +both get fewer and more resilient API calls. - The cache key is salted with the project ID + API key, so changing either invalidates implicitly — no flush needed, which matters on object-cache hosts (e.g. VIP) where `_transient_*` rows can't be enumerated. -- Only 2xx array responses are cached; errors retry on the next call. +- Successful 2xx array responses are cached for `Client::CACHE_TTL` (15 + minutes). +- Failures are negative-cached: an empty array is stored for the shorter + `Client::CACHE_TTL_ON_ERROR` (2 minutes). Because `cached_get()` returns any + value it finds (`false !== $cached`), that stored `[]` short-circuits the next + fetch, so an unreachable API is probed at most once per interval rather than + on every render. +- Requests use `Client::DEFAULT_REQUEST_TIMEOUT` (3 seconds), except voices, + which uses `Client::VOICES_REQUEST_TIMEOUT` (8 seconds) — it is the one slow + endpoint, and the default would abandon (and then negative-cache) many + cold-cache fetches. ### Background create/update (VIP only) -The blocking `create_audio`/`update_audio` call on `save_post` made saving slow. -`Sync::on_add_or_update_post()` now defers `generate_audio_for_post()` to a -WP-Cron single event (`Sync::GENERATE_AUDIO_CRON_HOOK`) so the save request -returns immediately. +The blocking `create_audio`/`update_audio` call on `wp_after_insert_post` made +saving slow. `Sync::on_add_or_update_post()` defers `generate_audio_for_post()` +to a WP-Cron single event (`Sync::GENERATE_AUDIO_CRON_HOOK`) so the save request +returns immediately: [src/post/class-sync.php](../src/post/class-sync.php). - **Gated to WordPress VIP only** (`Sync::is_async_generation_enabled()`), detected by VIP-only cron symbols (`Automattic\WP\Cron_Control\Main`, @@ -38,11 +55,43 @@ returns immediately. - Error meta is written when the job runs (≈1 cron tick later) rather than inline, so the editor surfaces an API error on its next load. -## Deferred (follow-up) +### Background delete (VIP only) + +The trash and permanent-delete handlers use the same mechanism. +`Sync::on_trash_post()` and `Sync::on_delete_post()` call +`delete_audio_for_post_or_defer()`, which on VIP schedules +`Sync::DELETE_AUDIO_CRON_HOOK` via `schedule_audio_deletion()` and off VIP +deletes inline. + +- The cron event carries the BeyondWords project + content IDs, not a post ID: + the meta is wiped (trash) or the post row is gone (permanent delete) by the + time the job runs. `Sync::delete_audio_by_ids()` handles the event and calls + `Client::delete_audio_by_ids()`. +- Both handlers first call `unschedule_audio_generation()` to drop any pending + generate event for the post. +- The save-time "delete content" branch in `Sync::on_add_or_update_post()` + (`beyondwords_delete_content` meta) still deletes synchronously. + +### Bulk generation -### Classic-editor async reads +`Sync::bulk_generate_audio_for_posts()` backs the posts-list "Generate audio" +bulk action ([src/posts-list/class-bulk-edit.php](../src/posts-list/class-bulk-edit.php)). +It sets `beyondwords_generate_audio` meta on every selected post, then: -The block editor is already fully async (it populates dropdowns from the +- On VIP, queues one cron event per post and returns immediately. +- Off VIP, generates inline up to `Sync::BULK_GENERATE_SYNC_LIMIT` (10) — each + post is a blocking API call, so the cap keeps the batch inside execution + limits. Posts without a content ID are processed first, so re-running the + action makes forward progress. The remainder is reported as a `deferred` + count and surfaced by `Notices::deferred_notice()` + ([src/posts-list/class-notices.php](../src/posts-list/class-notices.php)); + their generate flag is already set, so re-running the action completes them. + +## Known limitations + +### Classic-editor server-side reads + +The block editor is fully async (it populates dropdowns from the `beyondwords/v1` REST proxies via React). The classic editor still calls the API **server-side during metabox render**: @@ -51,19 +100,20 @@ The block editor is already fully async (it populates dropdowns from the - `SettingsFields::render_format_section()` → `get_video_settings_templates()` + `get_video_settings()` Read caching (above) means these only hit the network on a cold cache, so the -per-render blocking is largely mitigated. Fully removing it means rendering empty -``-driven voice/model UI replaced the old -`select-voice` dropdown. +`display-player`'s preview role was folded into `preview-panel/`. +`src/editor/components/play-audio/` was **not** removed — it is still the +component that renders the player, imported by +[preview-panel/index.js](../src/editor/components/preview-panel/index.js) and by +[block/document-setting/index.js](../src/editor/block/document-setting/index.js). + +`src/editor/components/select-voice/` was also kept. It was refactored in place +into the model-first Language → Model → Voice selects — see +[class-select-voice.php](../src/editor/components/select-voice/class-select-voice.php) +and [classic-metabox.js](../src/editor/components/select-voice/classic-metabox.js) +— and remains the classic editor's voice UI; it was not replaced by a new +component. ## Downgrade safety DB rows for the deprecated keys are preserved until full uninstall, so a plugin -downgrade still renders posts using the legacy values. New posts written under -v7 use the new keys only; the legacy keys are left untouched. +downgrade still renders posts using the legacy values — with the exception of +`beyondwords_disabled`, which `migrate_disabled_to_embed_none()` deletes on +upgrade to v7.0.0. A downgrade after that migration will not see the old +opt-out flag; the equivalent state lives in `beyondwords_embed = 'none'`, which +pre-v7 code ignores. + +New posts written under v7 use the new keys only; the remaining legacy keys are +left untouched. diff --git a/doc/plugin-features.md b/doc/plugin-features.md index db3568e5..867316c4 100644 --- a/doc/plugin-features.md +++ b/doc/plugin-features.md @@ -1,22 +1,32 @@ # Features +The settings page has three tabs: Authentication, Integration and +Preferences ([src/settings/class-tabs.php](../src/settings/class-tabs.php)). + | Screen | Feature | Description | | --------- | ---------------------------------- | ----------- | -| Settings | ‘API Key’ field | Required field to make BeyondWords API requests. | -| Settings | ‘Project ID’ field | Required field to make BeyondWords API requests. | -| Settings | Process excerpts | Should ‘excerpts’ be read aloud in-between titles and body content? | -| Settings | Preselect ‘Generate audio’ | The ‘Generate audio’ checkbox in the BeyondWords sidebar will be automatically checked for selected post types. | +| Settings | ‘API key’ field | Required field to make BeyondWords API requests. Authentication tab. | +| Settings | ‘Project ID’ field | Required field to make BeyondWords API requests. Authentication tab. | +| Settings | ‘Integration method’ | ‘REST API’ (the default) or ‘Magic Embed’, for sites where a theme or plugin prevents saving content via the REST API. Integration tab. | +| Settings | ‘Excerpt’ | Include the post excerpt at the start of generated audio and video. Preferences tab. | +| Settings | ‘Player UI’ | ‘Enabled’, ‘Headless’ or ‘Disabled’. Preferences tab. | +| Settings | Preselect ‘Generate audio’ | ‘Generate audio’ starts checked for the selected post types, either for every post or only for posts with selected taxonomy terms. See [preselect-generate-audio.md](./preselect-generate-audio.md). Preferences tab. | | Posts | BeyondWords column | A table column to display which posts have players or errors. | -| Posts | Bulk action | ‘Generate audio’ for multiple posts at once. | +| Posts | Bulk actions | ‘Generate audio’ or ‘Delete audio’ for multiple posts at once, from the bulk actions dropdown or the inline bulk edit. | | Post Edit | ‘Generate audio’ | Optionally select to ‘Generate audio’ for the current post. | | Post Edit | Player preview | Display a preview of the audio player, or the processing status if the player is not available. | -| Post Edit | Display player | Show/hide the player for the current post. | -| Post Edit | BeyondWords sidebar | All plugin functionality and support in one place (Block Editor only). | +| Post Edit | ‘Embed’ | Pick which generated asset is shown on the post — ‘None’ shows no player. Replaces the ‘Display player’ checkbox removed in 7.0.0; see [legacy-meta-migration.md](./legacy-meta-migration.md). | +| Post Edit | ‘Output’ | Generate ‘Audio’, ‘Video’, or ‘Audio + video’. | +| Post Edit | ‘Video template’ and ‘Video size’ | Per-post video options, shown when the output includes video. Each defaults to ‘Project default’. | +| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’, an optional ‘Model’, and a ‘Voice’. | +| Post Edit | Per-block generation toggle | Exclude an individual block from the generated audio, via the block toolbar button or the block inspector panel. | +| Post Edit | BeyondWords sidebar / metabox | All plugin functionality and support in one place: a sidebar in the Block Editor, and an equivalent metabox in the Classic Editor. | | Post Edit | Prepublish panel | Confirm ‘Generate audio’ immediately before publishing. | | Post Edit | ‘Insert BeyondWords player’ button | Customize the audio player location while using the Classic Editor. | | Post Edit | ’BeyondWords’ block | Customize the audio player location while using the Block Editor. | | Post Edit | Inspect panel | View and copy the custom data our plugin adds to each post. | | - | JavaScript SDK (Player) | Automatically embed audio versions using a custom audio player. | | - | Settings notice | Display a notice if the BeyondWords Project ID or API Key is missing. | +| - | Site Health | A BeyondWords section in Site Health with the current settings, plugin version, registered (and deprecated) filters, and a REST API connectivity check. | | - | AMP plugin compatibility | Compatibility with the [official AMP plugin](https://en-gb.wordpress.org/plugins/amp/). | | - | WPGraphQL plugin compatibility | Compatibility with the [GraphQL plugin](https://www.wpgraphql.com/). | diff --git a/doc/preselect-generate-audio.md b/doc/preselect-generate-audio.md index 051b00a5..2b920791 100644 --- a/doc/preselect-generate-audio.md +++ b/doc/preselect-generate-audio.md @@ -21,10 +21,27 @@ One entry per compatible post type, each with a `mode`: 'genre' => [ 56 ], ], ], - // a missing post type (or mode 'none') means never preselect + // a missing post type means never preselect (`Preselect::MODE_OFF`) ] ``` +Only `all` and `terms` are honoured by `Preselect::get_mode()`; anything else +resolves to `off`. Nothing writes an explicit `mode: off` — `sanitize()` +removes the post type's entry instead. + +### Default value + +`Preselect::DEFAULT_VALUE` is `[ 'post' => [ 'mode' => 'all' ] ]`, and +`Preselect::get()` passes it as the `get_option()` fallback. So the setting is +not pure opt-in: until the option has been saved at least once, posts +preselect out of the box (other post types do not). + +`sanitize()` merges into the *raw* stored option rather than into `get()`, so +that default is not silently written into the first save. It also reads only +the post types and hierarchical taxonomies rendered in that request, so saving +the settings page never wipes config belonging to a post type or taxonomy that +is currently unregistered. + ### Legacy (pre-7.0.0) shapes `Updater::run()` migrates the old shapes on upgrade: @@ -39,11 +56,20 @@ 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. +The classic editor's checkbox writes an explicit `'1'`/`'0'` on save. The +server renders the correct initial state; its metabox JS +([classic-metabox.js](../src/editor/components/generate-audio/classic-metabox.js)) +then does two things: + +1. Keeps the caption beside the checkbox in step with the checkbox state, + swapping between its `data-label-enabled` and `data-label-disabled` text. +2. In `terms` mode only, re-checks the checkbox as the watched taxonomy terms + are ticked and unticked (`post_category[]` for categories, `tax_input[…][]` + for other taxonomies). The `change` listener is delegated, so terms added + through the "+ Add New Category" UI are covered too. + +Once the user toggles the checkbox themselves, the term sync stops (the +caption still follows), so a deliberate choice is never clobbered. ## Save-time decision diff --git a/doc/rest-meta-visibility.md b/doc/rest-meta-visibility.md index 1e0254ee..9caf3e72 100644 --- a/doc/rest-meta-visibility.md +++ b/doc/rest-meta-visibility.md @@ -2,7 +2,8 @@ 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()`). +(`register_meta()`, `register_rest_meta_visibility()`, +`hide_private_meta_from_rest()`). ## Registration model @@ -18,18 +19,29 @@ compatible post type, so that: - 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. +`show_in_rest` is set from the `current` key list in +[src/core/class-utils.php](../src/core/class-utils.php) +(`Utils::get_post_meta_keys()`) plus `Sync::REST_LEGACY_META_KEYS`; every other +deprecated key gets `show_in_rest => false` and never reaches the REST API. +That hidden set covers the legacy `speechkit_access_key`, the cached SpeechKit +API state (`speechkit_response`, `speechkit_info`, `speechkit_status`, +`speechkit_retries`, …) and the per-post player/voice keys deprecated in v7 +(`beyondwords_player_style`, `beyondwords_player_content`, +`beyondwords_title_voice_id`, `beyondwords_summary_voice_id`, +`beyondwords_disabled`). The voice and language keys the v7 editor still uses — +`beyondwords_body_voice_id`, `beyondwords_language_code`, +`beyondwords_language_id` — are `current`, so they *are* exposed. ## 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 +On an upgraded site, deprecated keys can hold a post's only BeyondWords data +until audio is regenerated under v7. The allow-list spans both eras: the +legacy SpeechKit keys (`speechkit_generate_audio`, `speechkit_project_id`, +`speechkit_podcast_id`, `speechkit_error_message`, `_speechkit_link`) and the +pre-v7 BeyondWords `beyondwords_podcast_id`. 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`) @@ -43,7 +55,9 @@ 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. +`Sync::register_rest_meta_visibility()` adds the filter on `rest_api_init` — +once per compatible post type, as `rest_prepare_{$post_type}` — 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/running-tests.md b/doc/running-tests.md index 4a4b1df8..a085caf5 100644 --- a/doc/running-tests.md +++ b/doc/running-tests.md @@ -25,7 +25,15 @@ npm run env:tests run cli wp option get siteurl ### 1. Ensure Mock API is enabled -The tests environment has `BEYONDWORDS_MOCK_API` set to `true` by default in [`.wp-env.tests.json`](../.wp-env.tests.json). To override anything per developer, create a `.wp-env.override.json` file (for example, using the `config` section). Restart with `npm run env:tests:start` after editing. +The tests environment has `BEYONDWORDS_MOCK_API` set to `true` by default in +[`.wp-env.tests.json`](../.wp-env.tests.json). To override anything per +developer, create a `.wp-env.tests.override.json` file (for example, using the +`config` section) — see +[`.wp-env.tests.override.json.example`](../.wp-env.tests.override.json.example). +Restart with `npm run env:tests:start` after editing. + +`.wp-env.override.json` is the equivalent override for the **development** env, +and does not affect the tests env. ### 2. Create test audio in BeyondWords dashboard @@ -63,18 +71,67 @@ dev env. `/tests/cypress/` -To open the Cypress app: +To open the Cypress app (Chrome): ```bash npm run cypress:open ``` -Or to run all tests in terminal (like we do in CI): +### Run only the affected specs + +The full suite takes 20+ minutes, so the normal workflow is to run just the +specs that exercise the source you changed. Every spec starts with a `@group` +and one or more `@covers` header tags, so grep for them: + +```bash +# Specs that cover a file or directory +grep -rl '@covers .*content-id' tests/cypress/e2e/ + +# All specs in a group +grep -rl '@group block-editor' tests/cypress/e2e/ +``` + +Then run only those: + +```bash +# One spec +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/block-editor/content-id.cy.js' + +# Multiple (comma-separated, no spaces) +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/block-editor/content-id.cy.js,tests/cypress/e2e/classic-editor/content-id.cy.js' + +# Whole group via glob +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/settings/*.cy.js' +``` + +The groups in use, and the header convention for new specs, are listed in +[AGENTS.md](../AGENTS.md). + +Running `npm run cypress:run` with no arguments runs the whole suite in +Cypress's bundled Electron browser against the tests env on port 8889. CI runs +the suite differently — via the `cypress-io/github-action` with +`browser: chrome` against its own WordPress install — so pass +`--browser chrome` locally if you need to match CI's browser. + +## Jest unit tests + +Jest covers the pure JavaScript helpers and the `@wordpress/data` settings +store — logic that is awkward or unreliable to reach through the editor UI. +Test files live next to the source they cover as `*.test.js`, for example +[src/settings/store/index.test.js](../src/settings/store/index.test.js) and +[src/editor/components/inspect-panel/helpers.test.js](../src/editor/components/inspect-panel/helpers.test.js). ```bash -npm run cypress:run +npm run test:unit + +# Re-run on change +npm run test:unit:watch ``` +Both scripts wrap `wp-scripts test-unit-js`, which supplies the Jest config, so +there is no `jest.config.js` in the repo. The tests need no wp-env, database or +built assets. CI runs `npm run test:unit` as its own **Jest** job. + ## PHPUnit tests `/tests/phpunit/` diff --git a/doc/settings-internals.md b/doc/settings-internals.md index 2214120b..1db43781 100644 --- a/doc/settings-internals.md +++ b/doc/settings-internals.md @@ -20,11 +20,32 @@ transient. 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 +Validation runs when the Authentication tab loads, and is throttled by the +`beyondwords_api_connection_checked` transient +(`Utils::CONNECTION_CHECK_TRANSIENT`), whose TTL is +`Utils::CONNECTION_CHECK_TTL` — 5 minutes. The transient stores an `md5()` +**fingerprint of the credentials**, not a boolean: + +- Inside the window, a stored fingerprint matching the current project ID and + API key means the last result for *those* credentials is trusted and no + request is made. +- The fingerprint is recorded whatever the outcome, so a failing or down API is + throttled too rather than retried on every page load. +- Editing the API key or project ID changes the fingerprint, busting the + throttle and forcing an immediate re-check. + +How the flag itself is updated: + +- If the project ID or API key is missing, the flag is deleted outright and no + request (or throttle write) happens at all. +- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT` — 3 + seconds, the WordPress VIP ceiling for a blocking remote request — 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. +- Only authentication failures clear it: a 401 (in `Client::call_api()`, + [src/api/class-client.php](../src/api/class-client.php)) or a 403 (in the + validation itself). Re-validation then happens on the next Authentication + tab load that is not throttled — immediately if the credentials were edited, + otherwise once the 5-minute window expires. diff --git a/doc/video-settings-payload.md b/doc/video-settings-payload.md index bd639dc0..8750d904 100644 --- a/doc/video-settings-payload.md +++ b/doc/video-settings-payload.md @@ -14,8 +14,8 @@ carries all of: - 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. +A partial payload carrying only `template.id` and `sizes[].name`/`enabled` +leaves `variants` empty server-side, so no video is produced. ## Approach: mirror the dashboard @@ -28,7 +28,9 @@ video, so the plugin does the same: 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; + - when the post sets a Video size, that size becomes the only enabled + size; when the post leaves Video size at "Project default", every size + keeps the project's own `enabled` flag; - 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 diff --git a/doc/wordpress-vip.md b/doc/wordpress-vip.md index d7093ca0..bfb815b8 100644 --- a/doc/wordpress-vip.md +++ b/doc/wordpress-vip.md @@ -25,8 +25,13 @@ 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 uninstaller's transient cleanup + ([src/core/class-uninstaller.php](../src/core/class-uninstaller.php), + `cleanup_plugin_transients()`) is a direct SQL sweep of the options table + by prefix, deleting both the `_transient_beyondwords_%` value rows and the + `_transient_timeout_beyondwords_%` timeout rows. With an external object + cache there are no such rows to match, so the query deletes nothing and the + cached entries expire via their TTL instead; - 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. diff --git a/doc/wp-config.md b/doc/wp-config.md index f7ae08f4..14c5aee0 100644 --- a/doc/wp-config.md +++ b/doc/wp-config.md @@ -3,20 +3,49 @@ To override default behaviour, the following constants can be defined in `wp-config.php`. -## BEYONDWORDS_AUTO_SYNC_SETTINGS +## Behaviour -Should we auto-sync the settings to/from the BeyondWords dashboard? -Defaults to `true`. +### BEYONDWORDS_AUTOREGENERATE + +Setting this to a falsy value stops the plugin regenerating audio for posts +that already have a BeyondWords content ID. The check runs in +[src/post/class-sync.php](../src/post/class-sync.php) only after an existing +content ID has been found, so initial generation for posts without audio is +unaffected. Defaults to regenerating. ```php -define('BEYONDWORDS_AUTO_SYNC_SETTINGS', false); +define('BEYONDWORDS_AUTOREGENERATE', false); ``` -## BEYONDWORDS_AUTOREGENERATE +The value is reported in the "BeyondWords - Text-to-Speech" section of the Site +Health Info screen, see +[src/site-health/class-site-health.php](../src/site-health/class-site-health.php). + +## URL overrides + +The URLs the plugin calls are defined as class constants in +[src/core/class-urls.php](../src/core/class-urls.php). Each one can be +overridden by defining a constant of the same name in `wp-config.php`. These +are intended for local development and testing against non-production +environments. -Should we autoregenerate the audio when an existing Post is updated? -Defaults to `true`. +An override is only applied if it is a non-empty string — the accessors guard +with `strlen()`, so defining a constant as `''` leaves the built-in default in +place. + +| Constant | Default | +| --- | --- | +| `BEYONDWORDS_API_URL` | `https://api.beyondwords.io/v1` | +| `BEYONDWORDS_BACKEND_URL` | `''` (empty) | +| `BEYONDWORDS_JS_SDK_URL` | `https://proxy.beyondwords.io/npm/@beyondwords/player@latest/dist/umd.js` | +| `BEYONDWORDS_AMP_PLAYER_URL` | `https://audio.beyondwords.io/amp/%d?podcast_id=%s` | +| `BEYONDWORDS_AMP_IMG_URL` | `https://beyondwords-cdn-b7fyckdeejejb6dj.a03.azurefd.net/assets/logo.svg` | +| `BEYONDWORDS_DASHBOARD_URL` | `https://dash.beyondwords.io` | + +`BEYONDWORDS_AMP_PLAYER_URL` is a format string: `%d` is the project ID and +`%s` is the content ID. ```php -define('BEYONDWORDS_AUTOREGENERATE', false); +define('BEYONDWORDS_API_URL', 'https://api.staging.example.com/v1'); +define('BEYONDWORDS_DASHBOARD_URL', 'https://dash.staging.example.com'); ``` From 323507bcc33c7f88447620e793824dc1b925aa39 Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sun, 19 Jul 2026 13:45:56 +0100 Subject: [PATCH 2/3] docs: apply fact-check corrections from the review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the refreshed docs, correcting statements the first pass got wrong or overstated. Each correction was re-verified against the source. - plugin-features.md: no control is labelled "Generate audio" — the shipped toggle's caption reads "Generation enabled"/"Generation disabled". Model is a filter that is never submitted, not a saved override. Added the Content section (Source, Script template) and noted that Integration and Preferences stay hidden until the credentials validate. - settings-internals.md: the flag holds an ISO-8601 UTC string, not a timestamp, and gates the whole admin/editor bootstrap in Core\Plugin::init(), not just the settings tabs. - wp-config.md: BEYONDWORDS_BACKEND_URL has no callers in src/, so it is documented as legacy rather than a working override. - legacy-meta-migration.md: the migration only matches rows whose value is '1'; other values are left untouched. display-player was a checkbox row, not a preview component. - rest-meta-visibility.md: the Custom Fields panel is hidden by the is_protected_meta filter, not by register_meta(); the hidden-key list is illustrative, not exhaustive. - getting-started.md: added the host PHP/Composer prerequisite the rewrite depended on, and corrected the checks to run on commit, not push. - running-tests.md: BEYONDWORDS_MOCK_API applies to the wp-env site (Cypress); PHPUnit reads its own bootstrap constants. - code-quality-checks.md: grumphp.yml does not set max_body_width, so GrumPHP's default of 72 is enforced on every body line. Co-Authored-By: Claude Opus 4.8 --- doc/async-rest-migration.md | 14 +++-- doc/code-quality-checks.md | 102 ++++++++++++++++++++++++++++---- doc/getting-started.md | 47 +++++++++++++-- doc/legacy-meta-migration.md | 35 +++++++---- doc/plugin-features.md | 9 ++- doc/preselect-generate-audio.md | 8 ++- doc/rest-meta-visibility.md | 38 ++++++++---- doc/running-tests.md | 28 ++++++--- doc/settings-internals.md | 27 +++++---- doc/wp-config.md | 9 ++- 10 files changed, 246 insertions(+), 71 deletions(-) diff --git a/doc/async-rest-migration.md b/doc/async-rest-migration.md index d037cab4..4d015d2e 100644 --- a/doc/async-rest-migration.md +++ b/doc/async-rest-migration.md @@ -50,8 +50,11 @@ returns immediately: [src/post/class-sync.php](../src/post/class-sync.php). WP-Cron from a real system cron, so the deferred job fires promptly. - Overridable via the `beyondwords_async_generate_audio` filter. - The cron job re-runs `generate_audio_for_post()`, which re-checks eligibility - and captures the API response → writes `content_id` / `preview_token` / voice - meta exactly as the synchronous path does. No webhook/poll needed. + and passes the API response to `process_response()` → writes + `beyondwords_project_id`, `beyondwords_content_id` and + `beyondwords_preview_token` exactly as the synchronous path does. Language and + voice meta are deliberately not copied back from the response: those keys hold + explicit editor choices. No webhook/poll needed. - Error meta is written when the job runs (≈1 cron tick later) rather than inline, so the editor surfaces an API error on its next load. @@ -104,9 +107,10 @@ per-render blocking is largely mitigated. Removing it entirely is partly done. [src/editor/components/select-voice/classic-metabox.js](../src/editor/components/select-voice/classic-metabox.js) -already fetches from the REST proxies on load — `hydrate()` re-fetches the saved -language's voices, and `applyProjectDefaultLanguage()` fetches `/projects/{id}` -— so the model/voice dropdowns could be rendered empty and filled client-side. +already fetches from the REST proxies — `hydrate()` re-fetches the saved +language's voices on load, and `applyProjectDefaultLanguage()` fetches +`/projects/{id}` when Customize is switched on — so the model/voice dropdowns +could be rendered empty and filled client-side. The language list has no client-side fetch, and [src/editor/components/settings-fields/classic-metabox.js](../src/editor/components/settings-fields/classic-metabox.js) does no fetching at all, so its template and video-size dropdowns would need one diff --git a/doc/code-quality-checks.md b/doc/code-quality-checks.md index 9b4036f2..1e864e68 100644 --- a/doc/code-quality-checks.md +++ b/doc/code-quality-checks.md @@ -6,27 +6,107 @@ before every commit. > When somebody commits changes, GrumPHP will run some tests on the committed > code. If the tests fail, you won't be able to commit your changes. -The GrumPHP config file is at [`grumphp.yml`](../grumphp.yml). +The GrumPHP config file is at [`grumphp.yml`](../grumphp.yml). It declares a +list of tasks and a set of testsuites, and each git hook runs one named +testsuite: the `commit-msg` hook runs `git_commit_msg`, and the `pre-commit` +hook runs `git_pre_commit`. Tasks that are not in the testsuite for a hook do +not run on commit. -The checks are: +`stop_on_failure` is enabled, so the run halts at the first failing task. + +## What runs on commit + +The `git_commit_msg` testsuite runs one task: + +- `git_commit_message`: Cap the width of the commit subject line +(`max_subject_width: 120`) and do not require the subject to be capitalised +(`enforce_capitalized_subject: false`). `max_body_width` is not set in +`grumphp.yml`, so GrumPHP's default of 72 applies and every body line wider +than that fails the hook + +The `git_pre_commit` testsuite runs five tasks: -- `git_commit_message`: Require a brief commit message (under 120 chars) - `composer`: Validate `composer.json` -- `composer_normalize`: Keep `composer.json` nice and tidy +- `composer_normalize`: Keep `composer.json` nice and tidy (4-space indent) - `phpcs`: Run [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) -using the config at [`.phpcs.xml`](../.phpcs.xml), to the *WordPress-VIP-Go* standard. -- `phplint`: Check source files for syntax errors -- `test_phpunit`: PHPUnit tests -- `coverage_check`: PHPUnit code coverage +over the plugin source using the config at [`.phpcs.xml`](../.phpcs.xml) +- `lint_plugins`: Run PHP_CodeSniffer over the companion plugins in +[`plugins/`](../plugins) via the `lint:plugins` composer script +- `phplint`: Check source files for syntax errors, excluding `vendor` and +`plugins` If you see a big "nope" then fix the reported problems before attempting to commit your code again. -## Bypassing the checks +### The `phpcs` ruleset + +[`.phpcs.xml`](../.phpcs.xml) scans [`src`](../src), +[`speechkit.php`](../speechkit.php), [`uninstall.php`](../uninstall.php) and +[`index.php`](../index.php). It layers four WordPress rulesets in increasing +order of strictness — *WordPress-Core*, *WordPress-Docs*, *WordPress-Extra* and +*WordPress-VIP-Go* — plus *VariableAnalysis* for unused, undefined and +re-declared variables. Later rulesets override earlier ones. + +On top of those it sets some project conventions (short array syntax, K&R brace +placement, the `speechkit` text domain) and silences a set of rules, many of +them PHPDoc requirements. Most are grouped under a comment giving the +reason — legacy code under `src/` that predates the strict ruleset, and PHPDoc +rules whose retroactive satisfaction would be a sprint of mechanical typing +without functional value — and a few later ones carry their own note. Two +array-spacing sniffs +(`WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound` and +`NormalizedArrays.Arrays.ArrayBraceSpacing.SpaceBeforeArrayCloserSingleLine`) +are silenced without a stated reason. + +The `phpcs` task ignores `/tests/*`, `/plugins/*` and +`/wp-tests-config-sample.php`. The `plugins/` directory is covered separately by +`lint_plugins`, which uses [`plugins/.phpcs.xml`](../plugins/.phpcs.xml) — a +smaller ruleset based on *WordPress-VIP-Go* alone. + +## What exists but is not run on commit + +Two further tasks are declared in [`grumphp.yml`](../grumphp.yml) but are not +members of any testsuite: + +- `test_phpunit`: runs the `test` composer script (PHPUnit, then the coverage +check) +- `coverage_check`: runs the `test:coverage-check` composer script, which reads +`tests/phpunit/_report/clover.xml` and fails below 85% coverage + +Because they are in no testsuite, neither git hook runs them. They only run when +GrumPHP is invoked with no `--testsuite` option (`./vendor/bin/grumphp run`), +which runs every declared task. In practice PHPUnit is run directly — see +[running-tests.md](running-tests.md). + +## Bypassing the checks Adding `-n` as an argument to your `git commit` command will skip the GrumPHP -checks for the current commit on your development machine, but -**GrumPHP always runs in the GitHub Actions workflow**. +checks for the current commit on your development machine, but the checks also +run in the GitHub Actions workflow, so skipping them locally only defers the +failure. + +## What runs in GitHub Actions + +[`.github/workflows/main.yml`](../.github/workflows/main.yml) does not run all +of GrumPHP. The `grumphp` job runs a single testsuite: + +```sh +./vendor/bin/grumphp run --testsuite code_quality +``` + +The `code_quality` testsuite holds the same five tasks as `git_pre_commit`: +`composer`, `composer_normalize`, `phpcs`, `lint_plugins` and `phplint`. + +The remaining checks are separate jobs in the same workflow: + +- `lint-js`: ESLint over `src` via `npm run lint:js`, after asserting that +prettier resolves to wp-prettier +- `jest-tests`: Jest unit tests via `npm run test:unit` +- `phpunit-tests`: PHPUnit across a PHP 8.0 / 8.5 matrix, followed by +`composer test:coverage-check` +- `plugin-check`: the WordPress plugin-check action, run against the built +plugin ZIP +- `cypress-tests`: Cypress end-to-end tests against a real WordPress install We urge you to fix any reported problems locally before pushing your commits. diff --git a/doc/getting-started.md b/doc/getting-started.md index f1534900..65ebd8b5 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -36,13 +36,42 @@ npm install npm run build ``` -## 5. Install PHP dependencies +## 5. Get PHP & Composer + +The next step runs Composer on your host machine, so you need a host PHP and a +host Composer. [composer.json](../composer.json) requires `php >=8.0`. + +[Get Composer](https://getcomposer.org/download/). On macOS both are available +via Homebrew: + +```bash +brew install php composer +``` + +## 6. Install PHP dependencies + +Run Composer on your host machine, not inside a container: ```bash -npm run composer -- install +composer install ``` -## 6. Start wp-env +This must happen before wp-env starts. `wp-env start` activates every plugin +listed in `.wp-env.json`, including this one, and +[speechkit.php](../speechkit.php) requires `vendor/autoload.php` at load time, +so activation fatals if `vendor/` is missing. + +The `npm run composer` script is only usable once the environment exists — it +is `wp-env run cli ... composer`, so it dispatches into a container that +`npm run env:start` has not created yet. Use it for later Composer commands, +such as regenerating the classmap autoloader after adding, removing or renaming +files in `src/`: + +```bash +npm run composer -- dump-autoload +``` + +## 7. Start wp-env Ensure that Docker is running, then start both environments: @@ -74,13 +103,19 @@ WordPress development site started at http://localhost:8889/ Well done, you should now have a functional wp-env development environment for our plugin. -Log into WordPress admin and activate our plugin to get started. +The plugin is already active: wp-env activates every plugin listed in +`.wp-env.json`, and `./` (this repo) is one of them. + +Log into WordPress admin and go to Settings → BeyondWords to enter the API key +and Project ID that connect the plugin to your BeyondWords project. See +[src/settings/class-settings.php](../src/settings/class-settings.php). * The default WordPress credentials are Username: `admin`, Password: `password` * The default database credentials are: Username: `root`, Password: `password` -Before you push any commits in Git our [automated code quality checks](../doc/code-quality-checks.md) -need to pass. See [running tests](../doc/running-tests.md) to get the tests running in your wp-env. +Our [automated code quality checks](code-quality-checks.md) run on every commit +via a git hook, and need to pass before the commit is accepted. See +[running tests](running-tests.md) to get the tests running in your wp-env. ## Further reading diff --git a/doc/legacy-meta-migration.md b/doc/legacy-meta-migration.md index 5d73a169..cb35b8b8 100644 --- a/doc/legacy-meta-migration.md +++ b/doc/legacy-meta-migration.md @@ -18,10 +18,10 @@ That move does not unregister the keys. `Sync::register_meta()` in sanitised and `Sync::is_protected_meta()` still keeps them out of the legacy Custom Fields panel. What the move changes is `show_in_rest`, which is set from the `current` list plus `Sync::REST_LEGACY_META_KEYS` — a short allow-list of -deprecated keys (`beyondwords_podcast_id`, the `speechkit_*` ids and error -message, `_speechkit_link`) that the editor still reads as a fallback on -upgraded sites. Deprecated keys outside that allow-list get -`show_in_rest => false`. +deprecated keys (`beyondwords_podcast_id`, `speechkit_generate_audio`, +`speechkit_project_id`, `speechkit_podcast_id`, `speechkit_error_message`, +`_speechkit_link`) that the editor still reads as a fallback on upgraded sites. +Deprecated keys outside that allow-list get `show_in_rest => false`. ### Keys deprecated @@ -34,8 +34,8 @@ upgraded sites. Deprecated keys outside that allow-list get DB rows are **kept** on plugin deactivation and upgrade — only removed on full uninstall — so a plugin downgrade still finds the values. The one exception is -`beyondwords_disabled`, which the v7.0.0 migration deletes as it converts it -(see below). +the `beyondwords_disabled = '1'` rows, which the v7.0.0 migration deletes as it +converts them (see below). ### `beyondwords_disabled` → `beyondwords_embed` @@ -47,7 +47,15 @@ To carry existing opt-outs forward, the v7.0.0 migration in `beyondwords_disabled = '1'` in batches of 100. A post only gets `beyondwords_embed = 'none'` if its `beyondwords_embed` is still empty — an Embed value already set is authoritative and is left alone. Either way the -legacy flag is deleted, so this key does not survive the upgrade. +legacy flag is deleted from that post. Rows holding any other value (`''`, +`'0'`) are not matched by the query and stay in place, where both pre-v7 code +and `Meta::get_disabled()` read them as "not disabled". + +The query passes `'post_type' => 'any'`, which excludes post types registered +with `exclude_from_search`. Those types can still be BeyondWords-compatible — +`Settings\Utils::get_compatible_post_types()` builds its list from an +unfiltered `get_post_types()` call — so posts of such a type keep their +`beyondwords_disabled` row through the upgrade. `Player::is_enabled()` still honours `beyondwords_disabled` as a fallback for any post the migration hasn't reached (e.g. after a downgrade/re-upgrade). It @@ -60,7 +68,10 @@ non-empty `beyondwords_embed` and only falls back to `Meta::get_disabled()`. - `src/editor/components/player-content/` - `src/editor/components/display-player/` (replaced by the Embed setting) -`display-player`'s preview role was folded into `preview-panel/`. +`display-player` rendered the "Display player" checkbox as a row inside +`preview-panel/`; that row was dropped and the visibility control now lives in +the Embed select in +[settings-panel/player-section.js](../src/editor/components/settings-panel/player-section.js). `src/editor/components/play-audio/` was **not** removed — it is still the component that renders the player, imported by [preview-panel/index.js](../src/editor/components/preview-panel/index.js) and by @@ -77,10 +88,10 @@ component. DB rows for the deprecated keys are preserved until full uninstall, so a plugin downgrade still renders posts using the legacy values — with the exception of -`beyondwords_disabled`, which `migrate_disabled_to_embed_none()` deletes on -upgrade to v7.0.0. A downgrade after that migration will not see the old -opt-out flag; the equivalent state lives in `beyondwords_embed = 'none'`, which -pre-v7 code ignores. +the `beyondwords_disabled = '1'` rows, which `migrate_disabled_to_embed_none()` +deletes on upgrade to v7.0.0. A downgrade after that migration will not see the +old opt-out flag on those posts; the equivalent state lives in +`beyondwords_embed = 'none'`, which pre-v7 code ignores. New posts written under v7 use the new keys only; the remaining legacy keys are left untouched. diff --git a/doc/plugin-features.md b/doc/plugin-features.md index 867316c4..07779744 100644 --- a/doc/plugin-features.md +++ b/doc/plugin-features.md @@ -2,6 +2,8 @@ The settings page has three tabs: Authentication, Integration and Preferences ([src/settings/class-tabs.php](../src/settings/class-tabs.php)). +Only Authentication is shown until the saved credentials have validated +against the API — Integration and Preferences appear after that. | Screen | Feature | Description | | --------- | ---------------------------------- | ----------- | @@ -13,15 +15,16 @@ Preferences ([src/settings/class-tabs.php](../src/settings/class-tabs.php)). | Settings | Preselect ‘Generate audio’ | ‘Generate audio’ starts checked for the selected post types, either for every post or only for posts with selected taxonomy terms. See [preselect-generate-audio.md](./preselect-generate-audio.md). Preferences tab. | | Posts | BeyondWords column | A table column to display which posts have players or errors. | | Posts | Bulk actions | ‘Generate audio’ or ‘Delete audio’ for multiple posts at once, from the bulk actions dropdown or the inline bulk edit. | -| Post Edit | ‘Generate audio’ | Optionally select to ‘Generate audio’ for the current post. | +| Post Edit | Generate audio toggle | Turn audio generation on or off for the current post. The caption reflects the state: ‘Generation enabled’ or ‘Generation disabled’. | | Post Edit | Player preview | Display a preview of the audio player, or the processing status if the player is not available. | | Post Edit | ‘Embed’ | Pick which generated asset is shown on the post — ‘None’ shows no player. Replaces the ‘Display player’ checkbox removed in 7.0.0; see [legacy-meta-migration.md](./legacy-meta-migration.md). | +| Post Edit | ‘Source’ and ‘Script template’ | Generate from the ‘Post’, a ‘Script’, or ‘Post + script’. ‘Script template’ is shown only when the source includes a script, and defaults to ‘Project default’. | | Post Edit | ‘Output’ | Generate ‘Audio’, ‘Video’, or ‘Audio + video’. | | Post Edit | ‘Video template’ and ‘Video size’ | Per-post video options, shown when the output includes video. Each defaults to ‘Project default’. | -| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’, an optional ‘Model’, and a ‘Voice’. | +| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’ and a ‘Voice’. A ‘Model’ dropdown appears when the chosen language offers more than one model; it filters the voice list and is not saved — the voice id carries the model. | | Post Edit | Per-block generation toggle | Exclude an individual block from the generated audio, via the block toolbar button or the block inspector panel. | | Post Edit | BeyondWords sidebar / metabox | All plugin functionality and support in one place: a sidebar in the Block Editor, and an equivalent metabox in the Classic Editor. | -| Post Edit | Prepublish panel | Confirm ‘Generate audio’ immediately before publishing. | +| Post Edit | Prepublish panel | Confirm audio generation immediately before publishing. | | Post Edit | ‘Insert BeyondWords player’ button | Customize the audio player location while using the Classic Editor. | | Post Edit | ’BeyondWords’ block | Customize the audio player location while using the Block Editor. | | Post Edit | Inspect panel | View and copy the custom data our plugin adds to each post. | diff --git a/doc/preselect-generate-audio.md b/doc/preselect-generate-audio.md index 2b920791..aeb752fd 100644 --- a/doc/preselect-generate-audio.md +++ b/doc/preselect-generate-audio.md @@ -25,9 +25,11 @@ One entry per compatible post type, each with a `mode`: ] ``` -Only `all` and `terms` are honoured by `Preselect::get_mode()`; anything else -resolves to `off`. Nothing writes an explicit `mode: off` — `sanitize()` -removes the post type's entry instead. +`all` and `terms` are the only valid values of the `mode` key; any other `mode` +resolves to `off`. `Preselect::get_mode()` is also tolerant of the pre-7.0.0 +shapes described below, so a legacy `'1'` still reads as `all` and a bare +taxonomy array as `terms` before the migration has run. Nothing writes an +explicit `mode: off` — `sanitize()` removes the post type's entry instead. ### Default value diff --git a/doc/rest-meta-visibility.md b/doc/rest-meta-visibility.md index 9caf3e72..cc5bacdf 100644 --- a/doc/rest-meta-visibility.md +++ b/doc/rest-meta-visibility.md @@ -13,9 +13,7 @@ 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)); +- writes are authorised (`auth_callback` requires `edit_posts`); - only the keys the block editor actually reads/writes over REST get `show_in_rest => true`. @@ -23,15 +21,26 @@ compatible post type, so that: [src/core/class-utils.php](../src/core/class-utils.php) (`Utils::get_post_meta_keys()`) plus `Sync::REST_LEGACY_META_KEYS`; every other deprecated key gets `show_in_rest => false` and never reaches the REST API. -That hidden set covers the legacy `speechkit_access_key`, the cached SpeechKit -API state (`speechkit_response`, `speechkit_info`, `speechkit_status`, -`speechkit_retries`, …) and the per-post player/voice keys deprecated in v7 -(`beyondwords_player_style`, `beyondwords_player_content`, -`beyondwords_title_voice_id`, `beyondwords_summary_voice_id`, -`beyondwords_disabled`). The voice and language keys the v7 editor still uses — +That hidden set covers, among others, the legacy `speechkit_access_key`, the +cached SpeechKit API state (`speechkit_response`, `speechkit_info`, +`speechkit_status`, `speechkit_retries`, `speechkit_error`), the per-post +player/voice keys deprecated in v7 (`beyondwords_player_style`, +`beyondwords_player_content`, `beyondwords_title_voice_id`, +`beyondwords_summary_voice_id`, `beyondwords_disabled`) and the remaining +hash/state bookkeeping keys (`beyondwords_hash`, `speechkit_hash`, +`speechkit_disabled`, `speechkit_updated_at`, `publish_post_to_speechkit`, +`_speechkit_text`). The voice and language keys the v7 editor still uses — `beyondwords_body_voice_id`, `beyondwords_language_code`, `beyondwords_language_id` — are `current`, so they *are* exposed. +Hiding meta from the legacy "Custom Fields" panel is a separate mechanism, not +a side effect of registration: `Sync::init()` filters `is_protected_meta`, and +the callback `Sync::is_protected_meta()` flags every key in +`Utils::get_post_meta_keys( 'all' )` as protected. Unlike registration it is +not scoped per post type — the two only share the key list. The block editor's +panel can break when plugin meta renders there — see +[gutenberg#23078](https://github.com/WordPress/gutenberg/issues/23078). + ## Legacy keys still exposed (`Sync::REST_LEGACY_META_KEYS`) On an upgraded site, deprecated keys can hold a post's only BeyondWords data @@ -40,9 +49,14 @@ legacy SpeechKit keys (`speechkit_generate_audio`, `speechkit_project_id`, `speechkit_podcast_id`, `speechkit_error_message`, `_speechkit_link`) and the pre-v7 BeyondWords `beyondwords_podcast_id`. 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/)). +play/preview controls and the "Generate audio" toggle all read them (see the +components under [src/editor/components/](../src/editor/components/)). + +`_speechkit_link` is the odd one out: no editor component renders it. Its real +consumer is server-side — `Meta::get_podcast_id()` in +[src/post/class-meta.php](../src/post/class-meta.php) parses the legacy `/a/`, +`/e/` or `/m/` player URL out of it via `get_post_meta()`, which needs no +`show_in_rest`. ## Hiding private meta from public REST (`Sync::REST_PRIVATE_META_KEYS`) diff --git a/doc/running-tests.md b/doc/running-tests.md index a085caf5..7d3b8fda 100644 --- a/doc/running-tests.md +++ b/doc/running-tests.md @@ -18,22 +18,34 @@ To run an arbitrary `wp-env` command against a specific environment: npm run env -- run cli wp option get siteurl # tests -npm run env:tests run cli wp option get siteurl +npm run env:tests -- run cli wp option get siteurl ``` ## Prerequisites ### 1. Ensure Mock API is enabled -The tests environment has `BEYONDWORDS_MOCK_API` set to `true` by default in -[`.wp-env.tests.json`](../.wp-env.tests.json). To override anything per -developer, create a `.wp-env.tests.override.json` file (for example, using the -`config` section) — see -[`.wp-env.tests.override.json.example`](../.wp-env.tests.override.json.example). -Restart with `npm run env:tests:start` after editing. +The tests **site** — the WordPress install Cypress drives — has +`BEYONDWORDS_MOCK_API` set to `true` in +[`.wp-env.tests.json`](../.wp-env.tests.json), so Cypress is mocked out of the +box. To change any of that per developer, add a `config` section to +`.wp-env.tests.override.json` — see +[`.wp-env.tests.override.json.example`](../.wp-env.tests.override.json.example) +— and restart with `npm run env:tests:start`. + +PHPUnit does **not** inherit that setting. It boots against the WordPress test +framework's own `wp-tests-config.php`, which never sees wp-env's `wp-config.php` +defines, so [`tests/phpunit/bootstrap.php`](../tests/phpunit/bootstrap.php) +reads `BEYONDWORDS_MOCK_API` and the `BEYONDWORDS_TESTS_*` secrets from +environment variables (CI), falling back to the `config` section of +`.wp-env.tests.override.json` (local). That is why the example file sets +`BEYONDWORDS_MOCK_API` to `true`, and why you must create the override file +(step 3) — without it PHPUnit hits the real API. The bootstrap re-reads the +JSON on every run, so no restart is needed for PHPUnit. `.wp-env.override.json` is the equivalent override for the **development** env, -and does not affect the tests env. +and does not affect the tests env — see +[`.wp-env.override.json.example`](../.wp-env.override.json.example). ### 2. Create test audio in BeyondWords dashboard diff --git a/doc/settings-internals.md b/doc/settings-internals.md index 1db43781..5e8fff84 100644 --- a/doc/settings-internals.md +++ b/doc/settings-internals.md @@ -16,9 +16,14 @@ 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()`. +`beyondwords_valid_api_connection` stores an **ISO-8601 UTC datetime string** +(`gmdate( \DateTime::ATOM )`) recording the last successful credential +validation; only its truthiness is ever read. It gates visibility of the +Integration / Preferences tabs via `Tabs::get_visible_tabs()`, and also gates +the admin/editor UI bootstrap in `Core\Plugin::init()` +([src/core/class-plugin.php](../src/core/class-plugin.php)), which returns +early without it — so a missing flag hides the editor sidebar, posts-list +column and bulk-edit UI as well as the settings tabs. Validation runs when the Authentication tab loads, and is throttled by the `beyondwords_api_connection_checked` transient @@ -31,8 +36,10 @@ Validation runs when the Authentication tab loads, and is throttled by the request is made. - The fingerprint is recorded whatever the outcome, so a failing or down API is throttled too rather than retried on every page load. -- Editing the API key or project ID changes the fingerprint, busting the - throttle and forcing an immediate re-check. +- Saving a changed API key or project ID changes the fingerprint, busting the + throttle and forcing an immediate re-check. The fingerprint is computed from + the stored options, so typing in a field has no effect until the values are + saved. How the flag itself is updated: @@ -44,8 +51,8 @@ How the flag itself is updated: - 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()`, - [src/api/class-client.php](../src/api/class-client.php)) or a 403 (in the - validation itself). Re-validation then happens on the next Authentication - tab load that is not throttled — immediately if the credentials were edited, - otherwise once the 5-minute window expires. +- Once a request is made, only authentication failures clear it: a 401 (in + `Client::call_api()`, [src/api/class-client.php](../src/api/class-client.php)) + or a 403 (in the validation itself). Re-validation then happens on the next + Authentication tab load that is not throttled — immediately if the saved + credentials changed, otherwise once the 5-minute window expires. diff --git a/doc/wp-config.md b/doc/wp-config.md index 14c5aee0..378e1aa5 100644 --- a/doc/wp-config.md +++ b/doc/wp-config.md @@ -23,7 +23,7 @@ Health Info screen, see ## URL overrides -The URLs the plugin calls are defined as class constants in +The URLs the plugin uses are defined as class constants in [src/core/class-urls.php](../src/core/class-urls.php). Each one can be overridden by defining a constant of the same name in `wp-config.php`. These are intended for local development and testing against non-production @@ -45,6 +45,13 @@ place. `BEYONDWORDS_AMP_PLAYER_URL` is a format string: `%d` is the project ID and `%s` is the content ID. +`BEYONDWORDS_BACKEND_URL` is legacy: `Urls::get_backend_url()` has no callers in +`src/`, so overriding it changes nothing at runtime. Note that the build also +reads a `BEYONDWORDS_BACKEND_URL` *environment* variable via `DefinePlugin` in +[webpack.config.js](../webpack.config.js) (defaulting to +`https://audio.beyondwords.io`); that is a separate, build-time value and is +unrelated to the `wp-config.php` constant. + ```php define('BEYONDWORDS_API_URL', 'https://api.staging.example.com/v1'); define('BEYONDWORDS_DASHBOARD_URL', 'https://dash.staging.example.com'); From be72307e5f5f29f2a0a7c8fc3f5da3dbeaeb0ab2 Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sun, 19 Jul 2026 13:47:39 +0100 Subject: [PATCH 3/3] docs: cover the Accent and Native voice filters added in #567 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #567 reworked voice selection, adding 'Accent' and 'Native' alongside 'Model'. All three narrow the voice list without being saved — the post stores only beyondwords_language_code and beyondwords_body_voice_id. Re-checked the other docs that touch voice selection against #567: async-rest-migration.md's claims still hold (get_voices() still routes through cached_get() with VOICES_REQUEST_TIMEOUT, classic-metabox.js still fetches voices in hydrate() and /projects/{id} in applyProjectDefaultLanguage(), and all six cited REST routes exist). Co-Authored-By: Claude Opus 4.8 --- doc/plugin-features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/plugin-features.md b/doc/plugin-features.md index 07779744..370a2e6c 100644 --- a/doc/plugin-features.md +++ b/doc/plugin-features.md @@ -21,7 +21,7 @@ against the API — Integration and Preferences appear after that. | Post Edit | ‘Source’ and ‘Script template’ | Generate from the ‘Post’, a ‘Script’, or ‘Post + script’. ‘Script template’ is shown only when the source includes a script, and defaults to ‘Project default’. | | Post Edit | ‘Output’ | Generate ‘Audio’, ‘Video’, or ‘Audio + video’. | | Post Edit | ‘Video template’ and ‘Video size’ | Per-post video options, shown when the output includes video. Each defaults to ‘Project default’. | -| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’ and a ‘Voice’. A ‘Model’ dropdown appears when the chosen language offers more than one model; it filters the voice list and is not saved — the voice id carries the model. | +| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’ and a ‘Voice’. ‘Accent’, ‘Native’ and ‘Model’ narrow the voice list; they are filters only and are not saved — the post stores just the language code and voice id. | | Post Edit | Per-block generation toggle | Exclude an individual block from the generated audio, via the block toolbar button or the block inspector panel. | | Post Edit | BeyondWords sidebar / metabox | All plugin functionality and support in one place: a sidebar in the Block Editor, and an equivalent metabox in the Classic Editor. | | Post Edit | Prepublish panel | Confirm audio generation immediately before publishing. |