diff --git a/.distignore b/.distignore index 292b5ce..8df20df 100644 --- a/.distignore +++ b/.distignore @@ -39,6 +39,8 @@ eslint.config.cjs mise.toml phpunit.xml phpunit.xml.dist +playwright.config.js +.env.build.example README.md docker-compose.extra-hosts.example.yml docker-compose.extra-hosts.yml diff --git a/.env.build.example b/.env.build.example new file mode 100644 index 0000000..236a01f --- /dev/null +++ b/.env.build.example @@ -0,0 +1,4 @@ +# Copy to .env.build (gitignored) for `npm run build:zip:release`. +# Mirrors the BEEHIIV_REGISTRATION_TOKEN GitHub Actions secret used in +# .github/workflows/deploy.yml to produce a zip matching the .org release. +BEEHIIV_REGISTRATION_TOKEN= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e8fce90 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,138 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - master + +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: "PHPUnit (PHP ${{ matrix.php-version }}, WP ${{ matrix.wp-version }})" + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + # Keep in sync with "Requires PHP" / "Requires at least" in readme.txt. + php-version: ["7.4", "8.0", "8.1", "8.2", "8.3"] + wp-version: ["6.8", "latest"] + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer:v2 + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Install npm dependencies + run: npm ci + + - name: Pin wp-env PHP/WP core version + run: npm run env:set-version -- --php=${{ matrix.php-version }} --wp=${{ matrix.wp-version }} + + - name: Patch archived Debian base image (PHP 7.4 / 8.0) + if: matrix.php-version == '7.4' || matrix.php-version == '8.0' + run: scripts/ci-patch-debian-base.sh ${{ matrix.php-version }} + + - name: Start wp-env + run: npx wp-env start + + - name: Run PHPUnit + run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test" + + - name: Generate PHPUnit dashboard + if: always() + run: node tests/phpunit/report/generate-dashboard.js --label="PHP ${{ matrix.php-version }} / WP ${{ matrix.wp-version }}" + + - name: Upload PHPUnit dashboard + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpunit-report-php${{ matrix.php-version }}-wp${{ matrix.wp-version }} + path: tests/phpunit/test-results + retention-days: 7 + + - name: Stop wp-env + if: always() + run: npx wp-env stop + + e2e: + name: E2E tests + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "7.4" + tools: composer:v2 + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Install npm dependencies + run: npm ci + + - name: Build plugin assets + run: npm run build + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Patch archived Debian base image (PHP 7.4) + run: scripts/ci-patch-debian-base.sh 7.4 + + - name: Start wp-env + run: npx wp-env start + + - name: Run Playwright E2E tests + run: npx playwright test + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: tests/e2e/test-results + retention-days: 7 + + - name: Stop wp-env + if: always() + run: npx wp-env stop diff --git a/.gitignore b/.gitignore index e0aa800..de57c03 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,12 @@ coverage tests/php-coverage .phpunit.result.cache tests/e2e/test-results +tests/phpunit/test-results # Directories/files that may appear in your environment .wp-env.override.json docker-compose.extra-hosts.yml +.env.build .DS_Store Thumbs.db wp-cli.local.yml diff --git a/.wp-env.json b/.wp-env.json index ed8b1b7..0312c3b 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -5,7 +5,17 @@ "themes": [ "https://downloads.wordpress.org/theme/twentytwentyfive.zip" ], "config": { "WP_DEBUG": true, - "SCRIPT_DEBUG": true + "SCRIPT_DEBUG": true, + "AUTOMATIC_UPDATER_DISABLED": true }, - "testsEnvironment": false + "port": 8890, + "testsEnvironment": true, + "env": { + "tests": { + "port": 8891, + "mappings": { + "wp-content/mu-plugins": "./tests/e2e/plugins" + } + } + } } diff --git a/README.md b/README.md index bb894e1..62dcc4d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,22 @@ Without Doppler, define the same constants in `wp-config.php`, or put them in a If your local app uses HTTPS with a private CA, set `BEEHIIV_SSLVERIFY` to `false` (or install that CA in the WordPress container). PHP’s cURL does not trust host mkcert/Caddy CAs by default. +### Backward-compatibility testing (wp-env) + +Point wp-env at a specific PHP / WordPress core version to check compatibility with older or newer combinations than your default local setup, and get an HTML dashboard for that combination: + +```bash +npm run test:compat -- --php=8.1 --wp=6.8 +``` + +That pins wp-env to PHP 8.1 / WP 6.8 (leaving it set for subsequent `npm run env:start` runs — clear with `npm run env:set-version -- --clear`), **destroys and recreates the wp-env environment** so the combination starts from a genuinely clean install, runs PHPUnit, and writes `tests/phpunit/test-results/php8.1-wp6.8/dashboard.html` — pass/fail counts, a per-class breakdown, and failure details. Run it again with different `--php`/`--wp` values to get a separate dashboard per combination; nothing gets overwritten. `--wp` accepts a WordPress.org release number (e.g. `6.8`), `latest` (wp-env's own default), or `nightly`. + +⚠️ `test:compat` deletes your current wp-env database and content for this project (`wp-env destroy`) every time it runs. Only use it against a wp-env instance you're fine wiping — not one with dev content you want to keep. + +To only change the pinned version without wiping anything, use `npm run env:set-version -- --php=8.1 --wp=6.8` directly — it writes to the gitignored `.wp-env.override.json`, so it never touches tracked config, and wp-env rebuilds the WordPress core files (not the database) the next time it starts. Because the database is left as-is, wp-admin will show the "database needs to be updated" screen after switching to a different WP version; run `npm run env:destroy` first if you want a clean database for manual browsing too. `npm run test:php:report` regenerates a dashboard from an existing JUnit report (e.g. after `composer test`) without re-running anything. + +CI runs the full PHP × WP matrix on every PR (see `.github/workflows/test.yml`) and uploads one `phpunit-report-php{X}-wp{Y}` dashboard artifact per combination. + ## Development Commands | Command | Purpose | @@ -77,8 +93,11 @@ If your local app uses HTTPS with a private CA, set `BEEHIIV_SSLVERIFY` to `fals | `npm run env:start` | Start wp-env (+ optional extra_hosts overlay) | | `npm run env:start:doppler` | Start with Doppler-mounted OAuth/API overrides | | `npm run env:hosts` | Re-apply `docker-compose.extra-hosts.yml` if present | +| `npm run env:set-version` | Pin wp-env's PHP/WP core version (`--php` / `--wp`) | | `npm run env:stop` | Stop wp-env | | `npm run env:destroy` | Tear down wp-env | +| `npm run test:compat` | Run PHPUnit for one PHP/WP combo + its own dashboard | +| `npm run test:php:report` | Regenerate a PHPUnit dashboard from an existing run | | `npm run lint` | Lint JS, CSS, and PHP (or individually) | ## Project layout diff --git a/beehiiv.php b/beehiiv.php index 2238512..d26973f 100644 --- a/beehiiv.php +++ b/beehiiv.php @@ -2,7 +2,7 @@ /** * Plugin Name: beehiiv * Description: Official beehiiv WordPress plugin. Publish posts as newsletters and grow your audience with beehiiv. - * Version: 1.0.2 + * Version: 1.1.0 * Author: beehiiv * Text Domain: beehiiv * Domain Path: /languages @@ -18,7 +18,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'BEEHIIV_VERSION' ) ) { - define( 'BEEHIIV_VERSION', '1.0.2' ); + define( 'BEEHIIV_VERSION', '1.1.0' ); } if ( ! defined( 'BEEHIIV_PLUGIN_FILE' ) ) { diff --git a/composer.json b/composer.json index 930c922..60dea71 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,9 @@ "Beehiiv\\": "includes/" } }, + "scripts": { + "test": "phpunit -c phpunit.xml.dist" + }, "minimum-stability": "stable", "require": { "php": ">=7.4" diff --git a/includes/API/Cache.php b/includes/API/Cache.php index 422bcd2..f10543c 100644 --- a/includes/API/Cache.php +++ b/includes/API/Cache.php @@ -91,6 +91,18 @@ public static function set_publications( array $items ): void { set_transient( self::PUBLICATIONS_KEY, $items, self::TTL ); } + /** + * Clear the cached publications list so the next read fetches it from beehiiv. + * + * @since x.x.x + * + * @return void + */ + public static function delete_publications(): void { + + delete_transient( self::PUBLICATIONS_KEY ); + } + /** * Cached templates for a publication. * diff --git a/includes/API/Client.php b/includes/API/Client.php index 8414e5d..8be53f1 100644 --- a/includes/API/Client.php +++ b/includes/API/Client.php @@ -41,6 +41,13 @@ final class Client { */ private static $allow_retry = true; + /** + * Retry-After header from the most recent response, in seconds, if any. + * + * @var int|null + */ + private static $last_retry_after = null; + /** * HTTP timeout in seconds for beehiiv API requests. * @@ -51,13 +58,14 @@ final class Client { /** * Make an authenticated GET request to beehiiv v2. * - * @param string $path Relative API path (e.g. `/publications`). - * @param array $query Query parameters. + * @param string $path Relative API path (e.g. `/publications`). + * @param array $query Query parameters. + * @param int $timeout HTTP timeout in seconds; 0 uses the default. * @return array * @since 1.0.0 */ - public static function request( string $path, array $query = [] ): array { - return self::send( 'GET', $path, $query, null ); + public static function request( string $path, array $query = [], int $timeout = 0 ): array { + return self::send( 'GET', $path, $query, null, $timeout ); } /** @@ -80,6 +88,16 @@ public static function get_last_wp_error(): ?string { return self::$last_wp_error; } + /** + * Retry-After header from the most recent response, in seconds. + * + * @return int|null Null when the response had no numeric Retry-After header. + * @since 1.0.0 + */ + public static function get_last_retry_after(): ?int { + return self::$last_retry_after; + } + /** * Make an authenticated POST request with a JSON body. * @@ -125,10 +143,11 @@ public static function patch( string $path, array $body, array $query = [] ): ar * @param string $path Relative API path. * @param array $query Query parameters. * @param array|null $body JSON body for POST and PATCH; null for GET and DELETE. + * @param int $timeout HTTP timeout in seconds; 0 uses the default. * @return array * @since 1.0.0 */ - private static function send( string $method, string $path, array $query, ?array $body ): array { + private static function send( string $method, string $path, array $query, ?array $body, int $timeout = 0 ): array { $access_token = Manager::get_access_token(); $url = OAuthConfig::get_api_base_url() . $path; @@ -139,7 +158,7 @@ private static function send( string $method, string $path, array $query, ?array $args = [ 'method' => $method, - 'timeout' => self::REQUEST_TIMEOUT, + 'timeout' => $timeout > 0 ? $timeout : self::REQUEST_TIMEOUT, 'sslverify' => OAuthConfig::should_verify_ssl(), 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, @@ -157,6 +176,10 @@ private static function send( string $method, string $path, array $query, ?array self::$last_status_code = is_wp_error( $res ) ? null : (int) wp_remote_retrieve_response_code( $res ); self::$last_wp_error = is_wp_error( $res ) ? $res->get_error_message() : null; + self::$last_retry_after = is_wp_error( $res ) + ? null + : self::parse_retry_after( wp_remote_retrieve_header( $res, 'retry-after' ) ); + $raw = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res ); $json = json_decode( $raw, true ); @@ -165,7 +188,7 @@ private static function send( string $method, string $path, array $query, ?array $refreshed = TokenRefresher::refresh(); if ( ! is_wp_error( $refreshed ) ) { - return self::send( $method, $path, $query, $body ); + return self::send( $method, $path, $query, $body, $timeout ); } } @@ -173,4 +196,27 @@ private static function send( string $method, string $path, array $query, ?array return is_array( $json ) ? $json : []; } + + /** + * Seconds to wait from a Retry-After header, given as seconds or as an HTTP date. + * + * @param string|array $value Header value. + * @return int|null Seconds (0 or more), or null when absent or unparsable. + * @since 1.0.0 + */ + private static function parse_retry_after( $value ): ?int { + $value = is_array( $value ) ? (string) reset( $value ) : trim( (string) $value ); + + if ( '' === $value ) { + return null; + } + + if ( is_numeric( $value ) ) { + return max( 0, (int) $value ); + } + + $timestamp = strtotime( $value ); + + return false === $timestamp ? null : max( 0, $timestamp - time() ); + } } diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index 4653c54..4b0aa92 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -15,6 +15,7 @@ * Create posts (newsletters) in a publication. * * @link https://developers.beehiiv.com/api-reference/posts/create + * @link https://developers.beehiiv.com/api-reference/posts/show * @link https://developers.beehiiv.com/api-reference/posts/delete * @link https://developers.beehiiv.com/api-reference/posts/update * @since 1.0.0 @@ -80,14 +81,70 @@ public static function create( string $publication_id, array $payload ): array { ]; } + /** + * Fetch a single post from a beehiiv publication. + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @return array{success: bool, post: array, error: string} + * @since x.x.x + */ + public static function get( string $publication_id, string $post_id ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + + if ( '' === $publication_id || '' === $post_id ) { + return [ + 'success' => false, + 'post' => [], + 'error' => 'Publication ID or post ID is empty.', + ]; + } + + $path = sprintf( + '/publications/%s/posts/%s', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::request( $path ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + return [ + 'success' => false, + 'post' => [], + 'error' => $wp_error, + ]; + } + + $status_code = Client::get_last_status_code(); + + if ( null !== $status_code && $status_code > 299 ) { + return [ + 'success' => false, + 'post' => [], + 'error' => self::format_error_message( $response, $status_code ), + ]; + } + + return [ + 'success' => true, + 'post' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [], + 'error' => '', + ]; + } + /** * Delete or archive a post in a beehiiv publication. * - * Confirmed posts are archived; drafts are permanently deleted. + * Confirmed posts are archived; drafts are permanently deleted. beehiiv answers + * 202 while a Send API post is still processing, in which case nothing was + * deleted yet; `status_code` lets callers tell that apart from a 204. * * @param string $publication_id Publication ID. * @param string $post_id beehiiv post ID. - * @return array{success: bool, error: string} + * @return array{success: bool, error: string, status_code: int|null, retry_after: int|null} * @since 1.0.0 */ public static function delete( string $publication_id, string $post_id ): array { @@ -96,8 +153,10 @@ public static function delete( string $publication_id, string $post_id ): array if ( '' === $publication_id || '' === $post_id ) { return [ - 'success' => false, - 'error' => 'Publication ID or post ID is empty.', + 'success' => false, + 'error' => 'Publication ID or post ID is empty.', + 'status_code' => null, + 'retry_after' => null, ]; } @@ -112,31 +171,173 @@ public static function delete( string $publication_id, string $post_id ): array if ( null !== $wp_error ) { return [ - 'success' => false, - 'error' => $wp_error, + 'success' => false, + 'error' => $wp_error, + 'status_code' => null, + 'retry_after' => null, ]; } $status_code = Client::get_last_status_code(); + $retry_after = Client::get_last_retry_after(); if ( null !== $status_code && 204 === $status_code ) { return [ - 'success' => true, - 'error' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } if ( null !== $status_code && $status_code > 299 ) { return [ - 'success' => false, - 'error' => self::format_error_message( $response, $status_code ), + 'success' => false, + 'error' => self::format_error_message( $response, $status_code ), + 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } return [ - 'success' => true, - 'error' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, + 'retry_after' => $retry_after, + ]; + } + + /** + * Whether a post created through the Send API has finished being created. + * + * The beehiiv API creates posts in the background: the create call returns + * the id right away, and fetching the post answers 202 until creation + * finishes, then 200. A 404 means creation failed. Rate limiting, server + * errors and transport errors also count as pending, so callers keep waiting + * rather than testing a post that may not exist yet. + * + * @link https://developers.beehiiv.com/api-reference/posts/get + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @param int $timeout HTTP timeout in seconds; 0 uses the client default. + * @return array{state: string, retry_after: int|null, status_code: int|null, error: string} + * The state is one of `ready`, `pending`, `failed`, or `unknown`. + * @since 1.0.0 + */ + public static function get_creation_state( string $publication_id, string $post_id, int $timeout = 0 ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + $result = [ + 'state' => 'unknown', + 'retry_after' => null, + 'status_code' => null, + 'error' => '', ]; + + if ( '' === $publication_id || '' === $post_id ) { + $result['error'] = 'Publication ID or post ID is empty.'; + return $result; + } + + $path = sprintf( + '/publications/%s/posts/%s', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::request( $path, [], $timeout ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['state'] = 'pending'; + $result['error'] = $wp_error; + return $result; + } + + $status_code = Client::get_last_status_code(); + $result['status_code'] = $status_code; + $result['retry_after'] = Client::get_last_retry_after(); + + if ( 200 === $status_code ) { + $result['state'] = 'ready'; + } elseif ( 202 === $status_code || 429 === $status_code || ( null !== $status_code && $status_code >= 500 ) ) { + $result['state'] = 'pending'; + $result['error'] = 202 === $status_code ? '' : self::format_error_message( $response, $status_code ); + } elseif ( 404 === $status_code ) { + $result['state'] = 'failed'; + $result['error'] = self::format_error_message( $response, $status_code ); + } elseif ( null !== $status_code ) { + $result['error'] = self::format_error_message( $response, $status_code ); + } + + return $result; + } + + /** + * Send a test email of a beehiiv post to one or more addresses. + * + * @link https://developers.beehiiv.com/api-reference/posts/test-send + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @param array $recipient_emails Addresses to send the test to. + * @return array{ + * success: bool, + * status_code: int|null, + * remaining_test_sends: int|null, + * reset_at: int|null, + * error: string + * } + * @since 1.0.0 + */ + public static function test_send( string $publication_id, string $post_id, array $recipient_emails ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + $result = [ + 'success' => false, + 'status_code' => null, + 'remaining_test_sends' => null, + 'reset_at' => null, + 'error' => '', + ]; + + if ( '' === $publication_id || '' === $post_id ) { + $result['error'] = 'Publication ID or post ID is empty.'; + return $result; + } + + $path = sprintf( + '/publications/%s/posts/%s/test_sends', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::post( $path, [ 'recipient_emails' => array_values( $recipient_emails ) ] ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['error'] = $wp_error; + return $result; + } + + $status_code = Client::get_last_status_code(); + $result['status_code'] = $status_code; + + if ( null !== $status_code && $status_code > 299 ) { + $result['error'] = self::format_error_message( $response, $status_code ); + return $result; + } + + $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : []; + $remaining = $data['remaining_test_sends'] ?? null; + $reset_at = $data['reset_at'] ?? null; + + $result['success'] = true; + $result['remaining_test_sends'] = is_numeric( $remaining ) ? (int) $remaining : null; + $result['reset_at'] = is_numeric( $reset_at ) ? (int) $reset_at : null; + + return $result; } /** diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 20c34e1..8b831b4 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -67,7 +67,7 @@ private static function register_page_settings_section( string $page_slug ): voi add_settings_field( 'beehiiv_publication_id', - __( 'Publication', 'beehiiv' ), + __( 'Default publication', 'beehiiv' ), [ self::class, 'render_publication_id_field' ], $page_slug, self::PAGE_SETTINGS_SECTION_ID, @@ -147,8 +147,18 @@ static function ( $row ) use ( $selected ) { +

- +

+ * @var array */ private const META_KEYS = [ - Meta::SEND_TO_NEWSLETTER => [ + Meta::SEND_TO_NEWSLETTER => [ 'type' => 'boolean', 'default' => false, ], - Meta::SEND_TO_NEWSLETTER_DATE => [ + Meta::SEND_TO_NEWSLETTER_DATE => [ 'type' => 'string', 'default' => '', ], - Meta::SEND_TO_NEWSLETTER_SNIPPET => [ + Meta::SEND_TO_NEWSLETTER_SNIPPET => [ 'type' => 'boolean', 'default' => false, ], - Meta::BEEHIIV_POST_TEMPLATE_ID => [ + Meta::BEEHIIV_POST_TEMPLATE_ID => [ 'type' => 'string', 'default' => '', ], - Meta::BEEHIIV_POST_ID => [ + Meta::BEEHIIV_PUBLICATION_ID => [ + 'type' => 'string', + 'default' => '', + ], + Meta::BEEHIIV_POST_ID => [ 'type' => 'string', 'default' => '', 'readonly' => true, ], - Meta::BEEHIIV_SCHEDULED_AT => [ + Meta::BEEHIIV_SCHEDULED_AT => [ 'type' => 'string', 'default' => '', 'readonly' => true, ], - Meta::NEWSLETTER_ERROR => [ + Meta::NEWSLETTER_ERROR => [ 'type' => 'string', 'default' => '', 'readonly' => true, ], - Meta::NEWSLETTER_ERROR_TYPE => [ + Meta::NEWSLETTER_ERROR_TYPE => [ 'type' => 'string', 'default' => '', 'readonly' => true, ], + Meta::NEWSLETTER_TITLE => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + Meta::NEWSLETTER_SUBTITLE => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + Meta::NEWSLETTER_SHOW_TITLE_IN_EMAIL => [ + 'type' => 'boolean', + 'default' => false, + ], ]; /** @@ -96,6 +115,7 @@ final class PostSettings { */ public static function register_meta(): void { add_filter( 'rest_prepare_post', [ self::class, 'prepare_rest_post' ], 10, 3 ); + add_filter( 'rest_request_before_callbacks', [ self::class, 'drop_readonly_meta_from_request' ], 10, 3 ); foreach ( self::META_KEYS as $key => $config ) { $show_in_rest = true; @@ -110,24 +130,28 @@ public static function register_meta(): void { ]; } - register_post_meta( - self::POST_TYPE, - $key, - [ - 'show_in_rest' => $show_in_rest, - 'single' => true, - 'type' => $config['type'], - 'default' => $config['default'], - 'auth_callback' => [ self::class, 'authorize_meta' ], - ] - ); + $args = [ + 'show_in_rest' => $show_in_rest, + 'single' => true, + 'type' => $config['type'], + 'default' => $config['default'], + 'auth_callback' => [ self::class, 'authorize_meta' ], + ]; + + if ( ! empty( $config['sanitize'] ) ) { + $args['sanitize_callback'] = $config['sanitize']; + } + + register_post_meta( self::POST_TYPE, $key, $args ); } } /** * Whether the current user may read or write a beehiiv post meta key. * - * Newsletter send settings require `publish_posts`; other keys allow `edit_posts`. + * Readonly keys are never writable via REST. Newsletter send settings and + * the post's publication and newsletter wording require `publish_posts`; other + * keys allow `edit_posts`. * * @param bool $allowed Whether the user can add or edit the meta key. * @param string $meta_key Meta key. @@ -136,10 +160,18 @@ public static function register_meta(): void { * @since 1.0.0 */ public static function authorize_meta( $allowed, $meta_key, $post_id ) { + if ( ! empty( self::META_KEYS[ $meta_key ]['readonly'] ) ) { + return false; + } + $writable_keys = [ Meta::SEND_TO_NEWSLETTER, Meta::SEND_TO_NEWSLETTER_DATE, Meta::SEND_TO_NEWSLETTER_SNIPPET, + Meta::BEEHIIV_PUBLICATION_ID, + Meta::NEWSLETTER_TITLE, + Meta::NEWSLETTER_SUBTITLE, + Meta::NEWSLETTER_SHOW_TITLE_IN_EMAIL, ]; if ( in_array( $meta_key, $writable_keys, true ) ) { @@ -253,6 +285,47 @@ private static function enqueue_build_style( ?string $version = null ): void { ); } + /** + * Drop readonly beehiiv meta from a post REST request before it is applied. + * + * The block editor sends the whole meta object on save, readonly keys included. + * Core only skips the meta capability check when a value is unchanged, so a + * stale readonly value (for example an empty beehiiv post ID while the server + * already holds one) would fail the entire save. Readonly keys are never + * written from REST, so they are removed and the rest of the save proceeds. + * + * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Result to send to the client. + * @param array $handler Route handler. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed + * @since x.x.x + */ + public static function drop_readonly_meta_from_request( $response, $handler, $request ) { + if ( ! $request instanceof \WP_REST_Request ) { + return $response; + } + + if ( ! preg_match( '#^/wp/v2/posts(?:/\d+(?:/autosaves)?)?$#', $request->get_route() ) ) { + return $response; + } + + $meta = $request->get_param( 'meta' ); + + if ( ! is_array( $meta ) ) { + return $response; + } + + foreach ( self::META_KEYS as $key => $config ) { + if ( ! empty( $config['readonly'] ) ) { + unset( $meta[ $key ] ); + } + } + + $request->set_param( 'meta', $meta ); + + return $response; + } + /** * Ensure beehiiv meta in REST responses reflects the database after save. * @@ -285,6 +358,7 @@ public static function prepare_rest_post( $response, $post, $request ) { [ Meta::BEEHIIV_POST_ID, Meta::BEEHIIV_SCHEDULED_AT, + Meta::BEEHIIV_PUBLICATION_ID, Meta::SEND_TO_NEWSLETTER, Meta::NEWSLETTER_ERROR, Meta::NEWSLETTER_ERROR_TYPE, @@ -318,6 +392,7 @@ private static function get_editor_config(): array { $settings = Options::get(); $is_connected = Manager::is_connected(); $can_write_posts = $is_connected && Workspace::can_write_posts(); + $default_pub_id = PublicationResolver::get_default_publication_id(); return [ 'isConnected' => $is_connected, @@ -330,6 +405,8 @@ private static function get_editor_config(): array { 'publicationId' => trim( (string) ( $settings['publication_id'] ?? '' ) ), 'defaultPostTemplateId' => trim( (string) ( $settings['post_template_id'] ?? '' ) ), 'canPublishPosts' => current_user_can( 'publish_posts' ), + 'defaultPublicationId' => $default_pub_id, + 'publications' => $is_connected ? PublicationResolver::get_editor_publications() : [], ]; } } diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index c15b144..df74b14 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -30,12 +30,13 @@ final class PostSettingsBuilder { /** * Create beehiiv post settings array for the WordPress post. * - * @param int $post_id Post ID. - * @param bool $for_update When true, omit schedule validation and `scheduled_at` (used for linked-post updates). + * @param int $post_id Post ID. + * @param bool $for_update When true, omit schedule validation and `scheduled_at` (linked-post updates). + * @param string|null $publication_id Publication the payload is for. Defaults to the post's resolved publication. * @return array|WP_Error beehiiv post settings or error. * @since 1.0.0 */ - public static function get_post_settings( int $post_id, bool $for_update = false ) { + public static function get_post_settings( int $post_id, bool $for_update = false, ?string $publication_id = null ) { $post_object = get_post( $post_id ); if ( ! $post_object instanceof WP_Post ) { @@ -45,7 +46,25 @@ public static function get_post_settings( int $post_id, bool $for_update = false ); } - $post_template_id = self::get_post_template_id( $post_id ); + if ( null === $publication_id ) { + $publication_id = PublicationResolver::resolve_for_post( $post_id ); + } + + if ( '' === $publication_id ) { + return new WP_Error( + 'beehiiv_publication_missing', + sprintf( 'No beehiiv publication is chosen for post ID: %d.', $post_id ) + ); + } + + $post_template_id = self::resolve_post_template_id( $post_id, $publication_id ); + + if ( '' === $post_template_id && PublicationResolver::get_default_publication_id() !== $publication_id ) { + return new WP_Error( + 'beehiiv_post_template_required', + sprintf( 'No post template is chosen for this post\'s publication (post ID: %d).', $post_id ) + ); + } if ( '' === $post_template_id ) { return new WP_Error( @@ -87,7 +106,8 @@ public static function get_post_settings( int $post_id, bool $for_update = false $thumbnail_image_url = get_the_post_thumbnail_url( $post_object, 'full' ); $thumbnail_image_url = is_string( $thumbnail_image_url ) ? $thumbnail_image_url : ''; - $post_title = html_entity_decode( get_the_title( $post_object ) ); + $post_title = html_entity_decode( get_the_title( $post_object ) ); + $show_title_in_email = self::should_show_title_in_email( $post_id ); $settings = [ 'post_template_id' => $post_template_id, @@ -96,10 +116,10 @@ public static function get_post_settings( int $post_id, bool $for_update = false 'status' => 'confirmed', 'thumbnail_image_url' => '' !== $thumbnail_image_url ? $thumbnail_image_url : '', 'email_settings' => [ - 'email_subject_line' => $post_title, - 'display_title_in_email' => true, + 'email_subject_line' => self::get_newsletter_title( $post_id, $post_title ), + 'display_title_in_email' => $show_title_in_email, 'display_byline_in_email' => false, - 'display_subtitle_in_email' => false, + 'display_subtitle_in_email' => $show_title_in_email, ], 'web_settings' => [ 'slug' => $post_object->post_name, @@ -108,7 +128,21 @@ public static function get_post_settings( int $post_id, bool $for_update = false 'social_share' => 'none', ]; + $subtitle = self::get_newsletter_subtitle( $post_id ); + + if ( '' !== $subtitle ) { + $settings['subtitle'] = $subtitle; + } + + $settings['seo_settings'] = SeoMetadata::resolve( $post_object ); + $settings['content_tags'] = self::get_content_tags( $post_object ); + if ( ! $for_update ) { + // Nothing to clear on a first send, so an empty tag list is left out. + if ( empty( $settings['content_tags'] ) ) { + unset( $settings['content_tags'] ); + } + $scheduled_at = self::convert_send_date_to_scheduled_at_utc( $post_object ); if ( is_wp_error( $scheduled_at ) ) { @@ -139,15 +173,16 @@ public static function get_post_settings( int $post_id, bool $for_update = false * {@see Sender::reschedule_linked_post()} recreates the beehiiv post because the API * does not allow updating `scheduled_at` on confirmed posts. * - * @param int $post_id Post ID. + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the payload is for. Defaults to the post's resolved publication. * @return array{ * payload: array, * meta: array{scheduled_at: string|null, clear_custom_date: bool} * }|WP_Error * @since 1.0.0 */ - public static function build_update( int $post_id ) { - $settings = self::get_post_settings( $post_id, true ); + public static function build_update( int $post_id, ?string $publication_id = null ) { + $settings = self::get_post_settings( $post_id, true, $publication_id ); if ( is_wp_error( $settings ) ) { return $settings; @@ -169,8 +204,18 @@ public static function build_update( int $post_id ) { 'email_settings' => $settings['email_settings'], 'web_settings' => $settings['web_settings'], 'social_share' => $settings['social_share'], + 'content_tags' => $settings['content_tags'], + 'seo_settings' => $settings['seo_settings'], ]; + if ( isset( $settings['subtitle'] ) ) { + $payload['subtitle'] = $settings['subtitle']; + } elseif ( self::has_synced_subtitle( $post_id ) ) { + // The update API leaves omitted fields unchanged, so a subtitle cleared in + // WordPress is removed in beehiiv by sending it empty. + $payload['subtitle'] = ''; + } + $schedule = self::resolve_update_scheduled_at( $post_object ); if ( is_wp_error( $schedule ) ) { @@ -219,6 +264,51 @@ public static function get_update_payload( int $post_id ) { return $update['payload']; } + /** + * Whether a non-empty subtitle was last synced to the linked beehiiv post. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + private static function has_synced_subtitle( int $post_id ): bool { + $synced = get_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE, true ); + + return is_string( $synced ) && '' !== $synced; + } + + /** + * WordPress tag names for the beehiiv `content_tags` field. + * + * Always returns a list so beehiiv's tags match WordPress, including clearing them when + * the post has no tags. + * + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function get_content_tags( WP_Post $post ): array { + $names = wp_get_post_tags( $post->ID, [ 'fields' => 'names' ] ); + + if ( ! is_array( $names ) ) { + return []; + } + + return array_values( + array_filter( + array_map( + static function ( $name ): string { + return is_scalar( $name ) ? html_entity_decode( (string) $name, ENT_QUOTES, 'UTF-8' ) : ''; + }, + $names + ), + static function ( string $name ): bool { + return '' !== $name; + } + ) + ); + } + /** * Whether the post contains an Advertisement block with no advertisement selected. * @@ -288,7 +378,7 @@ private static function advertisement_block_with_unavailable_ad( WP_Post $post ) return null; } - $publication_id = self::get_publication_id(); + $publication_id = self::get_ad_publication_id(); if ( '' === $publication_id ) { return null; @@ -344,38 +434,95 @@ private static function collect_advertisement_ad_ids( array $blocks ): array { } /** - * Post template for the post: post meta when set and valid, else plugin default. + * Post template for the post in a publication. * - * @since 1.0.0 + * The post's own template when it exists in that publication. Otherwise the + * plugin default template, but only for the site-wide default publication, + * which is the only publication that has a default template. Empty when + * neither applies. * - * @param int $post_id Post ID. + * @since x.x.x + * + * @param int $post_id Post ID. + * @param string $publication_id Publication ID. * * @return string */ - private static function get_post_template_id( int $post_id ): string { + public static function resolve_post_template_id( int $post_id, string $publication_id ): string { $post_template_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_TEMPLATE_ID, true ); $post_template_id = is_string( $post_template_id ) ? trim( $post_template_id ) : ''; - if ( '' !== $post_template_id && self::post_template_exists( $post_template_id ) ) { + if ( '' !== $post_template_id && self::post_template_exists( $post_template_id, $publication_id ) ) { return $post_template_id; } - return self::get_site_post_template_id(); + if ( '' !== $publication_id && PublicationResolver::get_default_publication_id() === $publication_id ) { + return self::get_site_post_template_id(); + } + + return ''; } /** - * Whether a template ID exists for the configured publication. + * Email subject line for the post: its newsletter title, or the post title when empty. * - * @since 1.0.0 + * @since x.x.x + * + * @param int $post_id Post ID. + * @param string $post_title Decoded WordPress post title. + * + * @return string + */ + private static function get_newsletter_title( int $post_id, string $post_title ): string { + + $newsletter_title = get_post_meta( $post_id, Meta::NEWSLETTER_TITLE, true ); + $newsletter_title = is_string( $newsletter_title ) ? trim( $newsletter_title ) : ''; + + return '' !== $newsletter_title ? html_entity_decode( $newsletter_title ) : $post_title; + } + + /** + * Subtitle for the beehiiv post: its newsletter subtitle, or empty for none. + * + * @since x.x.x + * + * @param int $post_id Post ID. + * + * @return string + */ + private static function get_newsletter_subtitle( int $post_id ): string { + + $subtitle = get_post_meta( $post_id, Meta::NEWSLETTER_SUBTITLE, true ); + + return is_string( $subtitle ) ? html_entity_decode( trim( $subtitle ) ) : ''; + } + + /** + * Whether the beehiiv email shows the title and subtitle: the post's choice, hidden when never set. * - * @param string $template_id Template ID. + * @since x.x.x + * + * @param int $post_id Post ID. * * @return bool */ - private static function post_template_exists( string $template_id ): bool { + private static function should_show_title_in_email( int $post_id ): bool { - $publication_id = self::get_publication_id(); + return rest_sanitize_boolean( get_post_meta( $post_id, Meta::NEWSLETTER_SHOW_TITLE_IN_EMAIL, true ) ); + } + + /** + * Whether a template ID exists in a publication. + * + * @since 1.0.0 + * + * @param string $template_id Template ID. + * @param string $publication_id Publication ID. + * + * @return bool + */ + private static function post_template_exists( string $template_id, string $publication_id ): bool { if ( '' === $publication_id ) { return false; @@ -392,13 +539,16 @@ private static function post_template_exists( string $template_id ): bool { } /** - * Configured beehiiv publication ID. + * Publication used to validate Advertisement blocks. + * + * Advertisements stay on the site-wide default publication until per-post + * publications are decided for ads (PRD-06.8.02 Q-001). * * @since 1.0.0 * * @return string */ - private static function get_publication_id(): string { + private static function get_ad_publication_id(): string { $settings = Options::get(); @@ -732,7 +882,7 @@ private static function parse_gmt_mysql_datetime_to_timestamp( string $datetime * @return int|null Timestamp, or null when empty or invalid. * @since 1.0.0 */ - private static function utc_scheduled_at_to_timestamp( string $scheduled_at ): ?int { + public static function utc_scheduled_at_to_timestamp( string $scheduled_at ): ?int { if ( '' === $scheduled_at ) { return null; } diff --git a/includes/Newsletter/PublicationResolver.php b/includes/Newsletter/PublicationResolver.php new file mode 100644 index 0000000..3113f89 --- /dev/null +++ b/includes/Newsletter/PublicationResolver.php @@ -0,0 +1,195 @@ + + * @since x.x.x + */ + public static function get_connected_publications(): array { + return Publications::get_publications(); + } + + /** + * Publications offered in the editor's selector. + * + * Falls back to the default publication alone when the connected list is + * unavailable, so a beehiiv outage does not block sending. + * + * @return array + * @since x.x.x + */ + public static function get_editor_publications(): array { + $publications = self::get_connected_publications(); + + if ( ! empty( $publications ) ) { + return array_values( $publications ); + } + + $default_id = self::get_default_publication_id(); + + if ( '' === $default_id ) { + return []; + } + + return [ + [ + 'id' => $default_id, + 'name' => $default_id, + ], + ]; + } + + /** + * Whether a publication is still connected to the beehiiv account. + * + * @param string $publication_id Publication ID. + * @return bool|null Null when the connected list is unavailable. + * @since x.x.x + */ + public static function is_connected_publication( string $publication_id ): ?bool { + $publications = self::get_connected_publications(); + + if ( empty( $publications ) ) { + return null; + } + + foreach ( $publications as $publication ) { + if ( isset( $publication['id'] ) && $publication_id === (string) $publication['id'] ) { + return true; + } + } + + return false; + } + + /** + * Display name of a publication, or its ID when the name is unknown. + * + * @param string $publication_id Publication ID. + * @return string + * @since x.x.x + */ + public static function get_publication_name( string $publication_id ): string { + foreach ( self::get_connected_publications() as $publication ) { + if ( isset( $publication['id'] ) && $publication_id === (string) $publication['id'] ) { + $name = trim( (string) ( $publication['name'] ?? '' ) ); + + return '' !== $name ? $name : $publication_id; + } + } + + return $publication_id; + } + + /** + * Publication the post's newsletter should be created in. + * + * - No stored publication: the site-wide default. + * - Stored and connected (or the connected list is unavailable): the stored one. + * - Stored but no longer connected: the site-wide default, with `fallback_from` + * set to the stored publication. + * + * @param int $post_id Post ID. + * @return array{publication_id: string, is_default: bool, fallback_from: string} + * @since x.x.x + */ + public static function resolve_target( int $post_id ): array { + $default_id = self::get_default_publication_id(); + $stored_id = trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_PUBLICATION_ID, true ) ); + + if ( '' === $stored_id || $stored_id === $default_id ) { + return [ + 'publication_id' => $default_id, + 'is_default' => true, + 'fallback_from' => '', + ]; + } + + if ( false === self::is_connected_publication( $stored_id ) && '' !== $default_id ) { + return [ + 'publication_id' => $default_id, + 'is_default' => true, + 'fallback_from' => $stored_id, + ]; + } + + return [ + 'publication_id' => $stored_id, + 'is_default' => false, + 'fallback_from' => '', + ]; + } + + /** + * Publication the post's linked beehiiv post lives in. + * + * Posts linked before per-post publications existed have no record and use + * the site-wide default. + * + * @param int $post_id Post ID. + * @return string + * @since x.x.x + */ + public static function get_linked_publication_id( int $post_id ): string { + $linked_id = trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_LINKED_PUBLICATION_ID, true ) ); + + return '' !== $linked_id ? $linked_id : self::get_default_publication_id(); + } + + /** + * Publication to use for any beehiiv action on the post. + * + * The linked publication when the post has a beehiiv post, otherwise the + * resolved target. + * + * @param int $post_id Post ID. + * @return string + * @since x.x.x + */ + public static function resolve_for_post( int $post_id ): string { + $beehiiv_post_id = trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) ); + + if ( '' !== $beehiiv_post_id ) { + return self::get_linked_publication_id( $post_id ); + } + + return self::resolve_target( $post_id )['publication_id']; + } +} diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index 026b908..accbe8a 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -7,7 +7,6 @@ namespace Beehiiv\Newsletter; -use Beehiiv\Admin\Options; use Beehiiv\API\Resources\Posts; use Beehiiv\Connection\Manager; use Beehiiv\Editor\Meta; @@ -17,7 +16,7 @@ defined( 'ABSPATH' ) || exit; /** - * Creates or schedules a beehiiv post in the configured publication when newsletter + * Creates or schedules a beehiiv post in the post's publication when newsletter * send is enabled and the post is published (or scheduled). Updates linked beehiiv posts * when the WordPress post changes before the newsletter sends. Draft saves are skipped * unless retrying after a previous failed send. Future send times use beehiiv @@ -36,6 +35,32 @@ final class Sender { */ private const POST_TYPE = 'post'; + /** + * Seconds before `scheduled_at` from which a scheduled newsletter counts as sent. + * + * Keeps a publication or template move from racing beehiiv's own send. + * + * @since x.x.x + */ + private const SENT_SAFETY_MARGIN = 60; + + /** + * Meta keys that lock once beehiiv has sent the newsletter. + * + * @since x.x.x + */ + private const LOCKED_AFTER_SEND_META_KEYS = [ + Meta::BEEHIIV_PUBLICATION_ID, + Meta::BEEHIIV_POST_TEMPLATE_ID, + ]; + + /** + * Whether the server is writing locked meta itself. + * + * @var bool + */ + private static $bypass_meta_lock = false; + /** * Register hooks that sync newsletters to beehiiv on save. * @@ -48,6 +73,129 @@ public static function init(): void { add_action( 'transition_post_status', [ self::class, 'on_transition_post_status' ], 10, 3 ); add_action( 'before_delete_post', [ self::class, 'on_before_delete_post' ], 10, 1 ); add_filter( 'update_post_metadata', [ self::class, 'guard_beehiiv_post_id' ], 10, 4 ); + add_filter( 'update_post_metadata', [ self::class, 'guard_locked_newsletter_meta' ], 10, 4 ); + add_filter( 'add_post_metadata', [ self::class, 'guard_locked_newsletter_meta' ], 10, 4 ); + } + + /** + * Keep the post's publication and template unchanged once beehiiv has sent the newsletter. + * + * Applies to every save path (block editor, REST, PHP). Changes are ignored + * rather than rejected so a full editor save does not fail. + * + * @since x.x.x + * + * @param mixed $check Whether to allow updating metadata for the given type. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Proposed meta value. + * + * @return mixed Null to proceed, true to skip without updating. + */ + public static function guard_locked_newsletter_meta( $check, $post_id, $meta_key, $meta_value ) { + + if ( self::$bypass_meta_lock || ! in_array( $meta_key, self::LOCKED_AFTER_SEND_META_KEYS, true ) ) { + return $check; + } + + if ( ! self::is_newsletter_published( (int) $post_id ) ) { + return $check; + } + + $existing = get_post_meta( (int) $post_id, $meta_key, true ); + $existing = is_string( $existing ) ? trim( $existing ) : ''; + $incoming = is_string( $meta_value ) ? trim( wp_unslash( $meta_value ) ) : ''; + + if ( $incoming === $existing ) { + return $check; + } + + return true; + } + + /** + * Whether beehiiv has sent (or is about to send) the post's newsletter. + * + * A linked newsletter counts as sent when it was sent immediately (no + * `scheduled_at`) or its scheduled send time is within + * {@see SENT_SAFETY_MARGIN} seconds or has passed. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + public static function is_newsletter_published( int $post_id ): bool { + return self::has_beehiiv_post_id( $post_id ) && self::send_time_has_passed( $post_id ); + } + + /** + * Whether the linked newsletter's send time has been reached. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + private static function send_time_has_passed( int $post_id ): bool { + $scheduled_at = get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ); + $scheduled_at = is_string( $scheduled_at ) ? trim( $scheduled_at ) : ''; + + if ( '' === $scheduled_at ) { + return true; + } + + $timestamp = strtotime( $scheduled_at ); + + if ( false === $timestamp ) { + return true; + } + + return $timestamp <= time() + self::SENT_SAFETY_MARGIN; + } + + /** + * Write a meta key that locks after send, bypassing the lock for server writes. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param string $value Value. + * @return void + * @since x.x.x + */ + private static function update_locked_meta( int $post_id, string $meta_key, string $value ): void { + self::$bypass_meta_lock = true; + update_post_meta( $post_id, $meta_key, $value ); + self::$bypass_meta_lock = false; + } + + /** + * Record where the linked beehiiv post lives and which template it uses. + * + * @param int $post_id Post ID. + * @param string $publication_id Publication the beehiiv post was created in. + * @param string $post_template_id Template it was created with. + * @return void + * @since x.x.x + */ + private static function record_linked_publication( + int $post_id, + string $publication_id, + string $post_template_id + ): void { + self::update_locked_meta( $post_id, Meta::BEEHIIV_PUBLICATION_ID, $publication_id ); + update_post_meta( $post_id, Meta::BEEHIIV_LINKED_PUBLICATION_ID, $publication_id ); + update_post_meta( $post_id, Meta::BEEHIIV_LINKED_POST_TEMPLATE_ID, $post_template_id ); + } + + /** + * Forget where a beehiiv post lived after it was deleted. + * + * @param int $post_id Post ID. + * @return void + * @since x.x.x + */ + private static function clear_linked_publication( int $post_id ): void { + delete_post_meta( $post_id, Meta::BEEHIIV_LINKED_PUBLICATION_ID ); + delete_post_meta( $post_id, Meta::BEEHIIV_LINKED_POST_TEMPLATE_ID ); } /** @@ -58,7 +206,7 @@ public static function init(): void { * as an empty string even though the database already has a linked ID. Do not * overwrite the stored value; short-circuit as success so the REST save does not fail. * - * Intentional ID changes (for example after {@see reschedule_linked_post()}) must + * Intentional ID changes (for example after {@see recreate_linked_post()}) must * still be allowed so later updates target the active beehiiv post. * * @since 1.0.0 @@ -199,7 +347,7 @@ public static function cancel_scheduled_newsletter( int $post_id ): void { } if ( Manager::is_connected() ) { - $publication_id = self::get_publication_id(); + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); if ( '' !== $publication_id ) { $result = Posts::delete( $publication_id, $beehiiv_post_id ); @@ -229,6 +377,9 @@ public static function cancel_scheduled_newsletter( int $post_id ): void { delete_post_meta( $post_id, Meta::BEEHIIV_POST_ID ); delete_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT ); + self::clear_linked_publication( $post_id ); + delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); + delete_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ); self::clear_error( $post_id ); } @@ -287,6 +438,11 @@ public static function maybe_update_post_newsletter( WP_Post $post ): void { return; } + // A newsletter beehiiv already sent is never updated; skip without contacting beehiiv. + if ( self::is_newsletter_sent( $post->ID ) ) { + return; + } + if ( ! Manager::is_connected() ) { self::record_error( $post->ID, @@ -319,14 +475,15 @@ public static function send( int $post_id ): void { return; } - $publication_id = self::get_publication_id(); + $target = PublicationResolver::resolve_target( $post_id ); + $publication_id = $target['publication_id']; if ( '' === $publication_id ) { self::fail( $post_id, 'send', - self::no_publication_message(), - 'Publication ID is not configured.' + self::no_post_publication_message(), + 'No publication chosen for the post and no default publication configured.' ); return; } @@ -347,7 +504,7 @@ public static function send( int $post_id ): void { return; } - $beehiiv_post_data = PostSettingsBuilder::get_post_settings( $post_id ); + $beehiiv_post_data = PostSettingsBuilder::get_post_settings( $post_id, false, $publication_id ); if ( is_wp_error( $beehiiv_post_data ) ) { self::fail( @@ -372,11 +529,26 @@ public static function send( int $post_id ): void { } // Post created successfully for sending the newsletter. - // Save the beehiiv post ID in the post meta. + // Record its publication before the link so later actions target it. + self::record_linked_publication( + $post_id, + $publication_id, + (string) ( $beehiiv_post_data['post_template_id'] ?? '' ) + ); update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, false ); + delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); + self::persist_synced_subtitle( $post_id, $beehiiv_post_data ); self::persist_scheduled_at_meta( $post_id, $beehiiv_post_data['scheduled_at'] ?? null ); self::clear_error( $post_id ); + + if ( '' !== $target['fallback_from'] ) { + self::record_error( + $post_id, + 'publication_fallback', + self::publication_fallback_message( $target['fallback_from'], $publication_id ) + ); + } } /** @@ -389,7 +561,7 @@ public static function send( int $post_id ): void { public static function update( int $post_id ): void { self::clear_error( $post_id ); - if ( '' === Manager::is_connected() ) { + if ( ! Manager::is_connected() ) { self::fail( $post_id, 'send', @@ -399,7 +571,7 @@ public static function update( int $post_id ): void { return; } - $publication_id = self::get_publication_id(); + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); if ( '' === $publication_id ) { self::fail( @@ -418,6 +590,26 @@ public static function update( int $post_id ): void { return; } + if ( self::should_confirm_sent_state( $post_id ) ) { + $state = Posts::get( $publication_id, $beehiiv_post_id ); + + // Never update a newsletter whose sent state is unknown; the next save retries. + if ( ! $state['success'] ) { + self::fail( + $post_id, + 'send', + self::format_update_error_message( $state['error'] ), + 'Could not confirm whether the newsletter was sent: ' . $state['error'] + ); + return; + } + + if ( self::is_sent_beehiiv_post( $state['post'] ) ) { + update_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT, true ); + return; + } + } + $post_object = get_post( $post_id ); if ( ! $post_object instanceof WP_Post ) { @@ -430,7 +622,12 @@ public static function update( int $post_id ): void { return; } - $update = PostSettingsBuilder::build_update( $post_id ); + $target_publication_id = self::get_move_target_publication( $post_id, $publication_id ); + + $update = PostSettingsBuilder::build_update( + $post_id, + '' !== $target_publication_id ? $target_publication_id : $publication_id + ); if ( is_wp_error( $update ) ) { self::fail( @@ -444,11 +641,29 @@ public static function update( int $post_id ): void { $new_scheduled_at = $update['meta']['scheduled_at']; + if ( '' !== $target_publication_id ) { + if ( ! is_string( $new_scheduled_at ) || '' === trim( $new_scheduled_at ) ) { + $new_scheduled_at = get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ); + $new_scheduled_at = is_string( $new_scheduled_at ) ? trim( $new_scheduled_at ) : ''; + } + + self::recreate_linked_post( + $post_id, + $publication_id, + $beehiiv_post_id, + $target_publication_id, + $new_scheduled_at, + $update['meta'] + ); + return; + } + if ( is_string( $new_scheduled_at ) && '' !== trim( $new_scheduled_at ) ) { - self::reschedule_linked_post( + self::recreate_linked_post( $post_id, $publication_id, $beehiiv_post_id, + $publication_id, $new_scheduled_at, $update['meta'] ); @@ -467,32 +682,75 @@ public static function update( int $post_id ): void { return; } + self::persist_synced_subtitle( $post_id, $update['payload'] ); self::apply_update_meta( $post_id, $update['meta'] ); self::clear_error( $post_id ); } /** - * Recreate a linked beehiiv post when its send time must move later. + * Publication a scheduled, unsent newsletter must move to, if any. * - * The update API rejects `scheduled_at` changes on confirmed posts, so we archive the - * existing post and create a new one with the same content and a later schedule. + * A move is needed when the post's chosen publication differs from the one its + * beehiiv post lives in, or when its chosen template differs from the one the + * beehiiv post was created with (the update API cannot change the template). + * Posts linked before per-post publications have no recorded template, so only + * a publication change moves them. * - * @param int $post_id Post ID. - * @param string $publication_id Publication ID. - * @param string $beehiiv_post_id Linked beehiiv post ID. - * @param string $scheduled_at New UTC `scheduled_at`. - * @param array{scheduled_at: string|null, clear_custom_date: bool} $meta Meta updates. + * @param int $post_id Post ID. + * @param string $linked_publication_id Publication the beehiiv post lives in. + * @return string Target publication ID, or empty when no move is needed. + * @since x.x.x + */ + private static function get_move_target_publication( int $post_id, string $linked_publication_id ): string { + if ( self::is_newsletter_published( $post_id ) ) { + return ''; + } + + $target_publication_id = PublicationResolver::resolve_target( $post_id )['publication_id']; + + if ( '' !== $target_publication_id && $target_publication_id !== $linked_publication_id ) { + return $target_publication_id; + } + + $linked_template_id = get_post_meta( $post_id, Meta::BEEHIIV_LINKED_POST_TEMPLATE_ID, true ); + $linked_template_id = is_string( $linked_template_id ) ? trim( $linked_template_id ) : ''; + $chosen_template_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_TEMPLATE_ID, true ); + $chosen_template_id = is_string( $chosen_template_id ) ? trim( $chosen_template_id ) : ''; + + if ( '' !== $linked_template_id && '' !== $chosen_template_id && $chosen_template_id !== $linked_template_id ) { + return $linked_publication_id; + } + + return ''; + } + + /** + * Recreate a linked beehiiv post in a publication. + * + * Used when its send time must move later, or when a scheduled, unsent newsletter + * moves to another publication or template. The update API rejects `scheduled_at` + * and template changes on confirmed posts, so we delete the existing post and + * create a new one. The new payload is validated first, so nothing is deleted + * when it cannot be created. + * + * @param int $post_id Post ID. + * @param string $from_publication_id Current publication. + * @param string $beehiiv_post_id Linked beehiiv post ID. + * @param string $to_publication_id New publication. + * @param string $scheduled_at UTC send time. + * @param array{scheduled_at: string|null, clear_custom_date: bool} $meta Meta updates. * @return void * @since 1.0.0 */ - private static function reschedule_linked_post( + private static function recreate_linked_post( int $post_id, - string $publication_id, + string $from_publication_id, string $beehiiv_post_id, + string $to_publication_id, string $scheduled_at, array $meta ): void { - $create_payload = PostSettingsBuilder::get_post_settings( $post_id, true ); + $create_payload = PostSettingsBuilder::get_post_settings( $post_id, true, $to_publication_id ); if ( is_wp_error( $create_payload ) ) { self::fail( @@ -504,9 +762,11 @@ private static function reschedule_linked_post( return; } - $create_payload['scheduled_at'] = $scheduled_at; + if ( '' !== $scheduled_at ) { + $create_payload['scheduled_at'] = $scheduled_at; + } - $delete_result = Posts::delete( $publication_id, $beehiiv_post_id ); + $delete_result = Posts::delete( $from_publication_id, $beehiiv_post_id ); if ( ! $delete_result['success'] ) { self::fail( @@ -518,11 +778,14 @@ private static function reschedule_linked_post( return; } - $result = Posts::create( $publication_id, $create_payload ); + $result = Posts::create( $to_publication_id, $create_payload ); if ( ! $result['success'] ) { delete_post_meta( $post_id, Meta::BEEHIIV_POST_ID ); delete_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT ); + self::clear_linked_publication( $post_id ); + delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); + delete_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ); self::fail( @@ -534,11 +797,40 @@ private static function reschedule_linked_post( return; } + self::record_linked_publication( + $post_id, + $to_publication_id, + (string) ( $create_payload['post_template_id'] ?? '' ) + ); update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + self::persist_synced_subtitle( $post_id, $create_payload ); self::apply_update_meta( $post_id, $meta ); self::clear_error( $post_id ); } + /** + * Record the subtitle a successful create or update sent to beehiiv. + * + * @param int $post_id Post ID. + * @param array $payload Payload that was sent. + * @return void + * @since x.x.x + */ + private static function persist_synced_subtitle( int $post_id, array $payload ): void { + if ( ! array_key_exists( 'subtitle', $payload ) ) { + return; + } + + $subtitle = is_string( $payload['subtitle'] ) ? $payload['subtitle'] : ''; + + if ( '' === $subtitle ) { + delete_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE ); + return; + } + + update_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE, $subtitle ); + } + /** * Persist the beehiiv `scheduled_at` value synced for a linked post. * @@ -577,17 +869,15 @@ private static function apply_update_meta( int $post_id, array $meta ): void { } /** - * Site-wide beehiiv publication ID from plugin settings. + * Site-wide default beehiiv publication ID from plugin settings. + * + * Use {@see PublicationResolver::resolve_for_post()} for a post's publication. * * @return string * @since 1.0.0 */ public static function get_publication_id(): string { - $settings = Options::get(); - - return isset( $settings['publication_id'] ) - ? trim( (string) $settings['publication_id'] ) - : ''; + return PublicationResolver::get_default_publication_id(); } /** @@ -603,6 +893,66 @@ public static function has_beehiiv_post_id( int $post_id ): bool { return is_string( $beehiiv_post_id ) && '' !== $beehiiv_post_id; } + /** + * Whether this post's linked newsletter is already known to have been sent. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + public static function is_newsletter_sent( int $post_id ): bool { + return rest_sanitize_boolean( get_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT, true ) ); + } + + /** + * Whether the linked newsletter's sent state must be confirmed with beehiiv before updating. + * + * True when the last synced send time is unknown or has passed. A newsletter still + * scheduled for the future cannot have sent, so it updates without the extra request. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + private static function should_confirm_sent_state( int $post_id ): bool { + $scheduled_at = get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ); + $scheduled_at = is_string( $scheduled_at ) ? trim( $scheduled_at ) : ''; + + if ( '' === $scheduled_at ) { + return true; + } + + $timestamp = PostSettingsBuilder::utc_scheduled_at_to_timestamp( $scheduled_at ); + + return null === $timestamp || $timestamp <= time(); + } + + /** + * Whether a beehiiv post (from the Get Post endpoint) can no longer be updated. + * + * True once it has been sent (the API has no sent-at field; a confirmed post whose + * publish date has passed has gone out) or archived in beehiiv. + * + * @param array $beehiiv_post Post data from beehiiv. + * @return bool + * @since x.x.x + */ + private static function is_sent_beehiiv_post( array $beehiiv_post ): bool { + if ( isset( $beehiiv_post['status'] ) && 'archived' === $beehiiv_post['status'] ) { + return true; + } + + if ( ! isset( $beehiiv_post['status'] ) || 'confirmed' !== $beehiiv_post['status'] ) { + return false; + } + + if ( ! isset( $beehiiv_post['publish_date'] ) || ! is_numeric( $beehiiv_post['publish_date'] ) ) { + return false; + } + + return (int) $beehiiv_post['publish_date'] <= time(); + } + /** * Persist a newsletter failure for the block editor and log it. * @@ -659,7 +1009,7 @@ private static function clear_error( int $post_id ): void { * @return string * @since 1.0.0 */ - private static function format_save_error_message( \WP_Error $error ): string { + public static function format_save_error_message( \WP_Error $error ): string { switch ( $error->get_error_code() ) { case 'beehiiv_post_template_id_empty': return __( @@ -667,6 +1017,10 @@ private static function format_save_error_message( \WP_Error $error ): string { 'Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar.', 'beehiiv' ); + case 'beehiiv_post_template_required': + return __( "Pick a post template for this post's publication in the beehiiv sidebar.", 'beehiiv' ); + case 'beehiiv_publication_missing': + return self::no_post_publication_message(); case 'beehiiv_post_title_or_content_empty': return __( 'Add a title and body content before sending this newsletter.', 'beehiiv' ); case 'beehiiv_blocks_empty': @@ -734,6 +1088,40 @@ private static function no_publication_message(): string { return __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ); } + /** + * User-facing message when a post has no publication and there is no default. + * + * @return string + * @since x.x.x + */ + private static function no_post_publication_message(): string { + return __( 'Choose a publication for this post in the beehiiv sidebar.', 'beehiiv' ); + } + + /** + * User-facing notice when a post's publication is gone and the default was used. + * + * @param string $removed_publication_id Publication no longer connected. + * @param string $default_publication_id Default publication used instead. + * @return string + * @since x.x.x + */ + private static function publication_fallback_message( + string $removed_publication_id, + string $default_publication_id + ): string { + return sprintf( + /* translators: 1: removed publication name, 2: default publication name. */ + __( + // phpcs:ignore Generic.Files.LineLength.MaxExceeded,Generic.Files.LineLength.TooLong -- Single string for translators / i18n tools. + 'The publication "%1$s" is no longer connected, so this newsletter was sent to the default publication "%2$s" instead.', + 'beehiiv' + ), + PublicationResolver::get_publication_name( $removed_publication_id ), + PublicationResolver::get_publication_name( $default_publication_id ) + ); + } + /** * Map raw API and transport error strings to editor-friendly copy. * @@ -743,7 +1131,7 @@ private static function no_publication_message(): string { * @return string Mapped message, or empty when input is empty. * @since 1.0.0 */ - private static function format_api_error_message( string $error ): string { + public static function format_api_error_message( string $error ): string { $error = trim( $error ); if ( '' === $error ) { diff --git a/includes/Newsletter/SeoMetadata.php b/includes/Newsletter/SeoMetadata.php new file mode 100644 index 0000000..f9217ca --- /dev/null +++ b/includes/Newsletter/SeoMetadata.php @@ -0,0 +1,226 @@ + $values SEO plugin values. + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function build( array $values, WP_Post $post ): array { + $title = '' !== ( $values['title'] ?? '' ) ? $values['title'] : self::clean( get_the_title( $post ) ); + $description = '' !== ( $values['description'] ?? '' ) ? $values['description'] : self::excerpt( $post ); + $og_title = self::first_filled( $values['og_title'] ?? '', $title ); + $og_desc = self::first_filled( $values['og_description'] ?? '', $description ); + + return [ + 'default_title' => $title, + 'default_description' => $description, + 'og_title' => $og_title, + 'og_description' => $og_desc, + 'twitter_title' => self::first_filled( $values['twitter_title'] ?? '', $og_title ), + 'twitter_description' => self::first_filled( $values['twitter_description'] ?? '', $og_desc ), + ]; + } + + /** + * Apply the `beehiiv_newsletter_seo_settings` filter, keeping only known scalar values. + * + * @param array $settings Resolved values. + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function filter( array $settings, WP_Post $post ): array { + /** + * Filters the beehiiv `seo_settings` for a post. + * + * Use this to supply values from an SEO plugin the beehiiv plugin does not read natively. + * + * @since x.x.x + * + * @param array $settings Resolved `seo_settings` values. + * @param WP_Post $post WordPress post object. + */ + $filtered = apply_filters( 'beehiiv_newsletter_seo_settings', $settings, $post ); + + if ( ! is_array( $filtered ) ) { + return $settings; + } + + foreach ( array_keys( $settings ) as $key ) { + if ( isset( $filtered[ $key ] ) && is_scalar( $filtered[ $key ] ) ) { + $settings[ $key ] = self::clean( (string) $filtered[ $key ] ); + } + } + + return $settings; + } + + /** + * Plain-text description: the manual excerpt, or the opening words of the content. + * + * Avoids get_the_excerpt(), which renders the full content and appends the "read more" suffix. + * + * @param WP_Post $post Post object. + * @return string + * @since x.x.x + */ + private static function excerpt( WP_Post $post ): string { + if ( '' !== trim( $post->post_excerpt ) ) { + return self::clean( $post->post_excerpt ); + } + + $content = strip_shortcodes( excerpt_remove_blocks( $post->post_content ) ); + + return self::clean( wp_trim_words( wp_strip_all_tags( $content ), 55, '' ) ); + } + + /** + * Raw values from the active SEO plugin, keyed by title, description, and the og and twitter fields. + * + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function from_seo_plugin( WP_Post $post ): array { + if ( function_exists( 'YoastSEO' ) ) { + return self::from_yoast( $post ); + } + + if ( class_exists( '\RankMath\Helper' ) ) { + return self::from_rank_math( $post ); + } + + return []; + } + + /** + * Resolved values from Yoast SEO's indexable surface. + * + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function from_yoast( WP_Post $post ): array { + $meta = YoastSEO()->meta->for_post( $post->ID ); + + if ( ! is_object( $meta ) ) { + return []; + } + + return [ + 'title' => self::clean( (string) $meta->title ), + 'description' => self::clean( (string) $meta->description ), + 'og_title' => self::clean( (string) $meta->open_graph_title ), + 'og_description' => self::clean( (string) $meta->open_graph_description ), + 'twitter_title' => self::clean( (string) $meta->twitter_title ), + 'twitter_description' => self::clean( (string) $meta->twitter_description ), + ]; + } + + /** + * Values from Rank Math post meta, with its template variables replaced. + * + * @param WP_Post $post Post object. + * @return array + * @since x.x.x + */ + private static function from_rank_math( WP_Post $post ): array { + $keys = [ + 'title' => 'rank_math_title', + 'description' => 'rank_math_description', + 'og_title' => 'rank_math_facebook_title', + 'og_description' => 'rank_math_facebook_description', + 'twitter_title' => 'rank_math_twitter_title', + 'twitter_description' => 'rank_math_twitter_description', + ]; + + $can_replace = is_callable( [ '\RankMath\Helper', 'replace_vars' ] ); + $values = []; + + foreach ( $keys as $field => $meta_key ) { + $value = get_post_meta( $post->ID, $meta_key, true ); + $value = is_string( $value ) ? $value : ''; + + if ( '' !== $value && $can_replace ) { + $value = (string) \RankMath\Helper::replace_vars( $value, $post ); + } + + $values[ $field ] = self::clean( $value ); + } + + return $values; + } + + /** + * First non-empty value. + * + * @param string $value Preferred value. + * @param string $fallback Fallback value. + * @return string + * @since x.x.x + */ + private static function first_filled( string $value, string $fallback ): string { + return '' !== $value ? $value : $fallback; + } + + /** + * Plain text with HTML stripped and entities decoded. + * + * @param string $value Raw value. + * @return string + * @since x.x.x + */ + private static function clean( string $value ): string { + return trim( html_entity_decode( wp_strip_all_tags( $value ), ENT_QUOTES, 'UTF-8' ) ); + } +} diff --git a/includes/Newsletter/TestSender.php b/includes/Newsletter/TestSender.php new file mode 100644 index 0000000..994139e --- /dev/null +++ b/includes/Newsletter/TestSender.php @@ -0,0 +1,728 @@ +post_type ) { + return new WP_Error( + 'beehiiv_test_send_post_not_found', + __( 'This post no longer exists. Save or reload the editor and try again.', 'beehiiv' ) + ); + } + + if ( ! in_array( $post->post_status, self::ELIGIBLE_STATUSES, true ) ) { + return new WP_Error( + 'beehiiv_test_send_ineligible_status', + __( 'Test emails are available for drafts, pending posts, and scheduled posts.', 'beehiiv' ) + ); + } + + if ( Sender::has_beehiiv_post_id( $post_id ) ) { + // The sync flow's stored flag wins: once beehiiv is known to have sent the + // newsletter, it is sent whatever the stored schedule says. + if ( Sender::is_newsletter_sent( $post_id ) || self::is_linked_newsletter_sent( $post_id ) ) { + return new WP_Error( + 'beehiiv_test_send_already_sent', + __( "This post's newsletter was already sent, so it can't send a test email.", 'beehiiv' ) + ); + } + + if ( self::has_newsletter_error( $post_id ) ) { + return new WP_Error( + 'beehiiv_test_send_out_of_sync', + __( + // phpcs:ignore Generic.Files.LineLength.MaxExceeded,Generic.Files.LineLength.TooLong -- Single string for translators / i18n tools. + "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email.", + 'beehiiv' + ) + ); + } + } + + if ( ! Manager::is_connected() ) { + return new WP_Error( + 'beehiiv_not_connected', + __( 'Connect your beehiiv account in beehiiv settings to send a test email.', 'beehiiv' ) + ); + } + + if ( '' === PublicationResolver::resolve_for_post( $post_id ) ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + if ( '' === self::get_default_post_template_id() ) { + return new WP_Error( + 'beehiiv_missing_post_template', + __( 'Choose a default post template in beehiiv settings to send a test email.', 'beehiiv' ) + ); + } + + if ( ! Workspace::can_write_posts() ) { + return new WP_Error( + 'beehiiv_send_api_unavailable', + __( 'Test emails need beehiiv Send API access, available on the Max and Enterprise plans.', 'beehiiv' ) + ); + } + + return true; + } + + /** + * Send a test email of the saved post. + * + * @param int $post_id Post ID. + * @param array $recipient_emails Validated, de-duplicated addresses. + * @return array{remaining_test_sends: int|null, reset_at: int|null, leftover_draft: bool}|WP_Error + * @since 1.0.0 + */ + public static function send( int $post_id, array $recipient_emails ) { + $eligibility = self::check_eligibility( $post_id ); + + if ( is_wp_error( $eligibility ) ) { + return $eligibility; + } + + // The linked newsletter's publication, or the post's chosen one when unlinked -- + // the same publication a real send or sync of this post uses. + $publication_id = PublicationResolver::resolve_for_post( $post_id ); + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + $beehiiv_post_id = is_string( $beehiiv_post_id ) ? trim( $beehiiv_post_id ) : ''; + + if ( '' !== $beehiiv_post_id ) { + $result = Posts::test_send( $publication_id, $beehiiv_post_id, $recipient_emails ); + + return self::finish( $post_id, $publication_id, $result, false ); + } + + $payload = self::build_temporary_draft_payload( $post_id, $publication_id ); + + if ( is_wp_error( $payload ) ) { + return new WP_Error( $payload->get_error_code(), Sender::format_save_error_message( $payload ) ); + } + + $created = Posts::create( $publication_id, $payload ); + + if ( ! $created['success'] ) { + self::log( $post_id, sprintf( 'temporary draft could not be created: %s', $created['error'] ) ); + + return self::api_error( $created['error'] ); + } + + $failure = null; + $result = []; + + /** + * Filters how long a test send waits for beehiiv to create its temporary draft. + * + * Clamped to 0–60 seconds. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to wait before giving up. + */ + $wait_seconds = self::bounded_seconds( + apply_filters( 'beehiiv_test_send_creation_wait_seconds', (float) self::CREATION_WAIT_SECONDS ), + 0, + self::CREATION_WAIT_MAX_SECONDS, + self::CREATION_WAIT_SECONDS + ); + $deadline = microtime( true ) + $wait_seconds; + + try { + // beehiiv creates Send API posts in the background, so the new draft can't be + // tested until it reports ready. + $ready = self::wait_until_created( $publication_id, $created['post_id'], $deadline ); + + if ( is_wp_error( $ready ) ) { + $failure = $ready; + } else { + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + + // A ready post can still briefly be missing from the test-send lookup; retry once. + if ( empty( $result['success'] ) && 404 === $result['status_code'] ) { + $delay = self::poll_delay( null ); + + if ( microtime( true ) + $delay < $deadline ) { + self::sleep_seconds( $delay ); + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + } + } + } + } finally { + $leftover_draft = ! self::delete_temporary_draft( $post_id, $publication_id, $created['post_id'] ); + } + + if ( null !== $failure ) { + self::log( + $post_id, + sprintf( 'temporary draft %s not ready: %s', $created['post_id'], $failure->get_error_message() ) + ); + + return self::with_leftover( $failure, $leftover_draft ); + } + + return self::finish( $post_id, $publication_id, $result, $leftover_draft ); + } + + /** + * Wait until beehiiv has finished creating a post made through the Send API. + * + * Polls the post until it reports ready, honoring beehiiv's Retry-After. Rate + * limiting, server errors and transport errors keep it waiting; an auth failure + * or other unexpected status stops waiting and lets the test send report what + * beehiiv says. Each check's HTTP timeout is capped to the time left. + * + * @param string $publication_id Publication ID. + * @param string $beehiiv_post_id beehiiv post ID returned by the create call. + * @param float $deadline Unix time (with microseconds) to stop waiting at. + * @return true|WP_Error True when ready (or unknown), an error when creation failed or timed out. + * @since 1.0.0 + */ + private static function wait_until_created( + string $publication_id, + string $beehiiv_post_id, + float $deadline + ) { + while ( true ) { + $timeout = (int) max( 1, min( self::CHECK_TIMEOUT_MAX_SECONDS, ceil( $deadline - microtime( true ) ) ) ); + $state = Posts::get_creation_state( $publication_id, $beehiiv_post_id, $timeout ); + + if ( 'failed' === $state['state'] ) { + return new WP_Error( + 'beehiiv_test_send_draft_failed', + __( "beehiiv couldn't prepare the test email. Try again.", 'beehiiv' ) + ); + } + + if ( 'pending' !== $state['state'] ) { + return true; + } + + $delay = self::poll_delay( $state['retry_after'] ); + + if ( microtime( true ) + $delay > $deadline ) { + return new WP_Error( + 'beehiiv_test_send_draft_pending', + __( 'beehiiv is still preparing the test email. Try again in a moment.', 'beehiiv' ) + ); + } + + self::sleep_seconds( $delay ); + } + } + + /** + * Delete a temporary beehiiv draft, retrying while beehiiv is still processing it. + * + * A 404 counts as already gone (for example after creation failed). Anything left + * behind is logged. + * + * @param int $post_id WordPress post ID, for the log. + * @param string $publication_id Publication ID. + * @param string $beehiiv_post_id beehiiv post ID of the temporary draft. + * @return bool True when the draft is gone, false when it may be left behind. + * @since 1.0.0 + */ + private static function delete_temporary_draft( + int $post_id, + string $publication_id, + string $beehiiv_post_id + ): bool { + /** + * Filters how long cleanup retries deleting a draft beehiiv is still processing. + * + * Clamped to 0–30 seconds. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to keep retrying. + */ + $deadline = microtime( true ) + self::bounded_seconds( + apply_filters( 'beehiiv_test_send_cleanup_wait_seconds', (float) self::CLEANUP_WAIT_SECONDS ), + 0, + self::CLEANUP_WAIT_MAX_SECONDS, + self::CLEANUP_WAIT_SECONDS + ); + + while ( true ) { + $deleted = Posts::delete( $publication_id, $beehiiv_post_id ); + + if ( 404 === $deleted['status_code'] || ( $deleted['success'] && 202 !== $deleted['status_code'] ) ) { + return true; + } + + $delay = self::poll_delay( $deleted['retry_after'] ); + + if ( 202 === $deleted['status_code'] && microtime( true ) + $delay <= $deadline ) { + self::sleep_seconds( $delay ); + continue; + } + + self::log( + $post_id, + sprintf( + 'temporary beehiiv draft %s was not deleted (HTTP %s): %s', + $beehiiv_post_id, + null === $deleted['status_code'] ? 'n/a' : (string) $deleted['status_code'], + '' !== $deleted['error'] ? $deleted['error'] : 'still processing' + ) + ); + + return false; + } + } + + /** + * Seconds to pause before the next beehiiv check. + * + * Starts from beehiiv's Retry-After (or a 1-second default), clamped to 1–5 seconds, + * then runs the result through a filter, clamped again to 0.1–5 seconds. The same + * value is used for the deadline check and the actual pause. + * + * @param int|null $retry_after Retry-After seconds from beehiiv, if any. + * @return float + * @since 1.0.0 + */ + private static function poll_delay( ?int $retry_after ): float { + $requested = null !== $retry_after ? $retry_after : self::CREATION_POLL_SECONDS; + $requested = (float) max( self::CREATION_POLL_SECONDS, min( self::CREATION_POLL_MAX_SECONDS, $requested ) ); + + /** + * Filters how long a test send pauses between beehiiv checks. + * + * Clamped to 0.1–5 seconds. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to wait, from beehiiv's Retry-After when given. + */ + return self::bounded_seconds( + apply_filters( 'beehiiv_test_send_poll_seconds', $requested ), + self::POLL_MIN_SECONDS, + self::CREATION_POLL_MAX_SECONDS, + $requested + ); + } + + /** + * Clamp a filtered number of seconds, falling back when it isn't a finite number. + * + * @param mixed $value Filtered value. + * @param float $min Smallest allowed value. + * @param float $max Largest allowed value. + * @param float $fallback Value used when the filtered value isn't a finite number. + * @return float + * @since 1.0.0 + */ + private static function bounded_seconds( $value, float $min, float $max, float $fallback ): float { + $seconds = is_numeric( $value ) ? (float) $value : $fallback; + + if ( ! is_finite( $seconds ) ) { + $seconds = $fallback; + } + + return max( $min, min( $max, $seconds ) ); + } + + /** + * Sleep for a number of seconds. + * + * @param float $seconds Seconds to sleep. + * @return void + * @since 1.0.0 + */ + private static function sleep_seconds( float $seconds ): void { + if ( $seconds > 0 ) { + usleep( (int) round( $seconds * 1000000 ) ); + } + } + + /** + * Mark an error as having left a temporary draft behind in beehiiv. + * + * @param WP_Error $error Error to return to the editor. + * @param bool $leftover_draft Whether a temporary draft could not be deleted. + * @return WP_Error + * @since 1.0.0 + */ + private static function with_leftover( WP_Error $error, bool $leftover_draft ): WP_Error { + if ( $leftover_draft ) { + $error->add_data( [ 'leftover_draft' => true ] ); + } + + return $error; + } + + /** + * Build the create payload for a temporary beehiiv draft from the saved post. + * + * Uses the real send's payload builder in its no-schedule mode, then forces draft + * status after the public settings filter so a test can never become a real send. + * + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the draft is created in; defaults to the post's resolved one. + * @return array|WP_Error + * @since 1.0.0 + */ + public static function build_temporary_draft_payload( int $post_id, ?string $publication_id = null ) { + $settings = PostSettingsBuilder::get_post_settings( $post_id, true, $publication_id ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + if ( ! is_array( $settings ) ) { + return new WP_Error( + 'beehiiv_test_send_invalid_settings', + __( "Something went wrong and the test email wasn't sent. Try again.", 'beehiiv' ) + ); + } + + $settings['status'] = 'draft'; + unset( $settings['scheduled_at'] ); + + return $settings; + } + + /** + * Whether a linked newsletter has already been sent. + * + * Mirrors the linked-status notice: the newsletter counts as scheduled while the + * stored beehiiv send time, or else the custom send date, is in the future. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.0.0 + */ + public static function is_linked_newsletter_sent( int $post_id ): bool { + $candidates = [ + [ get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ), new DateTimeZone( 'UTC' ) ], + [ get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER_DATE, true ), wp_timezone() ], + ]; + + foreach ( $candidates as $candidate ) { + $value = is_string( $candidate[0] ) ? trim( $candidate[0] ) : ''; + + if ( '' === $value ) { + continue; + } + + try { + $send_time = new DateTimeImmutable( $value, $candidate[1] ); + } catch ( Exception $e ) { + continue; + } + + if ( $send_time->getTimestamp() > time() ) { + return false; + } + } + + return true; + } + + /** + * Last remembered test-send reset time for a publication. + * + * @param string $publication_id Publication ID. + * @return int|null Unix timestamp, or null when none is stored. + * @since 1.0.0 + */ + public static function get_remembered_reset_at( string $publication_id ): ?int { + $stored = get_option( self::RESET_AT_OPTION, [] ); + + if ( ! is_array( $stored ) || ! isset( $stored[ $publication_id ] ) ) { + return null; + } + + if ( ! is_numeric( $stored[ $publication_id ] ) ) { + return null; + } + + return (int) $stored[ $publication_id ]; + } + + /** + * Turn a test-send result into the REST response data or a classified error. + * + * @param int $post_id Post ID. + * @param string $publication_id Publication ID. + * @param array $result Result from {@see Posts::test_send()}. + * @param bool $leftover_draft Whether a temporary draft could not be deleted. + * @return array{remaining_test_sends: int|null, reset_at: int|null, leftover_draft: bool}|WP_Error + * @since 1.0.0 + */ + private static function finish( int $post_id, string $publication_id, array $result, bool $leftover_draft ) { + if ( ! empty( $result['success'] ) ) { + $reset_at = $result['reset_at']; + + if ( null !== $reset_at ) { + self::remember_reset_at( $publication_id, (int) $reset_at ); + } else { + // beehiiv's reset time is optional; fall back to the last one it sent. + $remembered = self::get_remembered_reset_at( $publication_id ); + $reset_at = null !== $remembered && $remembered > time() ? $remembered : null; + } + + return [ + 'remaining_test_sends' => $result['remaining_test_sends'], + 'reset_at' => $reset_at, + 'leftover_draft' => $leftover_draft, + ]; + } + + self::log( $post_id, sprintf( 'test send failed: %s', (string) $result['error'] ) ); + + return self::with_leftover( self::classify_failure( $publication_id, $result ), $leftover_draft ); + } + + /** + * Classified error for a failed test send. + * + * @param string $publication_id Publication ID. + * @param array $result Result from {@see Posts::test_send()}. + * @return WP_Error + * @since 1.0.0 + */ + private static function classify_failure( string $publication_id, array $result ): WP_Error { + + if ( 422 === $result['status_code'] ) { + $reset_at = self::get_remembered_reset_at( $publication_id ); + + if ( null !== $reset_at && $reset_at > time() ) { + return new WP_Error( + 'beehiiv_test_send_daily_limit', + sprintf( + /* translators: %s: date, time, and timezone when test sends reset. */ + __( "You've used all test sends for today. They reset on %s.", 'beehiiv' ), + wp_date( 'F j, g:i a T', $reset_at ) + ) + ); + } + + return new WP_Error( + 'beehiiv_test_send_daily_limit', + __( "You've used all test sends for today.", 'beehiiv' ) + ); + } + + if ( 429 === $result['status_code'] ) { + return new WP_Error( + 'beehiiv_test_send_rate_limited', + __( 'Too many requests. Try again in a moment.', 'beehiiv' ) + ); + } + + return self::api_error( (string) $result['error'] ); + } + + /** + * Generic test-send failure carrying beehiiv's (mapped) message. + * + * @param string $error Error string from the API client. + * @return WP_Error + * @since 1.0.0 + */ + private static function api_error( string $error ): WP_Error { + // Keep beehiiv's own wording for request errors; only auth and rate-limit + // statuses, and transport errors, use the shared editor-friendly copy. + if ( preg_match( '/^HTTP (\d+):\s*(.+)$/is', trim( $error ), $matches ) + && ! in_array( (int) $matches[1], [ 401, 403, 429 ], true ) ) { + $mapped = trim( $matches[2] ); + } else { + $mapped = Sender::format_api_error_message( $error ); + } + + if ( '' === $mapped ) { + return new WP_Error( + 'beehiiv_test_send_failed', + __( "Something went wrong and the test email wasn't sent. Try again.", 'beehiiv' ) + ); + } + + return new WP_Error( + 'beehiiv_test_send_failed', + sprintf( + /* translators: %s: error message from beehiiv. */ + __( "The test email wasn't sent: %s", 'beehiiv' ), + $mapped + ) + ); + } + + /** + * Site-wide default beehiiv post template from plugin settings. + * + * The same setting the editor's newsletter readiness rule checks. + * + * @return string + * @since 1.0.0 + */ + private static function get_default_post_template_id(): string { + $settings = Options::get(); + + return isset( $settings['post_template_id'] ) ? trim( (string) $settings['post_template_id'] ) : ''; + } + + /** + * Store the latest test-send reset time for a publication. + * + * @param string $publication_id Publication ID. + * @param int $reset_at Unix timestamp. + * @return void + * @since 1.0.0 + */ + private static function remember_reset_at( string $publication_id, int $reset_at ): void { + $stored = get_option( self::RESET_AT_OPTION, [] ); + $stored = is_array( $stored ) ? $stored : []; + + $stored[ $publication_id ] = $reset_at; + + update_option( self::RESET_AT_OPTION, $stored, false ); + } + + /** + * Whether a prior newsletter save or send left an error on this post. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.0.0 + */ + private static function has_newsletter_error( int $post_id ): bool { + $error = get_post_meta( $post_id, Meta::NEWSLETTER_ERROR, true ); + + return is_string( $error ) && '' !== trim( $error ); + } + + /** + * Write a test-send problem to the error log. + * + * @param int $post_id Post ID. + * @param string $message Log message. + * @return void + * @since 1.0.0 + */ + private static function log( int $post_id, string $message ): void { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'beehiiv test email for post ID %d: %s', $post_id, $message ) ); + } +} diff --git a/includes/Plugin.php b/includes/Plugin.php index 53c855b..c15a98f 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -44,7 +44,9 @@ public static function init(): void { add_action( 'admin_menu', [ OAuth\CallbackHandler::class, 'register_page' ] ); add_action( 'admin_init', [ self::class, 'bootstrap_admin_features' ] ); add_action( 'rest_api_init', [ REST\PostTemplatesController::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ REST\PublicationsController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\AdvertisementOpportunitiesController::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ REST\TestSendController::class, 'register_routes' ] ); } /** diff --git a/includes/REST/PublicationsController.php b/includes/REST/PublicationsController.php new file mode 100644 index 0000000..285466a --- /dev/null +++ b/includes/REST/PublicationsController.php @@ -0,0 +1,105 @@ + WP_REST_Server::READABLE, + 'callback' => [ self::class, 'get_items' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'refresh' => [ + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ], + ], + ] + ); + } + + /** + * Permission check for publication requests. + * + * Settings screen requires manage_options; the editor's Publication selector requires publish_posts. + * + * @since x.x.x + * + * @return bool + */ + public static function permissions_check(): bool { + + return current_user_can( 'manage_options' ) || current_user_can( 'publish_posts' ); + } + + /** + * Return the publications connected to the beehiiv account. + * + * @since x.x.x + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public static function get_items( WP_REST_Request $request ): WP_REST_Response { + + if ( ! Manager::is_connected() ) { + return new WP_REST_Response( + [ + 'code' => 'beehiiv_not_connected', + 'message' => __( 'beehiiv is not connected.', 'beehiiv' ), + ], + 400 + ); + } + + // A manual refresh clears the cached list so the next fetch repopulates it from beehiiv. + if ( $request->get_param( 'refresh' ) ) { + Cache::delete_publications(); + } + + return new WP_REST_Response( + Publications::get_publications(), + 200 + ); + } +} diff --git a/includes/REST/TestSendController.php b/includes/REST/TestSendController.php new file mode 100644 index 0000000..b104ffd --- /dev/null +++ b/includes/REST/TestSendController.php @@ -0,0 +1,196 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'send_test' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'recipients' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ], + ], + ] + ); + } + + /** + * Permission check for test-send requests. + * + * Requires publish rights and edit access to the referenced post. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return bool + */ + public static function permissions_check( WP_REST_Request $request ): bool { + + $post_id = absint( $request->get_param( 'post_id' ) ); + + return $post_id + && current_user_can( 'edit_post', $post_id ) + && current_user_can( 'publish_posts' ); + } + + /** + * Send a test email of the saved post. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function send_test( WP_REST_Request $request ): WP_REST_Response { + + $recipients = self::parse_recipients( (string) $request->get_param( 'recipients' ) ); + + if ( empty( $recipients ) ) { + return self::error_response( + 'beehiiv_test_send_no_recipients', + __( 'Enter at least one email address.', 'beehiiv' ) + ); + } + + $invalid = array_values( + array_filter( + $recipients, + static function ( $email ) { + return ! is_email( $email ); + } + ) + ); + + if ( ! empty( $invalid ) ) { + return self::error_response( + 'beehiiv_test_send_invalid_recipients', + sprintf( + /* translators: %s: comma-separated list of invalid email addresses. */ + __( "These email addresses aren't valid: %s", 'beehiiv' ), + implode( ', ', $invalid ) + ) + ); + } + + $result = TestSender::send( absint( $request->get_param( 'post_id' ) ), $recipients ); + + if ( is_wp_error( $result ) ) { + $data = $result->get_error_data(); + + return self::error_response( + $result->get_error_code(), + $result->get_error_message(), + is_array( $data ) && ! empty( $data['leftover_draft'] ) + ); + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * Split a recipients string on commas and new lines, trimmed and de-duplicated. + * + * Duplicates are matched case-insensitively; the first spelling is kept. + * + * @since 1.0.0 + * + * @param string $raw Raw recipients field value. + * @return array + */ + public static function parse_recipients( string $raw ): array { + + $parts = preg_split( '/[,\r\n]+/', $raw ); + $parts = is_array( $parts ) ? $parts : []; + $unique = []; + + foreach ( $parts as $part ) { + $email = trim( $part ); + + if ( '' === $email ) { + continue; + } + + $key = strtolower( $email ); + + if ( ! isset( $unique[ $key ] ) ) { + $unique[ $key ] = $email; + } + } + + return array_values( $unique ); + } + + /** + * Error response in the shape the other beehiiv REST controllers use. + * + * @since 1.0.0 + * + * @param string $code Error code. + * @param string $message Editor-facing message. + * @param bool $leftover_draft Whether a temporary beehiiv draft may be left behind. + * @return WP_REST_Response + */ + private static function error_response( + string $code, + string $message, + bool $leftover_draft = false + ): WP_REST_Response { + + $body = [ + 'code' => $code, + 'message' => $message, + ]; + + if ( $leftover_draft ) { + $body['leftover_draft'] = true; + } + + return new WP_REST_Response( $body, 400 ); + } +} diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 4f6fc05..62edea6 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -2,28 +2,30 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: beehiiv 1.0.0\n" +"Project-Id-Version: beehiiv 1.1.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/beehiiv\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-07-08T08:58:12+00:00\n" +"POT-Creation-Date: 2026-10-16T15:42:07+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"X-Generator: WP-CLI 2.7.1\n" +"X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" #. Plugin Name of the plugin #. Author of the plugin +#: beehiiv.php #: includes/Admin/Menu.php:32 #: includes/Blocks/Registry.php:45 -#: src/js/editor/post-settings/index.js:168 +#: src/js/editor/post-settings/index.js:314 msgid "beehiiv" msgstr "" #. Description of the plugin -msgid "Official beehiiv WordPress plugin." +#: beehiiv.php +msgid "Official beehiiv WordPress plugin. Publish posts as newsletters and grow your audience with beehiiv." msgstr "" #: includes/Admin/Menu.php:31 @@ -31,10 +33,14 @@ msgid "beehiiv Settings" msgstr "" #: includes/Admin/Registrar.php:63 -#: includes/Admin/Registrar.php:70 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:92 msgid "Publication" msgstr "" +#: includes/Admin/Registrar.php:70 +msgid "Default publication" +msgstr "" + #: includes/Admin/Registrar.php:81 msgid "Default post template" msgstr "" @@ -44,100 +50,139 @@ msgid "Default beehiiv publication and post template for newsletters sent from t msgstr "" #: includes/Admin/Registrar.php:121 +#: src/js/admin/settings/index.js:259 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:78 msgid "Select a publication" msgstr "" -#: includes/Admin/Registrar.php:151 -msgid "The publication to use for newsletters sent from this site." +#: includes/Admin/Registrar.php:155 +#: src/js/admin/settings/index.js:25 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:115 +msgid "Refresh publications" msgstr "" -#: includes/Admin/Registrar.php:180 -#: src/js/admin/settings/index.js:110 +#: includes/Admin/Registrar.php:160 +msgid "Preselected on new posts. Editors can choose a different publication for each post in the beehiiv sidebar." +msgstr "" + +#: includes/Admin/Registrar.php:190 +#: src/js/admin/settings/index.js:138 msgid "No default template" msgstr "" -#: includes/Admin/Registrar.php:215 -#: src/js/admin/settings/index.js:17 -#: src/js/editor/post-settings/components/newsletter-template-select.js:177 +#: includes/Admin/Registrar.php:225 +#: src/js/admin/settings/index.js:21 +#: src/js/editor/post-settings/components/newsletter-template-select.js:201 msgid "Refresh templates" msgstr "" -#: includes/Admin/Registrar.php:220 -#: src/js/admin/settings/index.js:47 +#: includes/Admin/Registrar.php:230 +#: src/js/admin/settings/index.js:59 msgid "This publication has no post templates. Create a template in beehiiv, then refresh." msgstr "" -#: includes/Admin/Registrar.php:229 +#: includes/Admin/Registrar.php:239 msgid "Default newsletter template for this site. Refresh after changing templates in beehiiv." msgstr "" -#: includes/Admin/Views/connection.php:22 +#: includes/Admin/Views/connection.php:30 msgid "Connection" msgstr "" -#: includes/Admin/Views/connection.php:33 +#: includes/Admin/Views/connection.php:41 msgid "Disconnect" msgstr "" -#: includes/Admin/Views/connection.php:40 +#: includes/Admin/Views/connection.php:48 msgid "Connect to beehiiv" msgstr "" -#: includes/Admin/Views/connection.php:65 +#: includes/Admin/Views/connection.php:73 msgid "Create a beehiiv account now" msgstr "" #. translators: %s: link to beehiiv sign-up. -#: includes/Admin/Views/connection.php:69 +#: includes/Admin/Views/connection.php:77 +#, php-format msgid "Don't have a beehiiv account? %s" msgstr "" -#: includes/Admin/Views/connection.php:87 +#: includes/Admin/Views/connection.php:95 msgid "You are now connected to beehiiv. To send WordPress posts to your newsletter:" msgstr "" -#: includes/Admin/Views/connection.php:99 +#: includes/Admin/Views/connection.php:107 msgid "Create" msgstr "" -#: includes/Admin/Views/connection.php:104 +#: includes/Admin/Views/connection.php:112 msgid "edit" msgstr "" #. translators: 1: link to create a new post, 2: link to the posts list. -#: includes/Admin/Views/connection.php:109 +#: includes/Admin/Views/connection.php:117 +#, php-format msgid "%1$s or %2$s a post in the block editor." msgstr "" -#: includes/Admin/Views/connection.php:125 +#: includes/Admin/Views/connection.php:133 msgid "Open the beehiiv panel in the editor sidebar (beehiiv icon in the top toolbar)." msgstr "" -#: includes/Admin/Views/connection.php:134 +#: includes/Admin/Views/connection.php:142 msgid "Turn on Send to newsletter before you publish, and the post will be queued for delivery via beehiiv." msgstr "" -#: includes/Admin/Views/connection.php:148 +#: includes/Admin/Views/connection.php:156 msgid "plugin page" msgstr "" #. translators: %s: link to the plugin page on WordPress.org. -#: includes/Admin/Views/connection.php:153 +#: includes/Admin/Views/connection.php:161 +#, php-format msgid "Read more in the documentation on the %s." msgstr "" -#: includes/Admin/Views/settings-page.php:26 +#: includes/Admin/Views/settings-page.php:38 +#: src/js/blocks/advertisement/edit.js:157 +msgid "Learn more about plans." +msgstr "" + +#. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. +#: includes/Admin/Views/settings-page.php:53 +#, php-format +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the %1$s or %2$s plan. %3$s" +msgstr "" + +#: includes/Admin/Views/settings-page.php:54 +#: includes/Admin/Views/settings-page.php:65 +msgid "Max" +msgstr "" + +#: includes/Admin/Views/settings-page.php:55 +#: includes/Admin/Views/settings-page.php:66 +msgid "Enterprise" +msgstr "" + +#. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. +#: includes/Admin/Views/settings-page.php:64 +#, php-format +msgid "The beehiiv integration is available to publications on the %1$s and %2$s plans. %3$s" +msgstr "" + +#: includes/Admin/Views/settings-page.php:84 msgid "Save settings" msgstr "" #: includes/API/Resources/AdvertisementOpportunities.php:153 -#: src/js/blocks/advertisement/edit.js:151 -#: src/js/blocks/advertisement/edit.js:159 +#: src/js/blocks/advertisement/edit.js:175 +#: src/js/blocks/advertisement/edit.js:183 msgid "Advertisement" msgstr "" #. translators: 1: send-by window start date, 2: send-by window end date. #: includes/API/Resources/AdvertisementOpportunities.php:198 +#, php-format msgid "%1$s – %2$s" msgstr "" @@ -151,109 +196,185 @@ msgstr "" #. translators: 1: user name, 2: email address #: includes/Connection/Manager.php:83 +#, php-format msgid "%1$s (%2$s)" msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:468 -#: includes/Newsletter/PostSettingsBuilder.php:517 -#: includes/Newsletter/Sender.php:692 +#: includes/Newsletter/PostSettingsBuilder.php:618 +#: includes/Newsletter/PostSettingsBuilder.php:667 +#: includes/Newsletter/Sender.php:1046 msgid "That send date has already passed. Pick a future date and time in the newsletter schedule." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:492 -#: includes/Newsletter/PostSettingsBuilder.php:547 -#: includes/Newsletter/PostSettingsBuilder.php:588 -#: includes/Newsletter/Sender.php:703 +#: includes/Newsletter/PostSettingsBuilder.php:642 +#: includes/Newsletter/PostSettingsBuilder.php:697 +#: includes/Newsletter/PostSettingsBuilder.php:738 +#: includes/Newsletter/Sender.php:1057 msgid "That send date isn't valid. Open the newsletter schedule and choose a different date and time." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:535 -#: includes/Newsletter/Sender.php:697 +#: includes/Newsletter/PostSettingsBuilder.php:685 +#: includes/Newsletter/Sender.php:1051 msgid "The newsletter can't send before this post publishes. Choose a later send time, or schedule the post first." msgstr "" -#: includes/Newsletter/Sender.php:211 +#: includes/Newsletter/Sender.php:359 msgid "We couldn't cancel the scheduled newsletter in beehiiv. Try saving the post again, or cancel it directly in beehiiv." msgstr "" -#: includes/Newsletter/Sender.php:340 -#: includes/Newsletter/Sender.php:427 -#: includes/Newsletter/Sender.php:690 +#: includes/Newsletter/Sender.php:497 +#: includes/Newsletter/Sender.php:619 +#: includes/Newsletter/Sender.php:1044 +#: includes/Newsletter/TestSender.php:125 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" -#: includes/Newsletter/Sender.php:665 +#: includes/Newsletter/Sender.php:1015 msgid "Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar." msgstr "" -#: includes/Newsletter/Sender.php:671 +#: includes/Newsletter/Sender.php:1021 +msgid "Pick a post template for this post's publication in the beehiiv sidebar." +msgstr "" + +#: includes/Newsletter/Sender.php:1025 msgid "Add a title and body content before sending this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:673 +#: includes/Newsletter/Sender.php:1027 msgid "This post doesn't include any blocks beehiiv can send. Add supported content and try again." msgstr "" -#: includes/Newsletter/Sender.php:678 +#: includes/Newsletter/Sender.php:1032 #: src/js/editor/post-settings/components/incomplete-advertisement-notice.js:25 msgid "Select an advertisement for the Advertisement block, or remove the block, to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:684 +#: includes/Newsletter/Sender.php:1038 msgid "The selected advertisement is no longer available in beehiiv. Choose a different advertisement or remove the Advertisement block, then try again." msgstr "" -#: includes/Newsletter/Sender.php:721 +#: includes/Newsletter/Sender.php:1075 msgid "Connect your beehiiv account in beehiiv settings to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:734 +#: includes/Newsletter/Sender.php:1088 +#: includes/Newsletter/TestSender.php:168 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" -#: includes/Newsletter/Sender.php:754 -#: includes/Newsletter/Sender.php:789 +#: includes/Newsletter/Sender.php:1098 +msgid "Choose a publication for this post in the beehiiv sidebar." +msgstr "" + +#. translators: 1: removed publication name, 2: default publication name. +#: includes/Newsletter/Sender.php:1115 +#, php-format +msgid "The publication \"%1$s\" is no longer connected, so this newsletter was sent to the default publication \"%2$s\" instead." +msgstr "" + +#: includes/Newsletter/Sender.php:1142 +#: includes/Newsletter/Sender.php:1177 msgid "Post not found." msgstr "" -#: includes/Newsletter/Sender.php:758 +#: includes/Newsletter/Sender.php:1146 msgid "Your beehiiv connection expired. Reconnect in beehiiv settings." msgstr "" #. translators: %s: error message from the beehiiv API. -#: includes/Newsletter/Sender.php:767 +#: includes/Newsletter/Sender.php:1155 +#, php-format msgid "beehiiv rejected this newsletter: %s" msgstr "" -#: includes/Newsletter/Sender.php:773 +#: includes/Newsletter/Sender.php:1161 +#: includes/Newsletter/TestSender.php:631 msgid "Too many requests. Try again in a moment." msgstr "" -#: includes/Newsletter/Sender.php:777 +#: includes/Newsletter/Sender.php:1165 msgid "beehiiv is temporarily unavailable. Try again later." msgstr "" -#: includes/Newsletter/Sender.php:781 -#: includes/Newsletter/Sender.php:793 +#: includes/Newsletter/Sender.php:1169 +#: includes/Newsletter/Sender.php:1181 msgid "Something went wrong. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:797 +#: includes/Newsletter/Sender.php:1185 msgid "beehiiv took too long to respond. Try again." msgstr "" -#: includes/Newsletter/Sender.php:801 +#: includes/Newsletter/Sender.php:1189 msgid "Couldn't reach beehiiv. Try again." msgstr "" -#: includes/Newsletter/Sender.php:818 +#: includes/Newsletter/Sender.php:1206 msgid "Something went wrong and the newsletter wasn't sent. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:835 +#: includes/Newsletter/Sender.php:1223 msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" +#: includes/Newsletter/TestSender.php:132 +#: src/js/editor/post-settings/components/test-email-send.js:63 +msgid "Test emails are available for drafts, pending posts, and scheduled posts." +msgstr "" + +#: includes/Newsletter/TestSender.php:142 +#: src/js/editor/post-settings/components/test-email-send.js:76 +msgid "This post's newsletter was already sent, so it can't send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:149 +#: src/js/editor/post-settings/components/test-email-send.js:83 +msgid "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:161 +msgid "Connect your beehiiv account in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:175 +msgid "Choose a default post template in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:182 +msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." +msgstr "" + +#: includes/Newsletter/TestSender.php:312 +msgid "beehiiv couldn't prepare the test email. Try again." +msgstr "" + +#: includes/Newsletter/TestSender.php:325 +msgid "beehiiv is still preparing the test email. Try again in a moment." +msgstr "" + +#: includes/Newsletter/TestSender.php:496 +#: includes/Newsletter/TestSender.php:658 +#: src/js/editor/post-settings/components/test-email-send.js:225 +msgid "Something went wrong and the test email wasn't sent. Try again." +msgstr "" + +#. translators: %s: date, time, and timezone when test sends reset. +#: includes/Newsletter/TestSender.php:616 +#, php-format +msgid "You've used all test sends for today. They reset on %s." +msgstr "" + +#: includes/Newsletter/TestSender.php:624 +msgid "You've used all test sends for today." +msgstr "" + +#. translators: %s: error message from beehiiv. +#: includes/Newsletter/TestSender.php:666 +#, php-format +msgid "The test email wasn't sent: %s" +msgstr "" + #: includes/OAuth/AdminActions.php:91 msgid "Disconnected from beehiiv." msgstr "" @@ -303,19 +424,21 @@ msgstr "" msgid "beehiiv connection is not configured for this plugin build. Contact your site administrator." msgstr "" -#: includes/OAuth/ClientRegistrar.php:61 -msgid "Could not reach beehiiv to register this site. Please try again." +#. translators: %s: transport error message +#: includes/OAuth/ClientRegistrar.php:63 +#, php-format +msgid "Could not reach beehiiv to register this site. %s" msgstr "" -#: includes/OAuth/ClientRegistrar.php:68 +#: includes/OAuth/ClientRegistrar.php:72 msgid "beehiiv rejected client registration for this site. Please try again." msgstr "" -#: includes/OAuth/ClientRegistrar.php:79 +#: includes/OAuth/ClientRegistrar.php:83 msgid "beehiiv returned an invalid client registration response." msgstr "" -#: includes/OAuth/ClientRegistrar.php:86 +#: includes/OAuth/ClientRegistrar.php:90 msgid "Could not save beehiiv client credentials on this site." msgstr "" @@ -339,71 +462,97 @@ msgstr "" msgid "Could not save refreshed beehiiv credentials." msgstr "" -#: includes/REST/AdvertisementOpportunitiesController.php:96 -#: includes/REST/PostTemplatesController.php:92 +#: includes/REST/AdvertisementOpportunitiesController.php:101 +#: includes/REST/PostTemplatesController.php:94 +#: includes/REST/PublicationsController.php:89 msgid "beehiiv is not connected." msgstr "" -#: includes/REST/AdvertisementOpportunitiesController.php:109 +#: includes/REST/AdvertisementOpportunitiesController.php:114 msgid "Publication ID is not configured." msgstr "" -#: includes/REST/PostTemplatesController.php:104 +#: includes/REST/PostTemplatesController.php:106 msgid "Publication ID is required." msgstr "" -#: src/js/admin/settings/index.js:77 -#: src/js/editor/post-settings/components/newsletter-template-select.js:184 -msgid "Templates updated from beehiiv." +#: includes/REST/TestSendController.php:97 +#: src/js/editor/post-settings/components/test-email-send.js:181 +msgid "Enter at least one email address." msgstr "" -#: src/js/admin/settings/index.js:158 -#: src/js/blocks/advertisement/edit.js:195 -#: src/js/editor/post-settings/components/newsletter-template-select.js:176 +#. translators: %s: comma-separated list of invalid email addresses. +#: includes/REST/TestSendController.php:115 +#: src/js/editor/post-settings/components/test-email-send.js:195 +#, php-format,js-format +msgid "These email addresses aren't valid: %s" +msgstr "" + +#: src/js/admin/settings/index.js:186 +#: src/js/admin/settings/index.js:302 +#: src/js/blocks/advertisement/edit.js:219 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:114 +#: src/js/editor/post-settings/components/newsletter-template-select.js:200 msgid "Refreshing…" msgstr "" -#: src/js/blocks/advertisement/edit.js:101 +#: src/js/admin/settings/index.js:213 +#: src/js/editor/post-settings/components/newsletter-template-select.js:208 +msgid "Templates updated from beehiiv." +msgstr "" + +#: src/js/admin/settings/index.js:316 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:122 +msgid "Publications updated from beehiiv." +msgstr "" + +#: src/js/blocks/advertisement/edit.js:106 msgid "Select an advertisement…" msgstr "" #. translators: %s: saved advertisement label. -#: src/js/blocks/advertisement/edit.js:119 +#: src/js/blocks/advertisement/edit.js:124 +#, js-format msgid "%s — no longer available" msgstr "" -#: src/js/blocks/advertisement/edit.js:142 +#: src/js/blocks/advertisement/edit.js:150 +#: src/js/blocks/advertisement/edit.js:250 +msgid "beehiiv Advertisement" +msgstr "" + +#: src/js/blocks/advertisement/edit.js:151 +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the Max or Enterprise plan." +msgstr "" + +#: src/js/blocks/advertisement/edit.js:166 msgid "Choose an advertisement in the block settings sidebar. Nothing is shown on the website." msgstr "" -#: src/js/blocks/advertisement/edit.js:163 +#: src/js/blocks/advertisement/edit.js:187 msgid "Only ads not already used in another post are listed." msgstr "" -#: src/js/blocks/advertisement/edit.js:176 +#: src/js/blocks/advertisement/edit.js:200 msgid "The selected advertisement is no longer available in beehiiv and won’t be sent. Choose another or clear it." msgstr "" -#: src/js/blocks/advertisement/edit.js:184 +#: src/js/blocks/advertisement/edit.js:208 msgid "Clear selection" msgstr "" -#: src/js/blocks/advertisement/edit.js:196 +#: src/js/blocks/advertisement/edit.js:220 msgid "Refresh advertisements" msgstr "" -#: src/js/blocks/advertisement/edit.js:206 +#: src/js/blocks/advertisement/edit.js:230 msgid "Advertisements updated from beehiiv." msgstr "" -#: src/js/blocks/advertisement/edit.js:213 +#: src/js/blocks/advertisement/edit.js:237 msgid "Opportunities are cached. Refresh to pull the latest from beehiiv." msgstr "" -#: src/js/blocks/advertisement/edit.js:226 -msgid "beehiiv Advertisement" -msgstr "" - #: src/js/blocks/subscribe-form/edit.js:22 msgid "The subscribe form is only rendered on the front-end and cannot be previewed here. View the page to see your form. If the form does not appear, make sure the entered form ID is valid." msgstr "" @@ -456,6 +605,7 @@ msgstr "" #. translators: %s: selected send date or "On publish". #: src/js/editor/post-settings/components/newsletter-date-picker.js:215 +#, js-format msgid "Change newsletter send time: %s" msgstr "" @@ -474,6 +624,7 @@ msgstr "" #. translators: %s: formatted newsletter send date, time, and timezone. #: src/js/editor/post-settings/components/newsletter-linked-notice.js:92 +#, js-format msgid "This post is scheduled in beehiiv for %s." msgstr "" @@ -481,57 +632,77 @@ msgstr "" msgid "Preview in beehiiv" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:22 -msgid "Connect your beehiiv account in beehiiv settings before you can send newsletters." +#. translators: %s: beehiiv publication ID +#: src/js/editor/post-settings/components/newsletter-publication-select.js:69 +#, js-format +msgid "%s (no longer connected)" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:35 -msgid "Choose a publication and default post template in beehiiv settings." +#: src/js/editor/post-settings/components/newsletter-publication-select.js:99 +msgid "The newsletter has been sent, so its publication can no longer change." +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-status-notices.js:26 +msgid "Connect your beehiiv account in beehiiv settings before you can send newsletters." msgstr "" #: src/js/editor/post-settings/components/newsletter-status-notices.js:40 -msgid "Choose a publication in beehiiv settings to send newsletters." +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the Max or Enterprise plan. Learn more about plans." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:45 -msgid "Choose a default post template in beehiiv settings to send newsletters." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:57 +msgid "Choose a publication in beehiiv settings to send newsletters." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:68 -msgid "Could not save this post to beehiiv:" +#. translators: %s: default beehiiv publication name +#: src/js/editor/post-settings/components/newsletter-status-notices.js:113 +#, js-format +msgid "The publication chosen for this post is no longer connected. The newsletter will be sent to the default publication \"%s\" instead." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:76 -msgid "Could not send this post to beehiiv:" +#: src/js/editor/post-settings/components/newsletter-status-notices.js:126 +msgid "Choose a publication for this post." msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:88 -msgid "Default (from beehiiv settings)" +#: src/js/editor/post-settings/components/newsletter-status-notices.js:134 +msgid "Pick a post template for this post." msgstr "" -#. translators: %s: post template name from beehiiv settings -#: src/js/editor/post-settings/components/newsletter-template-select.js:128 -msgid "Uses %s from beehiiv settings." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:167 +msgid "Could not save this post to beehiiv:" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:134 -msgid "Uses the template configured in beehiiv settings." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:175 +msgid "Could not send this post to beehiiv:" msgstr "" +#. translators: %s: post template name +#. translators: %s: post template ID +#: src/js/editor/post-settings/components/newsletter-template-select.js:122 #: src/js/editor/post-settings/components/newsletter-template-select.js:140 -msgid "No site default is set. Choose a template or configure one in beehiiv settings." +#, js-format +msgid "%s (default)" +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-template-select.js:156 +msgid "Select a template" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:161 +#: src/js/editor/post-settings/components/newsletter-template-select.js:178 msgid "Post template" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:188 +#: src/js/editor/post-settings/components/newsletter-template-select.js:184 +msgid "The template can't be changed once the newsletter is in beehiiv." +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-template-select.js:212 msgid "Templates are cached. Refresh to pull the latest from beehiiv after adding, renaming, or deleting one." msgstr "" #. translators: %d: number of omitted blocks. #: src/js/editor/post-settings/components/omitted-blocks-notice.js:27 +#, js-format msgid "%d block will be omitted from this newsletter." msgid_plural "%d blocks will be omitted from this newsletter." msgstr[0] "" @@ -545,36 +716,102 @@ msgstr "" msgid "Queue this post for delivery via beehiiv when published." msgstr "" +#: src/js/editor/post-settings/components/test-email-send.js:99 +msgid "Test email sent." +msgstr "" + +#. translators: %d: number of test sends left today. +#: src/js/editor/post-settings/components/test-email-send.js:105 +#, js-format +msgid "%d test send left today." +msgid_plural "%d test sends left today." +msgstr[0] "" +msgstr[1] "" + +#. translators: %s: date, time, and timezone when test sends reset. +#: src/js/editor/post-settings/components/test-email-send.js:125 +#, js-format +msgid "Test sends reset on %s." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:241 +msgid "Send test email to" +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:242 +msgid "Separate addresses with commas or new lines." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:253 +msgid "Save the post to include your latest edits." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:267 +msgid "Send test email" +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:280 +msgid "A leftover test draft may still be in beehiiv. You can delete it there." +msgstr "" + #: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:38 #: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:42 msgid "Not included in beehiiv newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:90 +#: src/js/editor/post-settings/index.js:157 +msgid "Newsletter title" +msgstr "" + +#: src/js/editor/post-settings/index.js:158 +msgid "Used as the email subject line. Leave empty to use the post title." +msgstr "" + +#: src/js/editor/post-settings/index.js:170 +msgid "Newsletter subtitle" +msgstr "" + +#: src/js/editor/post-settings/index.js:171 +msgid "Shown as the beehiiv post subtitle. Leave empty for no subtitle." +msgstr "" + +#: src/js/editor/post-settings/index.js:182 +msgid "Show title and subtitle in email" +msgstr "" + +#: src/js/editor/post-settings/index.js:183 +msgid "Shows the newsletter title and subtitle in the beehiiv email. Off by default." +msgstr "" + +#: src/js/editor/post-settings/index.js:229 msgid "The newsletter can only be sent once and cannot be undone." msgstr "" -#: src/js/editor/post-settings/index.js:111 +#: src/js/editor/post-settings/index.js:252 msgid "Snippet newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:114 +#: src/js/editor/post-settings/index.js:255 msgid "Send a snippet newsletter with a \"Read More\" button to the full post." msgstr "" -#: src/js/editor/post-settings/index.js:122 +#: src/js/editor/post-settings/index.js:263 msgid "Insert the \"More\" block in your post to mark where the snippet ends." msgstr "" -#: src/js/editor/post-settings/index.js:209 +#: src/js/editor/post-settings/index.js:278 +msgid "Test email" +msgstr "" + +#: src/js/editor/post-settings/index.js:357 msgid "Yes" msgstr "" -#: src/js/editor/post-settings/index.js:210 +#: src/js/editor/post-settings/index.js:358 msgid "No" msgstr "" -#: src/js/editor/post-settings/index.js:217 +#: src/js/editor/post-settings/index.js:365 msgid "Send to newsletter:" msgstr "" diff --git a/package-lock.json b/package-lock.json index a8027c5..1ec6a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,20 +31,6 @@ "@wordpress/stylelint-config": "^23.38.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@ariakit/components": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@ariakit/components/-/components-0.1.1.tgz", @@ -201,22 +187,22 @@ } }, "node_modules/@babel/core": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.7.tgz", - "integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helpers": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/template": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -239,9 +225,9 @@ "license": "MIT" }, "node_modules/@babel/eslint-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.7.tgz", - "integrity": "sha512-zxt+UJTOMKvUt3yOg+D58MLuz334pHp93qifMFcjIIO+9hN6t+ufw2gi7vDPMpxvfnHRR+3VVXvIjineCcgyXw==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.9.tgz", + "integrity": "sha512-GmrJTAtiRbNip+eKFqeuSvoHMJhKlH36j8U0yE3Gqn45B+SIO7NvsEiHtsmzkBqV1hLSMqYr41dI1aZLTkJ37g==", "dev": true, "license": "MIT", "dependencies": { @@ -734,32 +720,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", @@ -1771,17 +1731,17 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.25.7.tgz", - "integrity": "sha512-Y9p487tyTzB0yDYQOtWnC+9HGOuogtP3/wNpun1xJXEEvI6vip59BSBTsHnekZLqxmPcgsrAKt46HAAb//xGhg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", - "babel-plugin-polyfill-regenerator": "^0.6.1", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "engines": { @@ -1792,14 +1752,14 @@ } }, "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -2774,9 +2734,9 @@ } }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", - "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz", + "integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2836,9 +2796,9 @@ } }, "node_modules/@eslint/compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz", - "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4210,6 +4170,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -4298,22 +4269,25 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { @@ -4327,9 +4301,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", "dev": true, "funding": [ { @@ -4945,6 +4919,7 @@ "version": "7.6.1", "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz", "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "MIT" }, @@ -5200,9 +5175,9 @@ } }, "node_modules/@opentelemetry/instrumentation-http/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5444,9 +5419,9 @@ } }, "node_modules/@opentelemetry/instrumentation/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5522,9 +5497,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5883,268 +5858,290 @@ } }, "node_modules/@php-wasm/cli-util": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/cli-util/-/cli-util-3.1.35.tgz", - "integrity": "sha512-qEnMqtqO75d79kYsKz8ikSBoYzIOntF7tmUAcMvJqNrZbKrv59nZ6mkbZUlq+Nie011ZUusoAZOdvIrfSz52OA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/cli-util/-/cli-util-3.1.58.tgz", + "integrity": "sha512-G/+M4FWPKqEPRRiIsl3qwqrqR+0lgRzgvUHqvvAG6dbwN+KUQnAnLcn+gp3BYb0C6TpJu1nN325kkYQ3sx8dVg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/util": "3.1.35", - "fast-xml-parser": "^5.5.1", + "@php-wasm/util": "3.1.58", + "fast-xml-parser": "^5.8.0", "jsonc-parser": "3.3.1" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/logger": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.35.tgz", - "integrity": "sha512-5xIJ6c9Bh/Ja4iYN68p48AQlPFP0LLLANWtW9wwSwXmqpo3AxbV1SpQK0SbjhDLO6Ts1v2jKC6Q0IIV4sJKQ+w==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.58.tgz", + "integrity": "sha512-8ySsVa7XJ7x+onkaS1Z/EIFSvAS64+p5RbgJhlhoKh5RWErtUUBC5z01acDb4N7hH3z/euBCLifw+ZUqUfsNhA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node/-/node-3.1.35.tgz", - "integrity": "sha512-npj9hXlwyUx04DULFe+7ha9YazTPaGoJWEWiXezw6hmFCyP7XkcevArl2r3xRVW58oQz17I7oT6oQPiO4LPgxw==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@php-wasm/cli-util": "3.1.35", - "@php-wasm/logger": "3.1.35", - "@php-wasm/node-5-2": "3.1.35", - "@php-wasm/node-7-4": "3.1.35", - "@php-wasm/node-8-0": "3.1.35", - "@php-wasm/node-8-1": "3.1.35", - "@php-wasm/node-8-2": "3.1.35", - "@php-wasm/node-8-3": "3.1.35", - "@php-wasm/node-8-4": "3.1.35", - "@php-wasm/node-8-5": "3.1.35", - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node/-/node-3.1.58.tgz", + "integrity": "sha512-lnDxzmu9fOubysYuvcvErvRKit7I/rm0ElB/u/lVIAobsdpbKh30sS5r2Bka3paQxzQHehXGpfL2jCKe2MtpSQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/cli-util": "3.1.58", + "@php-wasm/logger": "3.1.58", + "@php-wasm/node-5-2": "3.1.58", + "@php-wasm/node-7-4": "3.1.58", + "@php-wasm/node-8-0": "3.1.58", + "@php-wasm/node-8-1": "3.1.58", + "@php-wasm/node-8-2": "3.1.58", + "@php-wasm/node-8-3": "3.1.58", + "@php-wasm/node-8-4": "3.1.58", + "@php-wasm/node-8-5": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", "fs-ext-extra-prebuilt": "2.2.7", "wasm-feature-detect": "1.8.0", - "ws": "8.18.0" + "ws": "8.21.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-5-2": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-5-2/-/node-5-2-3.1.35.tgz", - "integrity": "sha512-frJtXTdhIDSlqYWvcbBYrUP/oBDWzStmfdJcsxoRf0171Vr8lhEszs91rVA676T61nIVsstnNkrg4RgfxzYW/A==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-5-2/-/node-5-2-3.1.58.tgz", + "integrity": "sha512-DGXi2I4cBGwNIKSbFdXUIz0o+0YZWHnTSebgLOrWERtfTo01HnjpYKFgqK5xJkuy/tWcfulh5Tmd3jO5wPrKMQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-7-4": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-7-4/-/node-7-4-3.1.35.tgz", - "integrity": "sha512-2xPcC/t+pfkVj0hpPN2oNG1IbQgMplh4YPj2hMtKdxMvmod1OcXShoTHqCNK7ZGFmbT39s1JEIz7nNC7wh0Bjg==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-7-4/-/node-7-4-3.1.58.tgz", + "integrity": "sha512-Sb8NgU/DRe9fLC72H5ItdWCrXVUNsOTK+MsOtvCZA7fS48V60Du9sfE2EPjsC+a1NZynarNtd+TSN+aAzNJ1qA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-0": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-0/-/node-8-0-3.1.35.tgz", - "integrity": "sha512-uxDuXN8+JOp57lvMN/dtUMF8cwDkZ/MGD/PixTsh0zSK/NSDVGCod0Yvg53hKNzZsd4Qvj6iW7s6+jxQLMPH8A==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-0/-/node-8-0-3.1.58.tgz", + "integrity": "sha512-9Zz1ExZDdpet2XCzKTrszOnZyZm4aUQZDLHabIvzsTYGena2CS7rDJ9xe5qhil+6mF61doVjAQENZxvrmrd1lw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-1": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-1/-/node-8-1-3.1.35.tgz", - "integrity": "sha512-46zmiajRUW6msk957y1ZhAYPfn+d/j8jQXNFuUUTB6d5NlMvTrvdY4ZdOTjws9yE1k+eqYF39pfIfQIUQtbkqA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-1/-/node-8-1-3.1.58.tgz", + "integrity": "sha512-Lm8OErJf2r7EmOmuc598RPIRTrFdwT9emh9FhRCgElEHHFQ1cLnYuZUc96cKUN9P9Ga/g8eKlfgZZzywIWnn8Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-2": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-2/-/node-8-2-3.1.35.tgz", - "integrity": "sha512-Jmpmyij5GuLBEA8WG/EAaKzVnDREXVs8wkBnJ1qti65eVcrtwccpHrhUHVifnuQmh0PRyKk5bxhHyzekwhw3ew==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-2/-/node-8-2-3.1.58.tgz", + "integrity": "sha512-xgH70JttGIpqvXHJC4+CrLRZT/Mb2XRKUWKV/C7BL4cVmH/HjW7wnsltJwj6NCstz7kxtdVManP3r2y2aV5M5Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-3": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-3/-/node-8-3-3.1.35.tgz", - "integrity": "sha512-NMn/hAs9PsIshG53QMirfzbI56BtSOjVy+cAaeKvdQ+axLmMVb9CE5VMRuglR9P7bKfh/R61mt8onezKdr1pmQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-3/-/node-8-3-3.1.58.tgz", + "integrity": "sha512-H3Wf5ATEqb6VGmftcvpLAot0/+g7UDVFwWtR0RVb4xDZujz2N0okFW2ZUjSPVBgEkG1fr3A171Cll3EPYKu2Dg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-4": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-4/-/node-8-4-3.1.35.tgz", - "integrity": "sha512-jdhmX9HnrZ7xKhdp4iuTsMYYns46Md9+Rdl3otfcrdWQnfYn5C23J0wUkYVLIbj/cMRcAZVDSoJnxcbCqCYGoQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-4/-/node-8-4-3.1.58.tgz", + "integrity": "sha512-gn9wL4p/IS/351TseZCrpsO89jwNQs0poz+6+Oox5bCyDXH73KCSm8JJo/IqL2+MERSZdo2Lnu/bz8ZkLYHhZg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/node-8-5": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-5/-/node-8-5-3.1.35.tgz", - "integrity": "sha512-x12fwavQWnqb/HBkLnEgBiW87G5VgzONKtiU3pC9hjtfBTz3MhENCE7Phyr9N5DYW1SeZX/8gDg7kbV/6RLF0A==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-5/-/node-8-5-3.1.58.tgz", + "integrity": "sha512-gRBSRJlfvxBuIrUyO3pGAww7cGOMfIPEF31U2GVNf//ycsrm8sr1Y6R47FHIdsCDEZ6oorw15dxjZ8nQtL7lkw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", + "@php-wasm/universal": "3.1.58", "wasm-feature-detect": "1.8.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" + } + }, + "node_modules/@php-wasm/node/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@php-wasm/progress": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.35.tgz", - "integrity": "sha512-q189opZPpvXVtuTxwEtVuiSqXA3omxLibdWmtT1s+F9K+zO8rLdJb6SzfhV1S2JAYQ4w+PHGyduetNUrEAmVYg==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.58.tgz", + "integrity": "sha512-wmGxB9DBHluyyTHOVdIrlZWUYxeocmxh7eAUJV8RSg8xewl3xKcuXgTBI+/vt8J/vragIzqyygvNhgo6sBy6Gw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.35" + "@php-wasm/logger": "3.1.58" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/scopes": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/scopes/-/scopes-3.1.35.tgz", - "integrity": "sha512-6StQv8LCp6L9XOLBjnAhiTC6oasEGojk9DGYmLZsC8ZMBkjpLpQ2fBWh3wHAm/RcPh5lrW8Eb3jUWVqwtmEhbA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/scopes/-/scopes-3.1.58.tgz", + "integrity": "sha512-p+cajhft8GSlZWzlzBEpY1fFkZgsg37tUgijyCqJ5u+mi5UdEOuBSrXh1DngffzTAQ5Im+Ek1zfvQgqpxSd5Lw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/stream-compression": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.35.tgz", - "integrity": "sha512-0YQSWF5Y1YIBLEVnY1dcrpGveVDUeTs1hx+uFwA4OrN9ZAMExqmghPCJuIkIRw9b8GNsHwojg/LD/5NXgc8fZQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.58.tgz", + "integrity": "sha512-0r3fEqRbw8RwKSHGcaHKzJF8mRHYXrIOHQ2QDtF5qeMMt+ib5xEIpTgsn+0/hveYRdz2TCyRh1Yzu/cHoDe/WQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/util": "3.1.35" + "@php-wasm/util": "3.1.58" } }, "node_modules/@php-wasm/universal": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.35.tgz", - "integrity": "sha512-/7G87o6Lnux67MMzJFf+y/57Yi7rFQzBKuYLvhAsqg0l5na2m66FMOtTO8vOTYTt0tYcJPlCErenJ6Twd3yElQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.58.tgz", + "integrity": "sha512-pZ9UHlMB3Y12Qs504JbSmJBj9gNAjHgkAUcttpnR6nGFCOFo58+eH6rFoh+h9s1QuTww5xvivHoj34qPCNWeiw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.35", - "@php-wasm/progress": "3.1.35", - "@php-wasm/stream-compression": "3.1.35", - "@php-wasm/util": "3.1.35", + "@php-wasm/logger": "3.1.58", + "@php-wasm/progress": "3.1.58", + "@php-wasm/stream-compression": "3.1.58", + "@php-wasm/util": "3.1.58", "ini": "4.1.2" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/util": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.35.tgz", - "integrity": "sha512-JHbScavYz2yTfP/q1Hsn2Gg5tdQooKpQQXI5XEiTLU6YhaXGEW4hp5U+1hPBHFY+aawnByjIVjRkYfu9+YX2RQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.58.tgz", + "integrity": "sha512-6u+rIRZGawe3o5qFQDhmjfY1wn3RVQhOXsp6Nci/dmDeLPzwdyEZYixHVZmxKrEC9EtKzk4TUR++hgPPx9K9MA==", "dev": true, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/web-service-worker": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/web-service-worker/-/web-service-worker-3.1.35.tgz", - "integrity": "sha512-WfPmMSlGKm8t13HlID/8ULZ20bT9ihbioZd9I6VX8ME88cyUK8+N0kzGvAcT9wjvUODrwSBC2dNzKNHpZe1m9w==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/web-service-worker/-/web-service-worker-3.1.58.tgz", + "integrity": "sha512-MXHreV2guKeGnk75EpeMFWFNU6qZBL+9aSdieI0hrf5VF3DwdmuBTdDv0Iq15Oqu8veY4Mim2Lraj2EpY8focw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/scopes": "3.1.35", - "@php-wasm/universal": "3.1.35" + "@php-wasm/scopes": "3.1.58", + "@php-wasm/universal": "3.1.58" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@php-wasm/xdebug-bridge": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@php-wasm/xdebug-bridge/-/xdebug-bridge-3.1.35.tgz", - "integrity": "sha512-nIQlvtX+HlixGg6TjQP5reX+KEkBmlrS4339MvIoPbJ7BhH16AR7BmlzaraHIX9CKfCbBo5sbYkA3gKmZfCiOA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@php-wasm/xdebug-bridge/-/xdebug-bridge-3.1.58.tgz", + "integrity": "sha512-ZDehs44JJsZMbCa2OfcVjD2tuXys/QFeCXM4cFd+0ayuSqpXeWg12MuCYj9+zneLtU9Ua8sOWU3u7xujmbHQrg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.35", - "@php-wasm/universal": "3.1.35", - "ws": "8.18.0", + "@php-wasm/logger": "3.1.58", + "@php-wasm/universal": "3.1.58", + "ws": "8.21.0", "xml2js": "0.6.2", "yargs": "17.7.2" }, @@ -6152,8 +6149,30 @@ "xdebug-bridge": "xdebug-bridge.js" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" + } + }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@pkgjs/parseargs": { @@ -6168,13 +6187,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -6326,27 +6345,6 @@ "node": ">=18" } }, - "node_modules/@puppeteer/browsers/node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/@puppeteer/browsers/node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -6909,9 +6907,9 @@ "license": "MIT" }, "node_modules/@sentry/core": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", - "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==", + "version": "9.47.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.2.tgz", + "integrity": "sha512-WYEP2ZHkTQMP7Gg5frzrQy1475TnK5Qv8wgvHiTlNerYaEBCNpAeWjUltTgfCQ8Hbv1iq4inca7Kqmmyv9gLnQ==", "dev": true, "license": "MIT", "engines": { @@ -6919,9 +6917,9 @@ } }, "node_modules/@sentry/node": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.47.1.tgz", - "integrity": "sha512-CDbkasBz3fnWRKSFs6mmaRepM2pa+tbZkrqhPWifFfIkJDidtVW40p6OnquTvPXyPAszCnDZRnZT14xyvNmKPQ==", + "version": "9.47.2", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.47.2.tgz", + "integrity": "sha512-faHzAQh6Tp4L4u/fvY0gMRIdA5OQdWciiIuk/urTRXKox2DwIO7OeutFZE48Be4aXdnRiMSBcpHEp7GygAIwqg==", "dev": true, "license": "MIT", "dependencies": { @@ -6955,9 +6953,9 @@ "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.11.1", - "@sentry/core": "9.47.1", - "@sentry/node-core": "9.47.1", - "@sentry/opentelemetry": "9.47.1", + "@sentry/core": "9.47.2", + "@sentry/node-core": "9.47.2", + "@sentry/opentelemetry": "9.47.2", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" }, @@ -6966,14 +6964,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.47.1.tgz", - "integrity": "sha512-7TEOiCGkyShJ8CKtsri9lbgMCbB+qNts2Xq37itiMPN2m+lIukK3OX//L8DC5nfKYZlgikrefS63/vJtm669hQ==", + "version": "9.47.2", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.47.2.tgz", + "integrity": "sha512-DKHe8lld16QB5JQGDyYGhinjaFCQrNeqvmEECfkXfvoVFCuuORRNoIB3jTi0IZumiFo/ShO5JMYb6GqtP2gYLw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "9.47.1", - "@sentry/opentelemetry": "9.47.1", + "@sentry/core": "9.47.2", + "@sentry/opentelemetry": "9.47.2", "import-in-the-middle": "^1.14.2" }, "engines": { @@ -6997,9 +6995,9 @@ "license": "MIT" }, "node_modules/@sentry/node/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7023,13 +7021,13 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz", - "integrity": "sha512-STtFpjF7lwzeoedDJV+5XA6P89BfmFwFftmHSGSe3UTI8z8IoiR5yB6X2vCjSPvXlfeOs13qCNNCEZyznxM8Xw==", + "version": "9.47.2", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.2.tgz", + "integrity": "sha512-yyA4TLpEVNQKaKB4K0p+kHK5Lev/yCpY2qe2tK2YeGWLVVH03B+t+C6lTOf4qgR3NsiFuoWSmP9ypBceCixDyg==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "9.47.1" + "@sentry/core": "9.47.2" }, "engines": { "node": ">=18" @@ -7561,9 +7559,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -7572,9 +7570,9 @@ } }, "node_modules/@types/aws-lambda": { - "version": "8.10.161", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", - "integrity": "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==", + "version": "8.10.164", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.164.tgz", + "integrity": "sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg==", "dev": true, "license": "MIT" }, @@ -8136,17 +8134,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -8159,22 +8157,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -8190,14 +8188,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -8212,14 +8210,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8230,9 +8228,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -8247,15 +8245,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -8272,9 +8270,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -8286,16 +8284,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8314,9 +8312,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -8327,16 +8325,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8351,13 +8349,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8923,18 +8921,17 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.47.0.tgz", - "integrity": "sha512-Gq0PRRg7/jXFD4V8UfQNQEMR5DX8KxYUK1QUbHgRedsaW/ZhlgsXJHmBMZfeLubBn/tf0xpdPYdp0MNA5oc3Rg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.57.0.tgz", + "integrity": "sha512-1BhlEYblenx5w5SkHaEdBodcNf93NDt5nd7QmOc1PpDVxWsGc/ARO30puvUlTnc1tq0Z8KEwy/wiPaTJUoprLw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/dom-ready": "^4.47.0", - "@wordpress/i18n": "^6.20.0" + "@wordpress/dom-ready": "^4.57.0", + "@wordpress/i18n": "^6.30.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/admin-ui": { @@ -9255,216 +9252,36 @@ } }, "node_modules/@wordpress/autop": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.47.0.tgz", - "integrity": "sha512-2r/Fq6TeRV7ytsRfppJozKVaqY2jPGue2dwvthgo8/LruHOGuSBSFz67wmgI+0WUXPqq2+DjQ5mL0+416sxNMA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.57.0.tgz", + "integrity": "sha512-qDnGKl9zdGYFPeFXetG7DGjhHLQW2GVlNYqFW2CjSwysRy6lACTGmyvRyGIOdm1EYeDBt5o0mvQxVPnh1TRo9A==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.47.0.tgz", - "integrity": "sha512-PQBdCYkGIF0hPFfWyLJMVZSToh0gWlVyPNRy+h/U2XHIGX83CCWcY8+YB1xutP20KStFBmO9M/CNLfvSa5DKGw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.56.0.tgz", + "integrity": "sha512-e6M3B67Ooub7jQaBLW1KiY6Ghq9wXAdBBe08nZ6xeSX3znBdXlWCg6YpSRw54//t/p7ajHSKVaq8dvf2x46P+A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@babel/core": "7.25.7", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@babel/plugin-transform-react-jsx": "7.25.7", - "@babel/plugin-transform-runtime": "7.25.7", - "@babel/preset-env": "7.25.7", - "@babel/preset-typescript": "7.25.7", - "@wordpress/browserslist-config": "^6.47.0", - "@wordpress/warning": "^3.47.0", - "browserslist": "^4.21.10", + "@babel/core": "^7.25.7", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-transform-react-jsx": "^7.25.7", + "@babel/plugin-transform-runtime": "^7.25.7", + "@babel/preset-env": "^7.25.7", + "@babel/preset-typescript": "^7.25.7", + "@wordpress/browserslist-config": "^6.56.0", + "@wordpress/warning": "^3.56.0", + "browserslist": "^4.28.7", "core-js": "^3.31.0", - "react": "^19.2.4" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.7.tgz", - "integrity": "sha512-vILAg5nwGlR9EXE8JIOX4NHXd49lrYbN8hnjffDtoULwpL9hUx/N55nqh2qd0q6FyNDfjl9V79ecKGvFbcSA0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/plugin-syntax-jsx": "^7.25.7", - "@babel/types": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/@babel/preset-env": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.7.tgz", - "integrity": "sha512-Gibz4OUdyNqqLj+7OAvBZxOD7CklCtMA5/j0JgUEwOnaRULsPDXmic2iKxL2DX2vQduPR5wH2hjZas/Vr/Oc0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.7", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.7", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.25.7", - "@babel/plugin-syntax-import-attributes": "^7.25.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.7", - "@babel/plugin-transform-async-to-generator": "^7.25.7", - "@babel/plugin-transform-block-scoped-functions": "^7.25.7", - "@babel/plugin-transform-block-scoping": "^7.25.7", - "@babel/plugin-transform-class-properties": "^7.25.7", - "@babel/plugin-transform-class-static-block": "^7.25.7", - "@babel/plugin-transform-classes": "^7.25.7", - "@babel/plugin-transform-computed-properties": "^7.25.7", - "@babel/plugin-transform-destructuring": "^7.25.7", - "@babel/plugin-transform-dotall-regex": "^7.25.7", - "@babel/plugin-transform-duplicate-keys": "^7.25.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-dynamic-import": "^7.25.7", - "@babel/plugin-transform-exponentiation-operator": "^7.25.7", - "@babel/plugin-transform-export-namespace-from": "^7.25.7", - "@babel/plugin-transform-for-of": "^7.25.7", - "@babel/plugin-transform-function-name": "^7.25.7", - "@babel/plugin-transform-json-strings": "^7.25.7", - "@babel/plugin-transform-literals": "^7.25.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.7", - "@babel/plugin-transform-member-expression-literals": "^7.25.7", - "@babel/plugin-transform-modules-amd": "^7.25.7", - "@babel/plugin-transform-modules-commonjs": "^7.25.7", - "@babel/plugin-transform-modules-systemjs": "^7.25.7", - "@babel/plugin-transform-modules-umd": "^7.25.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-new-target": "^7.25.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.7", - "@babel/plugin-transform-numeric-separator": "^7.25.7", - "@babel/plugin-transform-object-rest-spread": "^7.25.7", - "@babel/plugin-transform-object-super": "^7.25.7", - "@babel/plugin-transform-optional-catch-binding": "^7.25.7", - "@babel/plugin-transform-optional-chaining": "^7.25.7", - "@babel/plugin-transform-parameters": "^7.25.7", - "@babel/plugin-transform-private-methods": "^7.25.7", - "@babel/plugin-transform-private-property-in-object": "^7.25.7", - "@babel/plugin-transform-property-literals": "^7.25.7", - "@babel/plugin-transform-regenerator": "^7.25.7", - "@babel/plugin-transform-reserved-words": "^7.25.7", - "@babel/plugin-transform-shorthand-properties": "^7.25.7", - "@babel/plugin-transform-spread": "^7.25.7", - "@babel/plugin-transform-sticky-regex": "^7.25.7", - "@babel/plugin-transform-template-literals": "^7.25.7", - "@babel/plugin-transform-typeof-symbol": "^7.25.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.7", - "@babel/plugin-transform-unicode-property-regex": "^7.25.7", - "@babel/plugin-transform-unicode-regex": "^7.25.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.7", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/@babel/preset-typescript": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.25.7.tgz", - "integrity": "sha512-rkkpaXJZOFN45Fb+Gki0c+KMIglk4+zZXOoMJuyEK8y8Kkc8Jd3BDmP7qPsz0zQMJj+UD7EprF+AqAXcILnexw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", - "@babel/plugin-syntax-jsx": "^7.25.7", - "@babel/plugin-transform-modules-commonjs": "^7.25.7", - "@babel/plugin-transform-typescript": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "react": "^18.3.1" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@wordpress/babel-preset-default/node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18.12.0" } }, "node_modules/@wordpress/base-styles": { @@ -9479,14 +9296,13 @@ } }, "node_modules/@wordpress/blob": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.47.0.tgz", - "integrity": "sha512-uLFDeZlccLFuz42jRGgw+yH+B/5ISXDl6ht3kZugHo5auXD3oUikDLyz1+kiR/hhbWF2dzzoWOxAi52d+nHhSQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.57.0.tgz", + "integrity": "sha512-t1ghgANfEzzSSNyPEIWE8PDX+wgvtzxkQX9yYG8o7jeDDqRS3aveKJS8226HkISqt7pAg+JN7iS7MaoQWT5Fuw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/block-editor": { @@ -10178,47 +9994,47 @@ "license": "MIT" }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.47.0.tgz", - "integrity": "sha512-xRZ026shHpau7VWDXLGBvfaU6s/yC1efzOGKiDGZ0fbTbTQcr09ma7ToaDyCiHIfMoSu9lMYnriLLOEKVht8Sg==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.57.0.tgz", + "integrity": "sha512-gxqS1TIMiWcIIsPTPdvztOZNEl7+TkVJ5WHk8BWOe2+WsaEcjL44heSceDl873+WZQ1RIAOBdHDyds8h4FjiaQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/blocks": { - "version": "15.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.20.0.tgz", - "integrity": "sha512-XqTNFRu6lZgNzYTj8hWnzVieDMXQ91rhRhkk6wl7HXmnalkk5b8kynUZlfDM47Qh0ZX4aCmsVru4bcebw3cbXg==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@wordpress/autop": "^4.47.0", - "@wordpress/blob": "^4.47.0", - "@wordpress/block-serialization-default-parser": "^5.47.0", - "@wordpress/data": "^10.47.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/dom": "^4.47.0", - "@wordpress/element": "^7.0.0", - "@wordpress/hooks": "^4.47.0", - "@wordpress/html-entities": "^4.47.0", - "@wordpress/i18n": "^6.20.0", - "@wordpress/is-shallow-equal": "^5.47.0", - "@wordpress/private-apis": "^1.47.0", - "@wordpress/rich-text": "^7.47.0", - "@wordpress/shortcode": "^4.47.0", - "@wordpress/warning": "^3.47.0", + "version": "15.27.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.27.0.tgz", + "integrity": "sha512-HW8SJBMF2HhZn8/9XKxu37Rspm3Oqb5odGHnpcc29jZKbYRzH/7AhEUYAdjuUedVwXB2UvzaybDMFFOZ6lN4FA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/autop": "^4.54.0", + "@wordpress/blob": "^4.54.0", + "@wordpress/block-serialization-default-parser": "^5.54.0", + "@wordpress/data": "^10.54.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/dom": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/hooks": "^4.54.0", + "@wordpress/html-entities": "^4.54.0", + "@wordpress/i18n": "^6.27.0", + "@wordpress/is-shallow-equal": "^5.54.0", + "@wordpress/keycodes": "^4.54.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/rich-text": "^7.54.0", + "@wordpress/shortcode": "^4.54.0", + "@wordpress/warning": "^3.54.0", "change-case": "^4.1.2", - "colord": "^2.7.0", + "colord": "^2.9.3", "fast-deep-equal": "^3.1.3", - "hpq": "^1.3.0", - "is-plain-object": "^5.0.0", - "memize": "^2.1.0", - "react-is": "^19.0.0", + "hpq": "^1.4.0", + "is-plain-object": "^5.1.0", + "marked": "^18.0.3", + "memize": "^2.1.1", + "react-is": "^18.3.0", "remove-accents": "^0.5.0", - "showdown": "^1.9.1", "simple-html-tokenizer": "^0.5.7", "uuid": "^14.0.0" }, @@ -10227,89 +10043,62 @@ "npm": ">=8.19.2" }, "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/@wordpress/blocks/node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@wordpress/blocks/node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^18 || ^19", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@wordpress/blocks/node_modules/@wordpress/element": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-7.0.0.tgz", - "integrity": "sha512-iJoc3pzeof0mhwN0gVqDqc64xCRoAij2JynU6YxGSmw5a1uJwi1w3GESX3nPR1eaDdv2V6vwkinMBI+aGG7wxQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.8.0.tgz", + "integrity": "sha512-cMQ32/FbQVwwa10+R1QUjJwXM+FSnNtL0t1ZOmY/rdtjm8sZh1A6idpQEC0JFCoBGx61ZXYA2EUyjqJoLyU9QQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@types/react": "^19.2.13", - "@types/react-dom": "^19.2.3", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/escape-html": "^3.47.0", + "@wordpress/escape-html": "^3.56.0", "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^19.2.4", - "react-dom": "^19.2.4" + "is-plain-object": "^5.1.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/blocks/node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@wordpress/blocks/node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" + "node": ">=18.12.0" }, "peerDependencies": { - "react": "^19.2.6" + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@wordpress/blocks/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/@wordpress/blocks/node_modules/@wordpress/private-apis": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.56.0.tgz", + "integrity": "sha512-LwtJallQ1xT1ABbulaW9lYswuMS20s/sQCX71Rf9s37M9qQBZ0Pw7hkkeCU6ik6jPkorHsLhbGCeThcAcHYr2w==", "dev": true, - "license": "MIT" + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.47.0.tgz", - "integrity": "sha512-ogoxvjDH6lH369uc1SLZkVWVkM/lFTQ9hkhzgn7egWigycdFydG7T76iUw0CvOkHlcxTJGIVaBXEUq7Y3oRqjQ==", + "version": "6.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.57.0.tgz", + "integrity": "sha512-FhgKf5DOa7W3KywIkSQcXL876nTqabaEY3EBJk+tO6XtgA14V9VoXXsk0ZtjWDFunZe/TVhZCtA9X0s27b9D+w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/commands": { @@ -11030,36 +10819,159 @@ "license": "MIT" }, "node_modules/@wordpress/data": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.47.0.tgz", - "integrity": "sha512-OAwxgplnv42U2oIKb7QkzR/v8OfStgSfl8/eHtA+aE4Z4yvRas71O1w6mmHABIKo3+zSwyxiTakag+LMD9/9Mw==", + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.57.0.tgz", + "integrity": "sha512-R/b8HUJu+XxyPgbOiY62UySzsRNf1gcVXw4vB4lyOyraVrXzK1tbxOXnSugpZLHKJFRvyipV0/m3o/M4DpZmzQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.10.0", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/is-shallow-equal": "^5.57.0", + "@wordpress/priority-queue": "^3.57.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/redux-routine": "^5.57.0", + "deepmerge": "^4.3.1", + "equivalent-key-map": "^0.2.2", + "is-plain-object": "^5.1.0", + "is-promise": "^4.0.0", + "redux": "^5.0.1", + "rememo": "^4.0.2" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/data/node_modules/@wordpress/compose": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.10.0.tgz", + "integrity": "sha512-ilezxcwhnlCLBZHYjgRgVf4+QxIKb373WLrab47EVFck9GwdLlq1rBWcWCnXME6r1MJ1r8q30RTGSbA5shub6Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { + "@types/mousetrap": "^1.6.8", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/dom": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/is-shallow-equal": "^5.57.0", + "@wordpress/keycodes": "^4.57.0", + "@wordpress/priority-queue": "^3.57.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/undo-manager": "^1.57.0", + "change-case": "^4.1.2", + "mousetrap": "^1.6.5" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/data/node_modules/@wordpress/element": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-9.0.0.tgz", + "integrity": "sha512-o4m80wTlAkqbdJ7F7vkmFzWx6B46fJ3eyG9dWu5BJ1adjL1tA1wv0xStTkFzKuU1iPHCRN0dhauS9G9rfdF3jw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/escape-html": "^3.57.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/data/node_modules/@wordpress/private-apis": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-2.0.0.tgz", + "integrity": "sha512-VmGwebQZoebF5t1xFMXbj6z/dTtAca4d8YaO3IPN2aP8IT+eOrUBvoAthC5BmLPU4JLN5m3r8vbrid3mcli6vw==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/dataviews": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-15.0.0.tgz", + "integrity": "sha512-Ez1pY87XiAf4yIlL5ett4y+yv8mn8Wms8KYJBjLlJyeiVKF3KRgTlTmyfrmCF2ydZqkR67SmJ6RjW7ErBy7wPQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.21", + "@wordpress/base-styles": "^9.0.0", + "@wordpress/components": "^34.0.0", "@wordpress/compose": "^8.0.0", + "@wordpress/data": "^10.47.0", + "@wordpress/date": "^5.47.0", "@wordpress/deprecated": "^4.47.0", "@wordpress/element": "^7.0.0", - "@wordpress/is-shallow-equal": "^5.47.0", - "@wordpress/priority-queue": "^3.47.0", + "@wordpress/i18n": "^6.20.0", + "@wordpress/icons": "^13.2.0", + "@wordpress/keycodes": "^4.47.0", + "@wordpress/primitives": "^4.47.0", "@wordpress/private-apis": "^1.47.0", - "@wordpress/redux-routine": "^5.47.0", - "deepmerge": "^4.3.0", - "equivalent-key-map": "^0.2.2", - "is-plain-object": "^5.0.0", - "is-promise": "^4.0.0", - "redux": "^5.0.1", - "rememo": "^4.0.2", - "use-memo-one": "^1.1.1" + "@wordpress/ui": "^0.14.0", + "@wordpress/warning": "^3.47.0", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "date-fns": "^4.1.0", + "deepmerge": "4.3.1", + "fast-deep-equal": "^3.1.3", + "remove-accents": "^0.5.0" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.4", + "react-dom": "^19.2.4" } }, - "node_modules/@wordpress/data/node_modules/@types/react": { + "node_modules/@wordpress/dataviews/node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", @@ -11069,7 +10981,7 @@ "csstype": "^3.2.2" } }, - "node_modules/@wordpress/data/node_modules/@types/react-dom": { + "node_modules/@wordpress/dataviews/node_modules/@types/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", @@ -11079,292 +10991,76 @@ "@types/react": "^19.2.0" } }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.1.0.tgz", - "integrity": "sha512-CAEQxrh3f19ku0SAnYAAiKcUe1zqaK9f0c8vJh+6qrpQnUjl7xLXj5TJOukXlzFH3Z9VZn6fJVfXqmDNAYIhQA==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.48.0", - "@wordpress/dom": "^4.48.0", - "@wordpress/element": "^8.0.0", - "@wordpress/is-shallow-equal": "^5.48.0", - "@wordpress/keycodes": "^4.48.0", - "@wordpress/priority-queue": "^3.48.0", - "@wordpress/private-apis": "^1.48.0", - "@wordpress/undo-manager": "^1.48.0", - "change-case": "^4.1.2", - "mousetrap": "^1.6.5", - "use-memo-one": "^1.1.1" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^18.0.0" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/@wordpress/element": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.0.0.tgz", - "integrity": "sha512-lQ8TB2vBr6lzcwQ2zh0xy+FC77Demb3FqL81fzpSsLbGUF9hZiTyyUuwc6SG21gCkGGjVm2TnU9BuHax/8nDfQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/react": "^18.3.27", - "@types/react-dom": "^18.3.1", - "@wordpress/deprecated": "^4.48.0", - "@wordpress/escape-html": "^3.48.0", - "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^18.3.0", - "react-dom": "^18.3.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/compose/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/@wordpress/data/node_modules/@wordpress/element": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-7.0.0.tgz", - "integrity": "sha512-iJoc3pzeof0mhwN0gVqDqc64xCRoAij2JynU6YxGSmw5a1uJwi1w3GESX3nPR1eaDdv2V6vwkinMBI+aGG7wxQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/react": "^19.2.13", - "@types/react-dom": "^19.2.3", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/escape-html": "^3.47.0", - "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/data/node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@wordpress/data/node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.6" - } - }, - "node_modules/@wordpress/data/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@wordpress/dataviews": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-15.0.0.tgz", - "integrity": "sha512-Ez1pY87XiAf4yIlL5ett4y+yv8mn8Wms8KYJBjLlJyeiVKF3KRgTlTmyfrmCF2ydZqkR67SmJ6RjW7ErBy7wPQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@ariakit/react": "^0.4.21", - "@wordpress/base-styles": "^9.0.0", - "@wordpress/components": "^34.0.0", - "@wordpress/compose": "^8.0.0", - "@wordpress/data": "^10.47.0", - "@wordpress/date": "^5.47.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/element": "^7.0.0", - "@wordpress/i18n": "^6.20.0", - "@wordpress/icons": "^13.2.0", - "@wordpress/keycodes": "^4.47.0", - "@wordpress/primitives": "^4.47.0", - "@wordpress/private-apis": "^1.47.0", - "@wordpress/ui": "^0.14.0", - "@wordpress/warning": "^3.47.0", - "clsx": "^2.1.1", - "colord": "^2.7.0", - "date-fns": "^4.1.0", - "deepmerge": "4.3.1", - "fast-deep-equal": "^3.1.3", - "remove-accents": "^0.5.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4", - "react-dom": "^19.2.4" - } - }, - "node_modules/@wordpress/dataviews/node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@wordpress/dataviews/node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@wordpress/dataviews/node_modules/@wordpress/components": { - "version": "34.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-34.0.0.tgz", - "integrity": "sha512-bpNDS/rnJaI4oosyPmygT7BOb758XUQBPbsE67ke9Qyp1W8iRA8AXkl+bNtuFZl5Jf/q9eDbmIOilQCo+JBetQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@ariakit/react": "^0.4.22", - "@date-fns/utc": "^2.1.1", - "@emotion/cache": "^11.14.0", - "@emotion/css": "^11.13.5", - "@emotion/native": "^11.11.0", - "@emotion/react": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/styled": "^11.14.1", - "@emotion/utils": "^1.4.2", - "@floating-ui/react-dom": "2.0.8", - "@types/gradient-parser": "1.1.0", - "@types/highlight-words-core": "1.2.1", - "@types/react": "^19.2.13", - "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.47.0", - "@wordpress/base-styles": "^9.0.0", - "@wordpress/compose": "^8.0.0", - "@wordpress/date": "^5.47.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/dom": "^4.47.0", - "@wordpress/element": "^7.0.0", - "@wordpress/escape-html": "^3.47.0", - "@wordpress/hooks": "^4.47.0", - "@wordpress/html-entities": "^4.47.0", - "@wordpress/i18n": "^6.20.0", - "@wordpress/icons": "^13.2.0", - "@wordpress/is-shallow-equal": "^5.47.0", - "@wordpress/keycodes": "^4.47.0", - "@wordpress/primitives": "^4.47.0", - "@wordpress/private-apis": "^1.47.0", - "@wordpress/rich-text": "^7.47.0", - "@wordpress/style-runtime": "^0.3.0", - "@wordpress/ui": "^0.14.0", - "@wordpress/warning": "^3.47.0", - "change-case": "^4.1.2", - "clsx": "^2.1.1", - "colord": "^2.7.0", - "csstype": "^3.2.3", - "date-fns": "^4.1.0", - "deepmerge": "^4.3.0", - "fast-deep-equal": "^3.1.3", - "framer-motion": "^11.15.0", - "gradient-parser": "1.1.1", - "highlight-words-core": "^1.2.2", - "is-plain-object": "^5.0.0", - "memize": "^2.1.0", - "path-to-regexp": "^6.2.1", - "re-resizable": "^6.4.0", - "react-colorful": "^5.6.1", - "react-day-picker": "^9.7.0", - "remove-accents": "^0.5.0", - "uuid": "^14.0.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4", - "react-dom": "^19.2.4" - } - }, - "node_modules/@wordpress/dataviews/node_modules/@wordpress/compose": { + "node_modules/@wordpress/dataviews/node_modules/@wordpress/components": { + "version": "34.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-34.0.0.tgz", + "integrity": "sha512-bpNDS/rnJaI4oosyPmygT7BOb758XUQBPbsE67ke9Qyp1W8iRA8AXkl+bNtuFZl5Jf/q9eDbmIOilQCo+JBetQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/native": "^11.11.0", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^19.2.13", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.47.0", + "@wordpress/base-styles": "^9.0.0", + "@wordpress/compose": "^8.0.0", + "@wordpress/date": "^5.47.0", + "@wordpress/deprecated": "^4.47.0", + "@wordpress/dom": "^4.47.0", + "@wordpress/element": "^7.0.0", + "@wordpress/escape-html": "^3.47.0", + "@wordpress/hooks": "^4.47.0", + "@wordpress/html-entities": "^4.47.0", + "@wordpress/i18n": "^6.20.0", + "@wordpress/icons": "^13.2.0", + "@wordpress/is-shallow-equal": "^5.47.0", + "@wordpress/keycodes": "^4.47.0", + "@wordpress/primitives": "^4.47.0", + "@wordpress/private-apis": "^1.47.0", + "@wordpress/rich-text": "^7.47.0", + "@wordpress/style-runtime": "^0.3.0", + "@wordpress/ui": "^0.14.0", + "@wordpress/warning": "^3.47.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^4.1.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^14.0.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + } + }, + "node_modules/@wordpress/dataviews/node_modules/@wordpress/compose": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.1.0.tgz", "integrity": "sha512-CAEQxrh3f19ku0SAnYAAiKcUe1zqaK9f0c8vJh+6qrpQnUjl7xLXj5TJOukXlzFH3Z9VZn6fJVfXqmDNAYIhQA==", @@ -11569,72 +11265,61 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "6.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.47.0.tgz", - "integrity": "sha512-fY8yFrvrj7c/tVmoaxm0TcHtsMYGh7nNxB1YTl2NOLUUGCVudby+ts1TWKTttN7vXY3TkRKsEuUL+4X3ToV1WQ==", + "version": "6.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.57.0.tgz", + "integrity": "sha512-5tX33Zxm2f7RdFMWr1AysScAPT5NGi6v5/iYqGZyYw/kqvH6vYPQLEEK0YO4CnG1p5FO44pu0gu6OQySLRwN1A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "json2php": "^0.0.7" + "json2php": "^0.0.9" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" }, "peerDependencies": { "webpack": "^5.0.0" } }, - "node_modules/@wordpress/dependency-extraction-webpack-plugin/node_modules/json2php": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.7.tgz", - "integrity": "sha512-dnSoUiLAoVaMXxFsVi4CrPVYMKOuDBXTghXSmMINX44RZ8WM9cXlY7UqrQnlAcODCVO7FV3+8t/5nDKAjimLfg==", - "dev": true, - "license": "BSD" - }, "node_modules/@wordpress/deprecated": { - "version": "4.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.48.0.tgz", - "integrity": "sha512-aTa7oww6hvTjfIvxLsxlcwYj7skAGPnr1V2S0iBVQfiIn5wJPiGjM9hz4QEf6kyR44Vh0IYjW9wSxVuDMGZUdw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.57.0.tgz", + "integrity": "sha512-eRjYiN5wE8cmV+sehqGTHpb2VxwSqv/FMkcQ+0po4G3JK+Y1V0StqZRDhsgcwUocPRKuAtTO7E28fcdo615DFw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^4.48.0" + "@wordpress/hooks": "^4.57.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/dom": { - "version": "4.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.48.0.tgz", - "integrity": "sha512-9UARZ0YQfmhx9VAi+QynSwu5fOJoG4mmPNTpYW8jDmtKh+9c2YIi1YSQFuOa1sipj78ZLPaBxaceZ7dbxKc3UA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.57.0.tgz", + "integrity": "sha512-KjnGUcxtdh8+Gvi0zUhh4iDRE8fsvVJkd798aS65HL6ysZYLuOVsdHwy5Qh6LT//tB3juSn1EMJ/TzdgtIOg6A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.48.0" + "@wordpress/deprecated": "^4.57.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/dom-ready": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.47.0.tgz", - "integrity": "sha512-j2s4GdhxxQi2pbgyqdSz+Xh59K626/ErASH/ZKF7GlnBk4fjf3X0Odg7wopZ5wQfJgZSfEjGr1sonRst5uMAVw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.57.0.tgz", + "integrity": "sha512-3UVrsq4LQxnPO7N28H2s0jdUbOb6+jJKT5EJVgwPJnZpCw2RLvpaN0u+P+6yZWIl8ytPwayTHuLHGnKBnIXZKQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.47.0.tgz", - "integrity": "sha512-ddDmuW+QaK9g2jsbZi/WWTEDIY3BaO17XLEIIw2vAXksiFTffFlaWh13f85Tl8Md68qZQvG89hEJ++y8DRmNKQ==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.54.0.tgz", + "integrity": "sha512-xpsb5c8T8VXBBrYfr98OqK2yevtiHVwpVE+u0vqYBYgWNiqQJj8Eyq7geRdY6v4SctbAVe8vGFVV+DfPaEhvRg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -12351,72 +12036,80 @@ } }, "node_modules/@wordpress/env": { - "version": "11.7.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.7.0.tgz", - "integrity": "sha512-BR4qAFZ5xrsQM00K5RJtnUqL86cd9opLMPthXi1uLVJqUXYNVf3YHtsptUgwL1wdZ/aON2jn5QWgKyiRL+0bVw==", + "version": "11.17.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.17.0.tgz", + "integrity": "sha512-2KQd7DMsDGIF3x2t8J9Se4A39O0m+8tAIaDYtB7A5kht4qAgr+8gpdPC7frNMEacH6YNPFJbQVtPOm+pmuIEIQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", - "@wp-playground/cli": "^3.0.48", - "chalk": "^4.0.0", + "@wp-playground/cli": "^3.1.56", + "adm-zip": "^0.6.1", + "chalk": "^4.1.1", "copy-dir": "^1.3.0", "cross-spawn": "^7.0.6", "docker-compose": "^0.24.3", - "extract-zip": "^1.6.7", "got": "^11.8.5", - "js-yaml": "^3.13.1", + "js-yaml": "^3.15.2", "ora": "^4.0.2", "rimraf": "^5.0.10", - "simple-git": "^3.5.0", + "simple-git": "^3.32.3", "yargs": "^17.3.0" }, "bin": { "wp-env": "bin/wp-env" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/env/node_modules/adm-zip": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" } }, "node_modules/@wordpress/escape-html": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.48.0.tgz", - "integrity": "sha512-phw399RofSqTqIM4DikmkDfgJ7exDYgPfDuxjv3D2YnUTTUsR+U9fA+pA+/rNUiZD1YOmVILQmkJt6oLaVM+nQ==", + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.57.0.tgz", + "integrity": "sha512-QT0x2X39blQjLHru11Dp00DAR0LLRMKMC6ATSWidliD3xQfKz+BjiPzTJd/LyhPLEE8C9li7ylKUlseIO2EYjQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/eslint-plugin": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-25.3.0.tgz", - "integrity": "sha512-QCZbHOlQFOM2VFW/3/n9y6l99ErgNz/3SxHQIZkXibezKHM7+hoO0uSyfWtWDhdoqKrh683qHjK7nOGwbTrW8A==", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-25.10.0.tgz", + "integrity": "sha512-Lx3LVVVQuhCOfwVQDUL7Mh8c++y/JKHh+GozJmYNEyOosEZGW+LFTWS6GQiqoR3d4qIRJTZ3lRlmRf0Md568aQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "^7.28.6", - "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.0", "@eslint/compat": "^2.0.0", - "@wordpress/babel-preset-default": "^8.47.0", - "@wordpress/prettier-config": "^4.47.0", - "@wordpress/theme": "^0.14.0", + "@wordpress/babel-preset-default": "^8.54.0", + "@wordpress/prettier-config": "^4.54.0", + "@wordpress/theme": "^2.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.0.0", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jest": "^29.16.0", "eslint-plugin-jsdoc": "^50.0.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-playwright": "^2.1.0", - "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^16.0.0", "requireindex": "^1.2.0", - "typescript-eslint": "^8.0.0" + "typescript-eslint": "^8.57.1" }, "engines": { "node": ">=18.12.0", @@ -12437,6 +12130,163 @@ } } }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/compose": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.10.0.tgz", + "integrity": "sha512-ilezxcwhnlCLBZHYjgRgVf4+QxIKb373WLrab47EVFck9GwdLlq1rBWcWCnXME6r1MJ1r8q30RTGSbA5shub6Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/mousetrap": "^1.6.8", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/dom": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/is-shallow-equal": "^5.57.0", + "@wordpress/keycodes": "^4.57.0", + "@wordpress/priority-queue": "^3.57.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/undo-manager": "^1.57.0", + "change-case": "^4.1.2", + "mousetrap": "^1.6.5" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/element": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-9.0.0.tgz", + "integrity": "sha512-o4m80wTlAkqbdJ7F7vkmFzWx6B46fJ3eyG9dWu5BJ1adjL1tA1wv0xStTkFzKuU1iPHCRN0dhauS9G9rfdF3jw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/escape-html": "^3.57.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/private-apis": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-2.0.0.tgz", + "integrity": "sha512-VmGwebQZoebF5t1xFMXbj6z/dTtAca4d8YaO3IPN2aP8IT+eOrUBvoAthC5BmLPU4JLN5m3r8vbrid3mcli6vw==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/style-runtime": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.13.0.tgz", + "integrity": "sha512-P8/7C0NLt+0r6Ks7csJPRUvTgdPccI51AMs05h8/LEJdVtW2k1nSdAgSpgC/lFAsZCPlCeicxjnelJChNRhXZQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0" + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/theme": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-2.3.0.tgz", + "integrity": "sha512-rPIBgKLq3WE0OiwNMBUGQcIpweLGYUjjt6IBn30J1i/Jvxpz8bE/SVMMZxN5R40orq3oswWXvjsuB1L61Bqvkg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/trace-mapping": "^0.3.31", + "@wordpress/compose": "^8.10.0", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/style-runtime": "^0.13.0", + "colorjs.io": "^0.7.1", + "magic-string": "^0.30.21", + "memize": "^2.1.1" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "esbuild": ">=0.27.2 <1.0.0", + "lightningcss": ">=1.33.0 <2.0.0", + "postcss": "^8.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "stylelint": "^16 || ^17", + "vite": "^7 || ^8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/colorjs.io": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.7.1.tgz", + "integrity": "sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/color" + } + }, "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -13123,46 +12973,42 @@ "license": "MIT" }, "node_modules/@wordpress/hooks": { - "version": "4.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.48.0.tgz", - "integrity": "sha512-rU1yGEy0Mb+2oRG5QX/bKIIwKQmYAvATfUQeXIF20/mbR0qutYeVTCIvWEyb4pf71tvnQFiN18RWRXWsvKrDbQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.57.0.tgz", + "integrity": "sha512-vRJxRhOj+a19RvdW6u0ZBmiQc/t/ueVGsIP3IbS9zuvfLJILtvMSWXU8Fe3m/W+mBK5ZEOfm72LYBhWGPse0DA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/html-entities": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.47.0.tgz", - "integrity": "sha512-D3sVJF1uTkjTUJvaAVJsoV8dCkP8q8L29NpavP4qClVQAhlYEynhmMUpesoL163f65Fi7XHwfIVejGjGDrMoXA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.57.0.tgz", + "integrity": "sha512-1g6qT2mnpe1ZGUDNFF8+AfeUDEqGuJgVJsiYz7Z+QPi0tl2kNF2mvSunuZTu6bBuHR9cabwTfD4z9k4CEioQrQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/i18n": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.21.0.tgz", - "integrity": "sha512-IXGGUJqN6b7QddU0dZB3HLJKu6uDQuhLsrrzYpUYTjDhfa43XEaikA9xHNgZhqzRtOVYqsNHVliWcISvJ/xjZQ==", + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.30.0.tgz", + "integrity": "sha512-Q8ivagc2aG+Qe5dTzmiohR+pDo4xDcD746fGI2dq+JIbTibYRnkFoN1VyGFZX/Q+g98I80ZJLJ5Vk3svSXb9ag==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.48.0", + "@wordpress/hooks": "^4.57.0", "gettext-parser": "^1.3.1", - "memize": "^2.1.0", "tannin": "^1.2.0" }, "bin": { "pot-to-php": "tools/pot-to-php.js" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/icons": { @@ -13824,20 +13670,20 @@ "license": "MIT" }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.48.0.tgz", - "integrity": "sha512-7ipiZ1+m84RfuVhiMbtKm6RN571W3ERV/pTL+fSG2qOVhLqccFmliuFHTKQG+0KIhV8DegOlE6eoKOenf+I9ng==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.57.0.tgz", + "integrity": "sha512-QoIVWgnkKHAgrFB9hfpnmGvrAbutsa53DNl08i98guhtVVTSLzttlxgJxA3wpIepmR8FYiLNGB2Kfa49NdyIwg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/jest-console": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.47.0.tgz", - "integrity": "sha512-FM1ySjABL6kdmDWPkEhy12CFiD/GH1OXSH5hUKXssIkrPvWrygrVMU38Yi4DKeWD82C8UZ92VEO2RHOZwCiSLg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.51.0.tgz", + "integrity": "sha512-NhX7hJy0XFYnsjca7RuV7jHsHotRAwKFi8md5By+EJaBdO3itAcBv5/QLLyGBgNXGSvEVGVQfzym/S8CeC8b0Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -13853,14 +13699,16 @@ } }, "node_modules/@wordpress/jest-preset-default": { - "version": "12.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.47.0.tgz", - "integrity": "sha512-L9JIFxRCnMr5G/CR+Npdva2pj7o/Yh629QX9W+rT4dW4Oq+yXqZE64T0eva/V8brz6//0FLrNNKtYXdXXtD+ZQ==", + "version": "12.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.51.0.tgz", + "integrity": "sha512-fJFOCdQHnutv+esdusNufxkrKsCW63UFrgFXWLwGr66YR9q0Z+d1Mw6BVFf7kvzGjT166PBJumf2XTRqUedPNQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/jest-console": "^8.47.0", - "babel-jest": "29.7.0" + "@wordpress/jest-console": "^8.51.0", + "babel-jest": "^29.7.0", + "change-case": "^4.1.2" }, "engines": { "node": ">=18.12.0", @@ -13962,17 +13810,24 @@ "license": "MIT" }, "node_modules/@wordpress/keycodes": { - "version": "4.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.48.0.tgz", - "integrity": "sha512-u3Uxxe3rDAqEmerAiJ2X94s7iO3ZVgS+10MFyD4nWhfuB/C6m/M2TqHPgZiKvyDH04EIhe+pIF2KFO4pq7NWsw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.57.0.tgz", + "integrity": "sha512-IhPt1cAy00wxGECraOlWJMT+PwgLdh1CqS57RDsdhXMCs//AbIKFBMNxvEjPyq9aeU3looVJzcBR1zVhWkDErQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^6.21.0" + "@wordpress/i18n": "^6.30.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@wordpress/latex-to-mathml": { @@ -15213,14 +15068,13 @@ "license": "MIT" }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.47.0.tgz", - "integrity": "sha512-Xn8hqvwDGKAv6VxsNbsLU/+y60vJjhh/8XlSSMJtx/mfGCC6d6W7p8JEdSji4oixbBI4aXg3zRX3zNpvKKKqHg==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.57.0.tgz", + "integrity": "sha512-kyFCVyWnjE/yaE4EbrcsS7ceW6Goi0GD09Wqp82uu8GmWfQS2YtuWDgIVcl4Wy5bPWZd9tnM7PqTsjSGR8+JFA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" }, "peerDependencies": { "npm-package-json-lint": ">=6.0.0" @@ -15838,19 +15692,18 @@ "license": "MIT" }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.47.0.tgz", - "integrity": "sha512-O5JbrKYhXjNsMINhFlXyR91YDw3lP5Ld62XMrHflx8F9bQ0PG/4aAMwPSI+Qazuw0GBeP5qbUaAZoeHo6tsEPA==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.57.0.tgz", + "integrity": "sha512-wbcEyFRa5J9Mv6Hhrzfz/YcadFEoZyWKGqgT5DaE4HKK5PIZI3e5IAznh+m5xuni2ceWnRLR89R33zFvTFew/A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^9.0.0", - "autoprefixer": "^10.4.20", + "@wordpress/browserslist-config": "^6.57.0", + "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" }, "peerDependencies": { "postcss": "^8.0.0" @@ -16162,14 +16015,13 @@ "license": "MIT" }, "node_modules/@wordpress/prettier-config": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.47.0.tgz", - "integrity": "sha512-9XQydHbcUyYse9SI+f4WCGGYHMbIIXFPW5OblmLGrF3gqMyKjvzhxxbEG7qMkkQHwIcKNu3dSJ/FRi7NOdL89A==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.57.0.tgz", + "integrity": "sha512-bUL7MI6c6yCKVj2/OBOlFYAJiqozrnBqwQIj+EYMxxjrxnO6ymUvRL8XbqpKHC4xVUBbQ5aw8Sh+zjQ0DaKfaw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" }, "peerDependencies": { "prettier": ">=3" @@ -16265,17 +16117,16 @@ "license": "MIT" }, "node_modules/@wordpress/priority-queue": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.48.0.tgz", - "integrity": "sha512-NuGrfSSnBC794erb3xSEKrzWLGCNLa+ukob0pyVRtnebU7fPgrhx4NCBCXYK1vTcAta3NAkOVRfUZgcmLFYA6g==", + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.57.0.tgz", + "integrity": "sha512-ZIfR49tJ8MKEARh/Ucy18QsVQXzeriFXcR0q+urvnCwaPwgpAY1bQo1a5oA0Mw5wO+uDvjGt7m5uZZeSxfHzxg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "requestidlecallback": "^0.3.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/private-apis": { @@ -16290,19 +16141,18 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.47.0.tgz", - "integrity": "sha512-r2HV47NUYxk/XiaG47nLgmb5GeC+KtisHVS4WYlKsUe3JlCffKc1dphL58EST1xvCt9UBCiwXfd8+Y0GHaXUpw==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.57.0.tgz", + "integrity": "sha512-k28mJoPzwJaIWtpM6kSSEXG3bggqMbFtaRm9tu9uDYtIc268NYb0j9/wAR8Zi8IZ7Gw2bP3So3r0hv0RoXVIPA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "is-plain-object": "^5.0.0", + "is-plain-object": "^5.1.0", "is-promise": "^4.0.0", "rungen": "^0.3.2" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" }, "peerDependencies": { "redux": ">=4" @@ -16615,24 +16465,150 @@ "license": "MIT" }, "node_modules/@wordpress/rich-text": { - "version": "7.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.47.0.tgz", - "integrity": "sha512-CNQGDtfp9ObpwtIYsAO8UD0mb4hj9F5K8maLn1t83EkxaozwzTGjpGSaV6Y/J9eQd2ZkvG9Cu37YfKO4gRhbHw==", + "version": "7.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.57.0.tgz", + "integrity": "sha512-7Qlh8jynGsaagfOUA3t8bourV+r6F8CN5EOanj6MIrsqZaasLT80zhGZeBBR21V77HVNFFCMlDIFNjxmvgmmxA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/a11y": "^4.57.0", + "@wordpress/compose": "^8.10.0", + "@wordpress/data": "^10.57.0", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/dom": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/escape-html": "^3.57.0", + "@wordpress/i18n": "^6.30.0", + "@wordpress/keycodes": "^4.57.0", + "@wordpress/private-apis": "^2.0.0", + "colord": "^2.9.4" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.10.0.tgz", + "integrity": "sha512-ilezxcwhnlCLBZHYjgRgVf4+QxIKb373WLrab47EVFck9GwdLlq1rBWcWCnXME6r1MJ1r8q30RTGSbA5shub6Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/mousetrap": "^1.6.8", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/dom": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/is-shallow-equal": "^5.57.0", + "@wordpress/keycodes": "^4.57.0", + "@wordpress/priority-queue": "^3.57.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/undo-manager": "^1.57.0", + "change-case": "^4.1.2", + "mousetrap": "^1.6.5" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/rich-text/node_modules/@wordpress/element": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-9.0.0.tgz", + "integrity": "sha512-o4m80wTlAkqbdJ7F7vkmFzWx6B46fJ3eyG9dWu5BJ1adjL1tA1wv0xStTkFzKuU1iPHCRN0dhauS9G9rfdF3jw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/escape-html": "^3.57.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/rich-text/node_modules/@wordpress/private-apis": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-2.0.0.tgz", + "integrity": "sha512-VmGwebQZoebF5t1xFMXbj6z/dTtAca4d8YaO3IPN2aP8IT+eOrUBvoAthC5BmLPU4JLN5m3r8vbrid3mcli6vw==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/route": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/route/-/route-0.13.0.tgz", + "integrity": "sha512-1vzi5uN5nhIwoStTUjtw78xbIB63BXFIFdUwIrOCyy5XbMZ1960SXmsTd7c7jgDxi9Yf60YDW5yKxY45FAJolw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@tanstack/history": "^1.133.28", + "@tanstack/react-router": "^1.120.5", + "@wordpress/private-apis": "^1.47.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/@wordpress/router": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@wordpress/router/-/router-1.47.0.tgz", + "integrity": "sha512-gQjH9S1ztLEkuhxRLN53Sev7o4xnmGDOiNfe+F2jW8WRcDKsmPiEYPG7TZncE+iV+9+HSrps0kmKXcWTX8ae4g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^4.47.0", "@wordpress/compose": "^8.0.0", - "@wordpress/data": "^10.47.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/dom": "^4.47.0", "@wordpress/element": "^7.0.0", - "@wordpress/escape-html": "^3.47.0", - "@wordpress/i18n": "^6.20.0", - "@wordpress/keycodes": "^4.47.0", "@wordpress/private-apis": "^1.47.0", - "colord": "2.9.3", - "memize": "^2.1.0" + "@wordpress/url": "^4.47.0", + "history": "^5.3.0", + "route-recognizer": "^0.3.4" }, "engines": { "node": ">=18.12.0", @@ -16642,7 +16618,7 @@ "react": "^19.2.4" } }, - "node_modules/@wordpress/rich-text/node_modules/@types/react": { + "node_modules/@wordpress/router/node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", @@ -16652,7 +16628,7 @@ "csstype": "^3.2.2" } }, - "node_modules/@wordpress/rich-text/node_modules/@types/react-dom": { + "node_modules/@wordpress/router/node_modules/@types/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", @@ -16662,226 +16638,7 @@ "@types/react": "^19.2.0" } }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.1.0.tgz", - "integrity": "sha512-CAEQxrh3f19ku0SAnYAAiKcUe1zqaK9f0c8vJh+6qrpQnUjl7xLXj5TJOukXlzFH3Z9VZn6fJVfXqmDNAYIhQA==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.48.0", - "@wordpress/dom": "^4.48.0", - "@wordpress/element": "^8.0.0", - "@wordpress/is-shallow-equal": "^5.48.0", - "@wordpress/keycodes": "^4.48.0", - "@wordpress/priority-queue": "^3.48.0", - "@wordpress/private-apis": "^1.48.0", - "@wordpress/undo-manager": "^1.48.0", - "change-case": "^4.1.2", - "mousetrap": "^1.6.5", - "use-memo-one": "^1.1.1" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^18.0.0" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/@wordpress/element": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.0.0.tgz", - "integrity": "sha512-lQ8TB2vBr6lzcwQ2zh0xy+FC77Demb3FqL81fzpSsLbGUF9hZiTyyUuwc6SG21gCkGGjVm2TnU9BuHax/8nDfQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/react": "^18.3.27", - "@types/react-dom": "^18.3.1", - "@wordpress/deprecated": "^4.48.0", - "@wordpress/escape-html": "^3.48.0", - "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^18.3.0", - "react-dom": "^18.3.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/compose/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/@wordpress/rich-text/node_modules/@wordpress/element": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-7.0.0.tgz", - "integrity": "sha512-iJoc3pzeof0mhwN0gVqDqc64xCRoAij2JynU6YxGSmw5a1uJwi1w3GESX3nPR1eaDdv2V6vwkinMBI+aGG7wxQ==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@types/react": "^19.2.13", - "@types/react-dom": "^19.2.3", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/escape-html": "^3.47.0", - "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } - }, - "node_modules/@wordpress/rich-text/node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@wordpress/rich-text/node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.6" - } - }, - "node_modules/@wordpress/rich-text/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@wordpress/route": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@wordpress/route/-/route-0.13.0.tgz", - "integrity": "sha512-1vzi5uN5nhIwoStTUjtw78xbIB63BXFIFdUwIrOCyy5XbMZ1960SXmsTd7c7jgDxi9Yf60YDW5yKxY45FAJolw==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@tanstack/history": "^1.133.28", - "@tanstack/react-router": "^1.120.5", - "@wordpress/private-apis": "^1.47.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/@wordpress/router": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/router/-/router-1.47.0.tgz", - "integrity": "sha512-gQjH9S1ztLEkuhxRLN53Sev7o4xnmGDOiNfe+F2jW8WRcDKsmPiEYPG7TZncE+iV+9+HSrps0kmKXcWTX8ae4g==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@wordpress/compose": "^8.0.0", - "@wordpress/element": "^7.0.0", - "@wordpress/private-apis": "^1.47.0", - "@wordpress/url": "^4.47.0", - "history": "^5.3.0", - "route-recognizer": "^0.3.4" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/@wordpress/router/node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@wordpress/router/node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@wordpress/router/node_modules/@wordpress/compose": { + "node_modules/@wordpress/router/node_modules/@wordpress/compose": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.1.0.tgz", "integrity": "sha512-CAEQxrh3f19ku0SAnYAAiKcUe1zqaK9f0c8vJh+6qrpQnUjl7xLXj5TJOukXlzFH3Z9VZn6fJVfXqmDNAYIhQA==", @@ -17040,30 +16797,30 @@ "license": "MIT" }, "node_modules/@wordpress/scripts": { - "version": "32.3.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-32.3.0.tgz", - "integrity": "sha512-9mmnfys0EzM7jNisSDvC36/50rsqssjobal5B7lTgZNB3hLWByO3RFJRz4EnWK2FRhFkvbZCiZCBU6SWR4zcBA==", + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-32.6.0.tgz", + "integrity": "sha512-nO5s2RYmOWiVjcG3/zp5NEejLu+J/dzujRIl633hd9yj8gBtQRKfZr/iAwyFQwW0Asn15bbwc1I0b4+lvnumOw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@babel/core": "7.25.7", + "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.47.0", - "@wordpress/browserslist-config": "^6.47.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.47.0", - "@wordpress/e2e-test-utils-playwright": "^1.47.0", - "@wordpress/eslint-plugin": "^25.3.0", - "@wordpress/jest-preset-default": "^12.47.0", - "@wordpress/npm-package-json-lint-config": "^5.47.0", - "@wordpress/postcss-plugins-preset": "^5.47.0", - "@wordpress/prettier-config": "^4.47.0", - "@wordpress/stylelint-config": "^23.39.0", + "@wordpress/babel-preset-default": "^8.50.0", + "@wordpress/browserslist-config": "^6.50.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.50.0", + "@wordpress/e2e-test-utils-playwright": "^1.50.0", + "@wordpress/eslint-plugin": "^25.6.0", + "@wordpress/jest-preset-default": "^12.50.0", + "@wordpress/npm-package-json-lint-config": "^5.50.0", + "@wordpress/postcss-plugins-preset": "^5.50.0", + "@wordpress/prettier-config": "^4.50.0", + "@wordpress/stylelint-config": "^23.42.0", "adm-zip": "^0.5.9", - "babel-jest": "29.7.0", - "babel-loader": "9.2.1", + "babel-jest": "^29.7.0", + "babel-loader": "^9.2.1", "browserslist": "^4.21.10", - "chalk": "^4.0.0", + "chalk": "^4.1.1", "check-node-version": "^4.1.0", "copy-webpack-plugin": "^10.2.0", "cross-spawn": "^7.0.6", @@ -17086,9 +16843,9 @@ "minimist": "^1.2.0", "npm-package-json-lint": "^6.4.0", "npm-packlist": "^3.0.0", - "postcss": "^8.4.5", + "postcss": "^8.4.38", "postcss-loader": "^6.2.1", - "prettier": "npm:wp-prettier@3.0.3", + "prettier": "npm:wp-prettier@^3.0.3", "puppeteer-core": "^23.10.1", "react-refresh": "^0.14.0", "read-pkg-up": "^7.0.1", @@ -17116,8 +16873,8 @@ "peerDependencies": { "@playwright/test": "^1.58.2", "@wordpress/env": ">=10.0.0", - "react": "^19.2.4", - "react-dom": "^19.2.4" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "peerDependenciesMeta": { "@wordpress/env": { @@ -17429,17 +17186,16 @@ "license": "MIT" }, "node_modules/@wordpress/shortcode": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.47.0.tgz", - "integrity": "sha512-WGCP3dWN6BwD9jOPLrICtMml4PcA+z01jliepQFrdblol9+McRsss9DZ8pvQQ9jiZEyRqw4SXw8RzDBirjFYYg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.57.0.tgz", + "integrity": "sha512-FQvDTVC6XS/Q8RYxgCLCo/akf/UlgUwHvI8zga0bzKzyuyXHprx4ujQkGO8w4uM4uDSxLVaO/ISYW9KGSNvhpA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "memize": "^2.0.1" + "memize": "^2.1.1" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/style-engine": { @@ -17468,14 +17224,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "23.39.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.39.0.tgz", - "integrity": "sha512-JnEn92PHqgmDo9+ddtauFvUgJhDVKZQLBgDv8DR28nEGtALrIRK63bd2ZK0EyF7JK7jRaUMFAJ4ryPeoy1VdMw==", + "version": "23.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.42.0.tgz", + "integrity": "sha512-k9lNU4sK3fdipdhHI2d/Rzd/NnfznPmOZ60ok8O8QhfCMZw2Jd1M8ckYqaIrHTKU62ZrcsugdfJlHodsdrxEzA==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^3.0.1", - "@wordpress/theme": "^0.14.0", + "@wordpress/theme": "^0.17.0", "stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended-scss": "^14.1.0" }, @@ -17488,6 +17244,178 @@ "stylelint-scss": "^6.4.0" } }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/compose": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.10.0.tgz", + "integrity": "sha512-ilezxcwhnlCLBZHYjgRgVf4+QxIKb373WLrab47EVFck9GwdLlq1rBWcWCnXME6r1MJ1r8q30RTGSbA5shub6Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/mousetrap": "^1.6.8", + "@wordpress/deprecated": "^4.57.0", + "@wordpress/dom": "^4.57.0", + "@wordpress/element": "^9.0.0", + "@wordpress/is-shallow-equal": "^5.57.0", + "@wordpress/keycodes": "^4.57.0", + "@wordpress/priority-queue": "^3.57.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/undo-manager": "^1.57.0", + "change-case": "^4.1.2", + "mousetrap": "^1.6.5" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/compose/node_modules/@wordpress/element": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-9.0.0.tgz", + "integrity": "sha512-o4m80wTlAkqbdJ7F7vkmFzWx6B46fJ3eyG9dWu5BJ1adjL1tA1wv0xStTkFzKuU1iPHCRN0dhauS9G9rfdF3jw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/escape-html": "^3.57.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/compose/node_modules/@wordpress/private-apis": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-2.0.0.tgz", + "integrity": "sha512-VmGwebQZoebF5t1xFMXbj6z/dTtAca4d8YaO3IPN2aP8IT+eOrUBvoAthC5BmLPU4JLN5m3r8vbrid3mcli6vw==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/element": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.8.0.tgz", + "integrity": "sha512-cMQ32/FbQVwwa10+R1QUjJwXM+FSnNtL0t1ZOmY/rdtjm8sZh1A6idpQEC0JFCoBGx61ZXYA2EUyjqJoLyU9QQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/escape-html": "^3.56.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/private-apis": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.56.0.tgz", + "integrity": "sha512-LwtJallQ1xT1ABbulaW9lYswuMS20s/sQCX71Rf9s37M9qQBZ0Pw7hkkeCU6ik6jPkorHsLhbGCeThcAcHYr2w==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/style-runtime": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.5.0.tgz", + "integrity": "sha512-hrXvdDUJpOzT1KIomgtysysgbc5bkkwAuJyEUAXiOCgVBXBeMkZlgfN19W0PuNY51j53K7VQ42txfayxgVmfgA==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/theme": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-0.17.0.tgz", + "integrity": "sha512-BU+PLBLxMBM4WJYrd1QEwy+IA64vUP+8IEEB9lLx2NfB+3/45vvuvXx9o3qPKRDHvtMtEaRArld4Kn05UIW9IA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.2.0", + "@wordpress/deprecated": "^4.49.0", + "@wordpress/element": "^8.1.0", + "@wordpress/private-apis": "^1.49.0", + "@wordpress/style-runtime": "^0.5.0", + "colorjs.io": "^0.6.0", + "memize": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "@types/react": "^18.3.27", + "esbuild": "^0.27.2", + "postcss": "^8.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "stylelint": "^16.8.2", + "vite": "^7.3.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "postcss": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@wordpress/sync": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/@wordpress/sync/-/sync-1.47.0.tgz", @@ -17880,17 +17808,16 @@ "license": "MIT" }, "node_modules/@wordpress/undo-manager": { - "version": "1.48.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.48.0.tgz", - "integrity": "sha512-HqPGxMvZeWZJ6AVaCqZhfGpH6tqq5+hMlaqh4aCO0SvZ2Gvc6fbXEoVpqWfKozO1DyJW2GnRf8At8PpPt2IopQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.57.0.tgz", + "integrity": "sha512-LGTnpgeuRKDiEMrfzfAxkivlhN6apCsIVx3v7lyRb3vcr1rM9j8JL34W/+sLDLaUafb4I1f/LEBFRB18F55/Gg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^5.48.0" + "@wordpress/is-shallow-equal": "^5.57.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/upload-media": { @@ -18416,14 +18343,13 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.47.0.tgz", - "integrity": "sha512-z4jb5zAMY1rFjNUMLaWz9BoHUe8uKfLqgM/20FooynH/AN9nKPBruJmiOOQvT+5m0sIZ8i7roGW2OdpRjtWP2g==", + "version": "3.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.57.0.tgz", + "integrity": "sha512-QZiohW7oPbHci796qcfz6i4jw1ybNwQqCaFk7/KigCUUjXMQv8WoKVX8IdoRF8MpelDmEUoZur6O+KXWPQ0bLw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/widgets": { @@ -18759,47 +18685,47 @@ } }, "node_modules/@wp-playground/blueprints": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/blueprints/-/blueprints-3.1.35.tgz", - "integrity": "sha512-w3+5CK99q/bCfN/HvRi/Kay3u94w33wkkYKt/Y9GKPTXAgkWkpDm7EuMSvSoOPcq6PLRZuWW/t4HZcP8MqR3EQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/blueprints/-/blueprints-3.1.58.tgz", + "integrity": "sha512-w557jhJ64DsUVC2zeX0cT887GZ83XaYENO+GzBVLt8DWAyBEyHRnbEaKQEJAPKWfTYS5vqrx5Q9fABsC5fxhRQ==", "dev": true, "dependencies": { - "@php-wasm/logger": "3.1.35", - "@php-wasm/progress": "3.1.35", - "@php-wasm/stream-compression": "3.1.35", - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35", - "@php-wasm/web-service-worker": "3.1.35", - "@wp-playground/common": "3.1.35", - "@wp-playground/storage": "3.1.35", - "@wp-playground/wordpress": "3.1.35", - "ajv": "8.12.0" + "@php-wasm/logger": "3.1.58", + "@php-wasm/progress": "3.1.58", + "@php-wasm/stream-compression": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", + "@php-wasm/web-service-worker": "3.1.58", + "@wp-playground/common": "3.1.58", + "@wp-playground/storage": "3.1.58", + "@wp-playground/wordpress": "3.1.58", + "ajv": "8.18.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@wp-playground/cli": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/cli/-/cli-3.1.35.tgz", - "integrity": "sha512-QpTWJ+eacdJ+yk2AbjaVJqI3P6iME1drpMOr/uT+U8EQMjDgHQA9nbkh/yux+m7sjFd1UE+/ajT5QZZ7kDWv9Q==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@php-wasm/cli-util": "3.1.35", - "@php-wasm/logger": "3.1.35", - "@php-wasm/node": "3.1.35", - "@php-wasm/progress": "3.1.35", - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35", - "@php-wasm/xdebug-bridge": "3.1.35", - "@wp-playground/blueprints": "3.1.35", - "@wp-playground/common": "3.1.35", - "@wp-playground/storage": "3.1.35", - "@wp-playground/tools": "3.1.35", - "@wp-playground/wordpress": "3.1.35", - "express": "4.22.0", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/cli/-/cli-3.1.58.tgz", + "integrity": "sha512-w+NbAhzbOmtwKa1zSv5V8sKOgYWTip+did2TbvvDp0RcIPe//OCXUYSRGwB6zfLMIpkjQefYT1H2OJHqIAVz8Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/cli-util": "3.1.58", + "@php-wasm/logger": "3.1.58", + "@php-wasm/node": "3.1.58", + "@php-wasm/progress": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", + "@php-wasm/xdebug-bridge": "3.1.58", + "@wp-playground/blueprints": "3.1.58", + "@wp-playground/common": "3.1.58", + "@wp-playground/storage": "3.1.58", + "@wp-playground/tools": "3.1.58", + "@wp-playground/wordpress": "3.1.58", + "express": "4.22.2", "fs-extra": "11.1.1", "tmp-promise": "3.0.3", "wasm-feature-detect": "1.8.0", @@ -18810,65 +18736,72 @@ } }, "node_modules/@wp-playground/common": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-3.1.35.tgz", - "integrity": "sha512-KVU5JWgCVjFU1SJ8PeaIl82oijCqs43JWRR5rfjgfJbDxXl6ntPfY/elPe7eI3noMoeyFJ10pAAO1ESXGWxygA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-3.1.58.tgz", + "integrity": "sha512-KLkPlt2itY4+z3Y0Fbj2n8VWWFMozn/q4yNeZcT8ketATEzMggHOt8koJey9o36wTwOvIMoy2dRjqguFtqxWjw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35" + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@wp-playground/storage": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/storage/-/storage-3.1.35.tgz", - "integrity": "sha512-De7zdHGwBuwQE+kO1p0EGkN8jBim/QJ8ftjRuymxDJXzNimdLmfedqSNa0ajpNcRSq0HKFz/gyxpcWq1hSDnCw==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/storage/-/storage-3.1.58.tgz", + "integrity": "sha512-sx2TTKehzvw3knQ37MHuC1sB4olycESO21a7xOfEmFo3NQdbmx7E/j+La8zkbI0cHnT+D3b7KqEBNVILmREYew==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/stream-compression": "3.1.35", - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35", + "@php-wasm/stream-compression": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", "@zip.js/zip.js": "2.7.57", "isomorphic-git": "1.37.6", "octokit": "3.1.2", - "pako": "^1.0.10" + "pako": "^1.0.10", + "sha.js": "2.4.12" } }, "node_modules/@wp-playground/tools": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/tools/-/tools-3.1.35.tgz", - "integrity": "sha512-tQHcCBmlV7FLj/4YO+3F166/n8J+UNSeJpOnYUrAUiVJjsACoQETYrcal2hejJViEk5KRYlPIISTQeHb9RxBGA==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/tools/-/tools-3.1.58.tgz", + "integrity": "sha512-uAwYVLMXaxl7YquM1Z8/TZSi1ixD4JE50nEddp4Ca2JhIPQNejCVFiPMCN8k6rV+8rjVzZgpVXDzbx71Wcbo5A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wp-playground/blueprints": "3.1.35" + "@php-wasm/node": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", + "@wp-playground/blueprints": "3.1.58", + "@wp-playground/common": "3.1.58", + "@wp-playground/wordpress": "3.1.58" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@wp-playground/wordpress": { - "version": "3.1.35", - "resolved": "https://registry.npmjs.org/@wp-playground/wordpress/-/wordpress-3.1.35.tgz", - "integrity": "sha512-agJyrrlrsIhMYPHsWZudgHnNGOHda8Jg+kX6TB91wgUR581QiXWv0cfubBnNpIKO+4pI6rOSopUu/5T7YKhhZQ==", + "version": "3.1.58", + "resolved": "https://registry.npmjs.org/@wp-playground/wordpress/-/wordpress-3.1.58.tgz", + "integrity": "sha512-n6rGORMCi1ceTjK71wyQn40lRXHdgfzErhwyjCjuKF0XvC7wgKyB28QEj8ASwUfxLIVcsXvEoukv0X9arKLf9g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.35", - "@php-wasm/universal": "3.1.35", - "@php-wasm/util": "3.1.35", - "@wp-playground/common": "3.1.35" + "@php-wasm/logger": "3.1.58", + "@php-wasm/universal": "3.1.58", + "@php-wasm/util": "3.1.58", + "@wp-playground/common": "3.1.58", + "zstddec": "^0.2.0" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=24.18.0", + "npm": ">=11.16.0" } }, "node_modules/@xtuc/ieee754": { @@ -18992,9 +18925,9 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", "dev": true, "license": "MIT", "engines": { @@ -19026,16 +18959,16 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -19175,6 +19108,19 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -19253,18 +19199,18 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" }, @@ -19484,9 +19430,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -19504,8 +19450,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -19544,9 +19490,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -19554,14 +19500,14 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -19959,9 +19905,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -20019,9 +19965,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "dev": true, "license": "MIT", "dependencies": { @@ -20033,7 +19979,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -20074,13 +20020,14 @@ "license": "MIT" }, "node_modules/body-parser/node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -20141,9 +20088,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -20161,11 +20108,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -20449,9 +20396,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -20588,9 +20535,9 @@ } }, "node_modules/chrome-launcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", - "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.2.tgz", + "integrity": "sha512-sI90u5qf3854bTrWsQMMdsQfSjjMbeSn7QtxVb9zrikf+QgMfHQ71KepHW7ZfixQKrDlAFKb+qnVlSR+xepBzw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -20875,9 +20822,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "dev": true, "license": "MIT" }, @@ -20960,15 +20907,16 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -20976,6 +20924,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -21018,22 +20970,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/configstore": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", @@ -21162,12 +21098,15 @@ } }, "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "dev": true, "hasInstallScript": true, "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -21241,10 +21180,20 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -22266,9 +22215,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.362", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", - "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", + "version": "1.5.456", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz", + "integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==", "dev": true, "license": "ISC" }, @@ -22505,6 +22454,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -22526,9 +22494,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -22603,15 +22571,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -22827,9 +22798,9 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, "license": "ISC", "dependencies": { @@ -22862,9 +22833,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -22931,9 +22902,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -22965,21 +22936,22 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "28.14.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.14.0.tgz", - "integrity": "sha512-P9s/qXSMTpRTerE2FQ0qJet2gKbcGyFTPAJipoKxmWqR6uuFqIqk8FuEfg5yBieOezVrEfAMZrEwJ6yEp+1MFQ==", + "version": "29.17.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz", + "integrity": "sha512-6jtpU6MhYKbagkES02TupWAUUooJpZtKg94Ax0b8HhUorv3InSTBU8RRtlSBe0LocOcShXOpyec3PHzPDkJCSg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/utils": "^8.0.0" }, "engines": { - "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0", - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", - "jest": "*" + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" }, "peerDependenciesMeta": { "@typescript-eslint/eslint-plugin": { @@ -22987,6 +22959,9 @@ }, "jest": { "optional": true + }, + "typescript": { + "optional": true } } }, @@ -23047,9 +23022,9 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -23097,9 +23072,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -23121,9 +23096,9 @@ } }, "node_modules/eslint-plugin-playwright": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.10.4.tgz", - "integrity": "sha512-l0V/VxyqfFbtqCTxj5AdRn3Q6S/hIW4nKBnKZVleVbZ24N2My6Usj//ytX3dKKqAoSbvKck9YtSytfdZ5qjLuA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.1.tgz", + "integrity": "sha512-JhavNuYBIq7SE/Zg9Esf9RGHkXoDbzrEKS383cowzMyvZ4JMbmdNCN6CoW+RU1Cjzwcoea1tou0je58uXmT/9A==", "dev": true, "license": "MIT", "dependencies": { @@ -23137,9 +23112,9 @@ } }, "node_modules/eslint-plugin-playwright/node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -23150,14 +23125,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz", + "integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.12.3" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -23241,9 +23216,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -23666,15 +23641,15 @@ "license": "MIT" }, "node_modules/express": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz", - "integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -23693,7 +23668,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -23737,38 +23712,26 @@ "license": "MIT" }, "node_modules/extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", + "debug": "^4.1.1", + "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "bin": { "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" } }, - "node_modules/extract-zip/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/extract-zip/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-average-color": { "version": "9.5.2", "resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.2.tgz", @@ -23844,10 +23807,27 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", "dev": true, "funding": [ { @@ -23857,14 +23837,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" } }, "node_modules/fast-xml-parser": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", - "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", "dev": true, "funding": [ { @@ -23874,11 +23854,12 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.1.0", + "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.3.0", - "xml-naming": "^0.1.0" + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -24241,17 +24222,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -24405,18 +24386,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -24585,9 +24569,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -24680,9 +24664,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -25017,9 +25001,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -25281,9 +25265,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -25316,9 +25300,9 @@ } }, "node_modules/http-link-header": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz", - "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.4.tgz", + "integrity": "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==", "dev": true, "license": "MIT", "engines": { @@ -25362,9 +25346,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -25506,9 +25490,9 @@ "license": "MIT" }, "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -25537,9 +25521,9 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "dev": true, "license": "MIT" }, @@ -25711,9 +25695,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "dev": true, "license": "MIT", "engines": { @@ -25849,9 +25833,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -25941,6 +25925,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -26107,9 +26107,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "dev": true, "license": "MIT", "engines": { @@ -26255,6 +26255,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -26420,9 +26433,9 @@ } }, "node_modules/isomorphic-git/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", "dev": true, "license": "MIT", "dependencies": { @@ -27394,9 +27407,9 @@ } }, "node_modules/joi": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", - "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -27437,9 +27450,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -27605,9 +27618,9 @@ } }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -27727,14 +27740,14 @@ } }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz", + "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.10.0" } }, "node_modules/lazy-cache": { @@ -27748,9 +27761,9 @@ } }, "node_modules/legacy-javascript": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", - "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.3.tgz", + "integrity": "sha512-1r6MT4dVaJyXt4G07Cm2cQ0AUaLJdefiEp4SmIAylA++gnEQ82YvmzE58RkM5EP0fk5hROLCslUPGnwIYdZQVA==", "dev": true, "license": "Apache-2.0" }, @@ -27898,27 +27911,6 @@ "devtools-protocol": "*" } }, - "node_modules/lighthouse/node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/lighthouse/node_modules/puppeteer-core": { "version": "24.43.1", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", @@ -27946,9 +27938,9 @@ "license": "BSD-3-Clause" }, "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -27968,9 +27960,9 @@ } }, "node_modules/lighthouse/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -27981,9 +27973,9 @@ } }, "node_modules/lighthouse/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "dev": true, "license": "MIT", "engines": { @@ -28269,6 +28261,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -28415,9 +28417,9 @@ "license": "MIT" }, "node_modules/markdownlint-cli/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -28446,10 +28448,20 @@ } }, "node_modules/markdownlint-cli/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -28485,6 +28497,19 @@ "dev": true, "license": "MIT" }, + "node_modules/marked": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz", + "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -28886,19 +28911,6 @@ "node": ">=0.10.0" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -28907,9 +28919,9 @@ "license": "MIT" }, "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", "dev": true, "license": "MIT", "engines": { @@ -28995,16 +29007,16 @@ } }, "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -29090,9 +29102,9 @@ "optional": true }, "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", "dev": true, "license": "MIT", "dependencies": { @@ -29126,9 +29138,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { @@ -29787,13 +29799,14 @@ } }, "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" }, @@ -30093,9 +30106,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "dev": true, "funding": [ { @@ -30194,9 +30207,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "dev": true, "license": "MIT" }, @@ -30435,9 +30448,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -30455,9 +30468,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -30569,9 +30582,9 @@ } }, "node_modules/postcss-import": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz", - "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.2.0.tgz", + "integrity": "sha512-0mQUGlSp87Zl70K58RNwQAN1WS9plFE6KWGui9nyK6ninduMyjW/Khaoy/BpBoFTBh7ndAvAKrSKVbwy6vd/BA==", "dev": true, "license": "MIT", "dependencies": { @@ -30792,9 +30805,9 @@ } }, "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -30822,9 +30835,9 @@ } }, "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -31115,9 +31128,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -31303,13 +31316,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -31371,9 +31377,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -31382,6 +31388,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-agent": { @@ -31505,13 +31515,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -31707,9 +31718,9 @@ } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, @@ -31819,24 +31830,11 @@ } }, "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/read-cache/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, "node_modules/read-pkg": { "version": "5.2.0", @@ -32151,13 +32149,6 @@ "node": ">=8.6.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, - "license": "ISC" - }, "node_modules/requireindex": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", @@ -32357,9 +32348,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -32835,9 +32826,9 @@ } }, "node_modules/seroval": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", - "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.9.tgz", + "integrity": "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==", "dev": true, "license": "MIT", "engines": { @@ -32950,13 +32941,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -33097,9 +33081,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { @@ -33116,255 +33100,16 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/showdown": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/showdown/-/showdown-1.9.1.tgz", - "integrity": "sha512-9cGuS382HcvExtf5AHk7Cb4pAeQQ+h0eTr33V1mu+crYWV4KvWAw6el92bDrqGEk5d46Ai/fhbEUwqJ/mTCNEA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "yargs": "^14.2" - }, - "bin": { - "showdown": "bin/showdown.js" - } - }, - "node_modules/showdown/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/showdown/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - } - }, - "node_modules/showdown/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/showdown/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/showdown/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true, - "license": "MIT" - }, - "node_modules/showdown/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/showdown/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/showdown/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/showdown/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/showdown/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/showdown/node_modules/yargs": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", - "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^5.0.0", - "decamelize": "^1.2.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^15.0.1" - } - }, - "node_modules/showdown/node_modules/yargs-parser": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.3.tgz", - "integrity": "sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -33652,9 +33397,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -34015,25 +33760,25 @@ } }, "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" + "side-channel": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -34054,19 +33799,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -34076,16 +33822,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -34209,9 +33955,9 @@ } }, "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", "dev": true, "funding": [ { @@ -34219,7 +33965,10 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/stubborn-fs": { "version": "2.0.0", @@ -34417,9 +34166,9 @@ } }, "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -34594,10 +34343,20 @@ "license": "ISC" }, "node_modules/stylelint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -34630,9 +34389,9 @@ } }, "node_modules/stylelint/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -34734,9 +34493,9 @@ "dev": true }, "node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "dev": true, "license": "MIT", "dependencies": { @@ -34798,13 +34557,14 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz", + "integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@dual-bundle/import-meta-resolve": "^4.2.1", + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -35071,9 +34831,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -35126,9 +34886,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -35143,9 +34903,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -35169,20 +34929,20 @@ } }, "node_modules/tldts-core": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", - "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "dev": true, "license": "MIT" }, "node_modules/tldts-icann": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.4.0.tgz", - "integrity": "sha512-guDQQAxukhpUJB4ax1Is9tWrSYRbvDAJFSMPCHSSc+C5viEfLpoF5KELybFUuw0nno2R2sP+oXHfAxZkbSd8KQ==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.4.18.tgz", + "integrity": "sha512-+29GkEj4qOTb8oQRzg7Lxe1TQdkIDFvyqVTcu775faf9iLczNTix8L/wDFiQi4/Ncib7ZjyWPcVf0iRQCrmUjg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.0" + "tldts-core": "^7.4.18" } }, "node_modules/tldts/node_modules/tldts-core": { @@ -35193,9 +34953,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -35485,19 +35245,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", "gopd": "^1.2.0", - "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -35507,18 +35266,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -35534,24 +35293,17 @@ "dev": true, "license": "MIT" }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -35730,9 +35482,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -36567,9 +36319,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -36739,17 +36491,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true, - "license": "ISC" - }, "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -36848,9 +36593,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -36893,9 +36638,9 @@ } }, "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", "dev": true, "funding": [ { @@ -37088,9 +36833,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { @@ -37109,6 +36854,13 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "dev": true, + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index fa42362..6801667 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "build": "wp-scripts build --webpack-src-dir=src/js", "make-pot": "wp-env run cli wp i18n make-pot wp-content/plugins/beehiiv wp-content/plugins/beehiiv/languages/beehiiv.pot --domain=beehiiv --exclude=node_modules,vendor,tests,dist,build,build-dev,build-module,build-style", "build:zip": "npm run build && composer install --no-dev --optimize-autoloader --prefer-dist --no-interaction --no-progress && find . -name '.DS_Store' -not -path './node_modules/*' -not -path './.git/*' -delete && rm -rf dist && mkdir -p dist && composer archive --format=zip --dir=dist --file=beehiiv", + "build:zip:release": "bash scripts/build-release-zip.sh", "format": "wp-scripts format", "lint": "npm run lint:js && npm run lint:css && npm run lint:php", "lint:js": "wp-scripts lint-js", @@ -42,8 +43,14 @@ "env:start": "wp-env start && npm run env:hosts", "env:start:doppler": "doppler run --mount .wp-env.override.json --mount-template .wp-env.override.tmpl -- npm run env:start", "env:hosts": "sh scripts/wp-env-extra-hosts.sh", + "env:set-version": "node scripts/wp-env-set-version.js", "env:stop": "wp-env stop", - "env:destroy": "wp-env destroy" + "env:destroy": "wp-env destroy", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "node tests/e2e/report/generate-dashboard.js", + "test:php:report": "node tests/phpunit/report/generate-dashboard.js", + "test:compat": "node scripts/test-compat.js" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..04166e2 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + ./tests/phpunit + + + + + + diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..e5e9624 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,43 @@ +const { defineConfig } = require( '@playwright/test' ); + +/** + * Runs against wp-env's dedicated tests environment (testsEnvironment: true + * in .wp-env.json), never the dev environment, so E2E runs never touch real + * dev data. Port matches the explicit env.tests.port set in .wp-env.json + * (chosen to avoid colliding with other wp-env projects on this machine — + * the wp-env default of 8889 was already taken locally). + */ +const baseURL = process.env.WP_BASE_URL || 'http://localhost:8891'; + +module.exports = defineConfig( { + testDir: './tests/e2e/specs', + outputDir: './tests/e2e/test-results', + timeout: 30 * 1000, + expect: { + timeout: 5 * 1000, + }, + // fullyParallel: false only serializes tests *within* a file -- separate + // spec files still default to running in separate parallel workers. + // Every spec here writes real data (posts, users, plugin state) into the + // one shared wp-env tests environment, so cross-file concurrency corrupts + // state the same way parallel qa-e2e-author runs would -- force a single + // worker so every spec file runs strictly one at a time too. + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [ + [ 'list' ], + [ 'json', { outputFile: 'tests/e2e/test-results/results.json' } ], + ], + use: { + baseURL, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +} ); diff --git a/readme.txt b/readme.txt index c6e1807..eeef2b8 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: newsletter, email, publishing, beehiiv, subscribe Requires at least: 6.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.0.2 +Stable tag: 1.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -145,6 +145,15 @@ It is visible in the editor but does not render on the front end of your site. == Changelog == += 1.1.0 = +* Added a newsletter title and subtitle for each post, used as the email subject line and subtitle on beehiiv. +* Added a "show title and subtitle in email" toggle for each post. +* Added the ability to choose which beehiiv publication each post is sent to. +* Added a "Send test email" section to the post editor, so you can send a test of the saved post to any addresses you choose without sending the newsletter. +* Newsletters that haven't been sent now stay in step with their post on every save, including SEO metadata and tags. +* A newsletter beehiiv has already sent is never changed by later edits. +* Clearing a previously set subtitle now removes it on beehiiv. + = 1.0.2 = * Fixed connecting a beehiiv account on WordPress.org installs of 1.0.1. diff --git a/scripts/build-release-zip.sh b/scripts/build-release-zip.sh new file mode 100755 index 0000000..9c0cb39 --- /dev/null +++ b/scripts/build-release-zip.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build dist/beehiiv.zip with the registration token baked in, matching what +# .github/workflows/deploy.yml ships to WordPress.org. Unlike CI's throwaway +# checkout, this runs against the real working tree, so the token is written +# to includes/OAuth/Config.php only for the duration of the build and is +# always restored afterward, even on failure. +set -euo pipefail +cd "$(dirname "$0")/.." + +CONFIG_FILE="includes/OAuth/Config.php" +ENV_FILE=".env.build" + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +if [ -z "${BEEHIIV_REGISTRATION_TOKEN:-}" ]; then + echo "BEEHIIV_REGISTRATION_TOKEN is not set. Copy .env.build.example to .env.build and fill in a token (see wp-config.php for the local value), or export it in your shell." >&2 + exit 1 +fi + +BACKUP_FILE="$(mktemp)" +cp "$CONFIG_FILE" "$BACKUP_FILE" + +restore_config() { + cp "$BACKUP_FILE" "$CONFIG_FILE" + rm -f "$BACKUP_FILE" +} +trap restore_config EXIT + +npm run build + +composer install --no-dev --optimize-autoloader --prefer-dist --no-interaction --no-progress + +perl -pi -e 's/BEEHIIV_REGISTRATION_TOKEN_PLACEHOLDER/$ENV{BEEHIIV_REGISTRATION_TOKEN}/g' "$CONFIG_FILE" +if grep -q 'BEEHIIV_REGISTRATION_TOKEN_PLACEHOLDER' "$CONFIG_FILE"; then + echo "Failed to replace registration token placeholder" >&2 + exit 1 +fi + +find . -name '.DS_Store' -not -path './node_modules/*' -not -path './.git/*' -delete +rm -rf dist +mkdir -p dist +composer archive --format=zip --dir=dist --file=beehiiv + +echo "Built dist/beehiiv.zip with registration token injected." diff --git a/scripts/ci-patch-debian-base.sh b/scripts/ci-patch-debian-base.sh new file mode 100755 index 0000000..ce244aa --- /dev/null +++ b/scripts/ci-patch-debian-base.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Re-tag wordpress:php with apt sources pointed at archive.debian.org. +# +# The PHP 7.4 / 8.0 official images are Debian bullseye, whose security mirror +# no longer serves packages. wp-env's generated Dockerfile runs +# `apt-get install` on top of those images and fails with 404s. Building +# over the same local tag lets wp-env pick up the patched base unchanged. +# +# Usage: scripts/ci-patch-debian-base.sh e.g. 7.4 + +set -euo pipefail + +php_version="${1:?Usage: $0 }" +image="wordpress:php${php_version}" + +docker pull "${image}" + +docker build -t "${image}" - < /etc/apt/apt.conf.d/99archive +DOCKERFILE diff --git a/scripts/test-compat.js b/scripts/test-compat.js new file mode 100644 index 0000000..91f3ad8 --- /dev/null +++ b/scripts/test-compat.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- CLI script, console is its output. */ +/** + * Runs PHPUnit against a specific PHP / WordPress core combination and + * writes an HTML dashboard scoped to that combination, for + * backward-compatibility testing. + * + * Usage: + * npm run test:compat -- --php=8.1 --wp=6.8 + * + * Writes: + * tests/phpunit/test-results/php8.1-wp6.8/junit.xml + * tests/phpunit/test-results/php8.1-wp6.8/dashboard.html + * + * Runs `wp-env destroy` before starting, so each combination gets a genuinely + * clean WordPress install rather than the new core version's files sitting on + * top of a database from a previous version (which triggers the "database + * needs to be updated" nag and can skew test results). THIS DELETES YOUR + * CURRENT WP-ENV DATABASE AND CONTENT for this project -- only run this + * against a wp-env instance you're fine wiping, not one with dev content you + * want to keep. Plain `npm run env:set-version` + `npm run env:start` (for + * manual browsing) does not destroy anything. + * + * Leaves the wp-env PHP/WP version set via scripts/wp-env-set-version.js so + * a subsequent `npm run env:start` keeps using this combination; run + * `npm run env:set-version -- --clear` to go back to the tracked default. + */ + +const { execFileSync } = require( 'child_process' ); +const path = require( 'path' ); + +const PLUGIN_ROOT = path.join( __dirname, '..' ); + +function parseArgs( argv ) { + const args = {}; + for ( const arg of argv ) { + const match = arg.match( /^--([a-z-]+)(?:=(.*))?$/ ); + if ( match ) { + args[ match[ 1 ] ] = match[ 2 ] ?? true; + } + } + return args; +} + +const args = parseArgs( process.argv.slice( 2 ) ); + +if ( ! args.php || ! args.wp ) { + console.error( + 'Usage: npm run test:compat -- --php= --wp=' + ); + process.exit( 1 ); +} + +const slug = `php${ args.php }-wp${ args.wp }`; +const reportDir = `tests/phpunit/test-results/${ slug }`; +const junitPath = `${ reportDir }/junit.xml`; +const dashboardPath = `${ reportDir }/dashboard.html`; +const label = `PHP ${ args.php } / WP ${ args.wp }`; + +function run( command, commandArgs ) { + console.log( `$ ${ command } ${ commandArgs.join( ' ' ) }` ); + execFileSync( command, commandArgs, { + stdio: 'inherit', + cwd: PLUGIN_ROOT, + } ); +} + +run( 'node', [ + 'scripts/wp-env-set-version.js', + `--php=${ args.php }`, + `--wp=${ args.wp }`, + '--destroy', +] ); +run( 'npx', [ 'wp-env', 'start' ] ); + +let exitCode = 0; +try { + run( 'npx', [ + 'wp-env', + 'run', + 'tests-cli', + '--', + 'bash', + '-c', + `cd wp-content/plugins/beehiiv && mkdir -p ${ reportDir } && vendor/bin/phpunit -c phpunit.xml.dist --log-junit ${ junitPath }`, + ] ); +} catch { + exitCode = 1; +} + +try { + run( 'node', [ + 'tests/phpunit/report/generate-dashboard.js', + `--junit=${ junitPath }`, + `--out=${ dashboardPath }`, + `--label=${ label }`, + ] ); +} catch { + console.error( 'Could not generate dashboard (no JUnit report to read).' ); + exitCode = 1; +} + +try { + run( 'npx', [ 'wp-env', 'stop' ] ); +} catch { + // Ignore stop failures; the run's own exit code already reflects the test outcome. +} + +process.exit( exitCode ); diff --git a/scripts/wp-env-set-version.js b/scripts/wp-env-set-version.js new file mode 100644 index 0000000..1c90da8 --- /dev/null +++ b/scripts/wp-env-set-version.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- CLI script, console is its output. */ +/** + * Point wp-env at a specific PHP / WordPress core version, for backward-compatibility testing. + * + * Writes `phpVersion` / `core` into `.wp-env.override.json`, which wp-env deep-merges + * over `.wp-env.json` natively. That file is gitignored, so this never touches tracked + * config, and wp-env rebuilds the affected containers automatically the next time + * `wp-env start` runs, because it detects that the resolved config changed. + * + * Usage: + * node scripts/wp-env-set-version.js --php=8.1 --wp=6.8 + * node scripts/wp-env-set-version.js --php=8.3 # PHP only, leave WP core as-is + * node scripts/wp-env-set-version.js --wp=nightly # WordPress/WordPress#master + * node scripts/wp-env-set-version.js --wp=latest # wp-env default (newest stable) + * node scripts/wp-env-set-version.js --clear # remove both overrides + * + * Then apply with: npm run env:start + * + * Add --destroy to also run `wp-env destroy` right away (e.g. + * `--php=8.1 --wp=6.8 --destroy`). wp-env only refreshes the WordPress core + * files when the version changes, not the database, so a stale database + * can trigger the "database needs to be updated" screen or skew test + * results after switching versions -- --destroy avoids that by starting + * the next `wp-env start` from a clean install. THIS DELETES YOUR CURRENT + * WP-ENV DATABASE AND CONTENT for this project; omit it to keep them. + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); +const { execFileSync } = require( 'child_process' ); + +const PLUGIN_ROOT = path.join( __dirname, '..' ); +const overridePath = path.join( PLUGIN_ROOT, '.wp-env.override.json' ); + +function parseArgs( argv ) { + const args = {}; + for ( const arg of argv ) { + const match = arg.match( /^--([a-z]+)(?:=(.*))?$/ ); + if ( match ) { + args[ match[ 1 ] ] = match[ 2 ] ?? true; + } + } + return args; +} + +// Maps a WP core version string to the wp-env `core` value that pins it. +function resolveCore( wpVersion ) { + if ( ! wpVersion || wpVersion === 'latest' ) { + return undefined; // Fall back to wp-env's own default (newest stable). + } + if ( wpVersion === 'nightly' || wpVersion === 'trunk' ) { + return 'WordPress/WordPress#master'; + } + return `https://wordpress.org/wordpress-${ wpVersion }.zip`; +} + +function readOverride() { + if ( ! fs.existsSync( overridePath ) ) { + return {}; + } + return JSON.parse( fs.readFileSync( overridePath, 'utf8' ) ); +} + +function writeOverride( config ) { + fs.writeFileSync( + overridePath, + `${ JSON.stringify( config, null, '\t' ) }\n` + ); +} + +function destroyEnv() { + console.log( + 'Destroying wp-env (database and content for this project will be lost)...' + ); + try { + execFileSync( 'npx', [ 'wp-env', 'destroy', '--force' ], { + stdio: 'inherit', + cwd: PLUGIN_ROOT, + } ); + } catch { + // Nothing to destroy on a first run; a later `wp-env start` still works fine. + } +} + +const args = parseArgs( process.argv.slice( 2 ) ); +const override = readOverride(); + +if ( args.clear ) { + delete override.phpVersion; + delete override.core; + writeOverride( override ); + console.log( + 'Cleared phpVersion/core overrides from .wp-env.override.json.' + ); + if ( args.destroy ) { + destroyEnv(); + } + process.exit( 0 ); +} + +if ( ! args.php && ! args.wp ) { + console.error( + 'Usage: node scripts/wp-env-set-version.js --php= --wp= [--destroy]\n' + + ' node scripts/wp-env-set-version.js --clear [--destroy]' + ); + process.exit( 1 ); +} + +if ( args.php ) { + override.phpVersion = args.php; +} + +if ( args.wp ) { + const core = resolveCore( args.wp ); + if ( core === undefined ) { + delete override.core; + } else { + override.core = core; + } +} + +writeOverride( override ); + +console.log( + `Updated .wp-env.override.json — phpVersion: ${ + override.phpVersion ?? '(default)' + }, core: ${ override.core ?? '(latest stable)' }` +); + +if ( args.destroy ) { + destroyEnv(); +} + +console.log( + 'Run `npm run env:start` to apply (wp-env rebuilds automatically).' +); diff --git a/src/js/admin/settings/index.js b/src/js/admin/settings/index.js index 396cec1..82f51ee 100644 --- a/src/js/admin/settings/index.js +++ b/src/js/admin/settings/index.js @@ -12,12 +12,24 @@ const refreshTemplatesButton = document.getElementById( 'beehiiv_refresh_post_templates' ); +const refreshPublicationsButton = document.getElementById( + 'beehiiv_refresh_publications' +); + const refreshDefaultLabel = refreshTemplatesButton?.textContent.trim() || __( 'Refresh templates', 'beehiiv' ); -let refreshNotice = null; -let refreshNoticeTimer = null; +const refreshPublicationsDefaultLabel = + refreshPublicationsButton?.textContent.trim() || + __( 'Refresh publications', 'beehiiv' ); + +/** + * Confirmation notice element and its dismiss timer, per refresh button. + * + * @type {Map} + */ +const refreshNotices = new Map(); /** * Show or hide the "no templates available" notice for the current publication. @@ -55,40 +67,56 @@ function updateNoTemplatesNotice( hasTemplates ) { } /** - * Show an auto-dismissing confirmation after a manual refresh. + * Show an auto-dismissing confirmation next to a refresh button. + * + * @param {HTMLElement|null} button Refresh button the notice follows. + * @param {string} message Confirmation text. * * @return {void} */ -function showRefreshNotice() { - if ( ! refreshTemplatesButton ) { +function showRefreshNotice( button, message ) { + if ( ! button ) { return; } - if ( ! refreshNotice ) { - refreshNotice = document.createElement( 'span' ); - refreshNotice.className = 'beehiiv-refresh-notice'; - refreshNotice.setAttribute( 'role', 'status' ); - refreshTemplatesButton.insertAdjacentElement( - 'afterend', - refreshNotice - ); + let state = refreshNotices.get( button ); + + if ( ! state ) { + const element = document.createElement( 'span' ); + element.className = 'beehiiv-refresh-notice'; + element.setAttribute( 'role', 'status' ); + button.insertAdjacentElement( 'afterend', element ); + state = { element, timer: null }; + refreshNotices.set( button, state ); } - refreshNotice.textContent = __( - 'Templates updated from beehiiv.', - 'beehiiv' - ); - refreshNotice.hidden = false; + state.element.textContent = message; + state.element.hidden = false; - if ( refreshNoticeTimer ) { - clearTimeout( refreshNoticeTimer ); + if ( state.timer ) { + clearTimeout( state.timer ); } - refreshNoticeTimer = setTimeout( () => { - refreshNotice.hidden = true; + state.timer = setTimeout( () => { + state.element.hidden = true; }, 4000 ); } +/** + * Hide a refresh button's confirmation notice, if shown. + * + * @param {HTMLElement|null} button Refresh button. + * + * @return {void} + */ +function hideRefreshNotice( button ) { + const state = button ? refreshNotices.get( button ) : null; + + if ( state ) { + state.element.hidden = true; + } +} + /** * Build option elements for the template dropdown. * @@ -157,9 +185,7 @@ async function loadTemplates( publicationId, refresh = false ) { if ( refresh ) { refreshTemplatesButton.textContent = __( 'Refreshing…', 'beehiiv' ); - if ( refreshNotice ) { - refreshNotice.hidden = true; - } + hideRefreshNotice( refreshTemplatesButton ); } } @@ -182,7 +208,10 @@ async function loadTemplates( publicationId, refresh = false ) { } if ( refresh ) { - showRefreshNotice(); + showRefreshNotice( + refreshTemplatesButton, + __( 'Templates updated from beehiiv.', 'beehiiv' ) + ); } } catch { populateTemplateOptions( [] ); @@ -208,3 +237,93 @@ if ( refreshTemplatesButton ) { loadTemplates( publicationSelect?.value, true ); } ); } + +/** + * Rebuild the publication dropdown, keeping the selected publication. + * + * A selected publication that beehiiv no longer returns stays listed by ID, + * as it is when the page first renders. + * + * @param {Array<{id: string, name: string}>} items Publication items. + * @param {string} selected Publication ID to keep selected. + * + * @return {void} + */ +function populatePublicationOptions( items, selected ) { + while ( publicationSelect.options.length > 0 ) { + publicationSelect.remove( 0 ); + } + + const emptyOption = document.createElement( 'option' ); + emptyOption.value = ''; + emptyOption.textContent = __( 'Select a publication', 'beehiiv' ); + publicationSelect.appendChild( emptyOption ); + + items.forEach( ( item ) => { + if ( ! item?.id ) { + return; + } + + const option = document.createElement( 'option' ); + option.value = item.id; + option.textContent = item.name || item.id; + publicationSelect.appendChild( option ); + } ); + + if ( + selected && + ! [ ...publicationSelect.options ].some( + ( option ) => option.value === selected + ) + ) { + const option = document.createElement( 'option' ); + option.value = selected; + option.textContent = selected; + publicationSelect.appendChild( option ); + } + + publicationSelect.value = selected; +} + +/** + * Pull a fresh publication list from beehiiv, bypassing the server cache. + * + * @return {Promise} + */ +async function refreshPublications() { + if ( ! publicationSelect || ! refreshPublicationsButton ) { + return; + } + + const previousValue = publicationSelect.value; + + publicationSelect.disabled = true; + refreshPublicationsButton.disabled = true; + refreshPublicationsButton.textContent = __( 'Refreshing…', 'beehiiv' ); + hideRefreshNotice( refreshPublicationsButton ); + + try { + const items = await apiFetch( { + path: '/beehiiv/v1/publications?refresh=1', + } ); + + populatePublicationOptions( + Array.isArray( items ) ? items : [], + previousValue + ); + showRefreshNotice( + refreshPublicationsButton, + __( 'Publications updated from beehiiv.', 'beehiiv' ) + ); + } catch { + // Keep the current list when beehiiv cannot be reached. + } finally { + publicationSelect.disabled = false; + refreshPublicationsButton.disabled = false; + refreshPublicationsButton.textContent = refreshPublicationsDefaultLabel; + } +} + +if ( refreshPublicationsButton ) { + refreshPublicationsButton.addEventListener( 'click', refreshPublications ); +} diff --git a/src/js/editor/post-settings/components/newsletter-linked-notice.js b/src/js/editor/post-settings/components/newsletter-linked-notice.js index e27d703..9b2e1a7 100644 --- a/src/js/editor/post-settings/components/newsletter-linked-notice.js +++ b/src/js/editor/post-settings/components/newsletter-linked-notice.js @@ -18,7 +18,7 @@ import getBeehiivPostPreviewUrl from '../utils/get-beehiiv-post-preview-url'; * @param {string} dateValue ISO 8601 datetime string. * @return {string|null} Formatted date, time, and timezone abbreviation. */ -function formatSiteDateTime( dateValue ) { +export function formatSiteDateTime( dateValue ) { const timestamp = new Date( dateValue ).getTime(); if ( Number.isNaN( timestamp ) ) { @@ -38,7 +38,7 @@ function formatSiteDateTime( dateValue ) { * @param {string|null|undefined} sendToNewsletterDate Custom send datetime from post meta. * @return {string|null} Raw datetime string for a future send, or null when not scheduled. */ -function getFutureNewsletterSendDateString( +export function getFutureNewsletterSendDateString( scheduledAtUtc, sendToNewsletterDate ) { diff --git a/src/js/editor/post-settings/components/newsletter-publication-select.js b/src/js/editor/post-settings/components/newsletter-publication-select.js new file mode 100644 index 0000000..39fd5eb --- /dev/null +++ b/src/js/editor/post-settings/components/newsletter-publication-select.js @@ -0,0 +1,127 @@ +/** + * Publication picker for a post's newsletter. + */ +import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; +import { Button, SelectControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; + +import { refreshEditorPublications } from '../hooks/use-editor-publications'; + +/** + * @param {Object} props + * @param {Array<{id: string, name: string}>} props.publications Connected publications. + * @param {string} props.value Publication to show as selected, or empty. + * @param {(value: string) => void} props.onChange Called when the user picks a publication. + * @param {boolean} [props.disabled] Whether the choice is locked. + */ +export default function NewsletterPublicationSelect( { + publications, + value, + onChange, + disabled = false, +} ) { + const [ isRefreshing, setIsRefreshing ] = useState( false ); + const [ justRefreshed, setJustRefreshed ] = useState( false ); + const noticeTimer = useRef( null ); + + useEffect( () => { + return () => { + if ( noticeTimer.current ) { + clearTimeout( noticeTimer.current ); + } + }; + }, [] ); + + const handleRefresh = () => { + setJustRefreshed( false ); + setIsRefreshing( true ); + + if ( noticeTimer.current ) { + clearTimeout( noticeTimer.current ); + } + + refreshEditorPublications() + .then( ( refreshed ) => { + if ( ! refreshed ) { + return; + } + + setJustRefreshed( true ); + noticeTimer.current = setTimeout( + () => setJustRefreshed( false ), + 4000 + ); + } ) + .finally( () => setIsRefreshing( false ) ); + }; + + const options = useMemo( () => { + const opts = publications.map( ( item ) => ( { + value: item.id, + label: item.name || item.id, + } ) ); + + if ( value && ! opts.some( ( option ) => option.value === value ) ) { + opts.push( { + value, + label: sprintf( + /* translators: %s: beehiiv publication ID */ + __( '%s (no longer connected)', 'beehiiv' ), + value + ), + } ); + } + + if ( ! value ) { + opts.unshift( { + value: '', + label: __( 'Select a publication', 'beehiiv' ), + } ); + } + + return opts; + }, [ publications, value ] ); + + if ( publications.length === 0 && ! value ) { + return null; + } + + return ( +
+ + + { justRefreshed && ( +

+ { __( 'Publications updated from beehiiv.', 'beehiiv' ) } +

+ ) } +
+ ); +} diff --git a/src/js/editor/post-settings/components/newsletter-status-notices.js b/src/js/editor/post-settings/components/newsletter-status-notices.js index f797693..777194b 100644 --- a/src/js/editor/post-settings/components/newsletter-status-notices.js +++ b/src/js/editor/post-settings/components/newsletter-status-notices.js @@ -1,12 +1,13 @@ /** * Connection, configuration, and newsletter send error notices. */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { createInterpolateElement } from '@wordpress/element'; import { ExternalLink } from '@wordpress/components'; import PostSettingsNotice from './post-settings-notice'; import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; +import { usePostPublication } from '../hooks/use-post-publication'; import renderSettingsLinkMessage from '../utils/render-settings-link-message'; /** @@ -14,14 +15,9 @@ import renderSettingsLinkMessage from '../utils/render-settings-link-message'; * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta|null} props.beehiivMeta */ export default function NewsletterStatusNotices( { beehiivMeta } ) { - const { - isConnected, - canWritePosts, - settingsUrl, - pricingUrl, - hasPublication, - hasPostTemplate, - } = useBeehiivEditorConfig(); + const { isConnected, canWritePosts, settingsUrl, pricingUrl } = + useBeehiivEditorConfig(); + const postPublication = usePostPublication( beehiivMeta ); if ( ! isConnected ) { return ( @@ -54,39 +50,117 @@ export default function NewsletterStatusNotices( { beehiivMeta } ) { ); } - let errorMessage = ''; + if ( postPublication.publications.length === 0 ) { + return ( + + { renderSettingsLinkMessage( + __( + 'Choose a publication in beehiiv settings to send newsletters.', + 'beehiiv' + ), + settingsUrl + ) } + + ); + } + + return ( + <> + + + + ); +} + +/** + * Publication and template the post still needs before it can send. + * + * @param {Object} props + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta|null} props.beehiivMeta + * @param {import('../hooks/use-post-publication').PostPublication} props.postPublication + */ +function PostPublicationNotices( { beehiivMeta, postPublication } ) { + if ( + ! beehiivMeta?.sendToNewsletter || + beehiivMeta.newsletterAlreadySent + ) { + return null; + } + + const { + publications, + defaultPublicationId, + isFallback, + missingPublication, + missingTemplate, + } = postPublication; - if ( ! hasPublication && ! hasPostTemplate ) { - errorMessage = __( - 'Choose a publication and default post template in beehiiv settings.', - 'beehiiv' + if ( isFallback ) { + const defaultPublication = publications.find( + ( item ) => item.id === defaultPublicationId ); - } else if ( ! hasPublication ) { - errorMessage = __( - 'Choose a publication in beehiiv settings to send newsletters.', - 'beehiiv' + + return ( + + { sprintf( + /* translators: %s: default beehiiv publication name */ + __( + 'The publication chosen for this post is no longer connected. The newsletter will be sent to the default publication "%s" instead.', + 'beehiiv' + ), + defaultPublication?.name || defaultPublicationId + ) } + ); - } else if ( ! hasPostTemplate ) { - errorMessage = __( - 'Choose a default post template in beehiiv settings to send newsletters.', - 'beehiiv' + } + + if ( missingPublication ) { + return ( + + { __( 'Choose a publication for this post.', 'beehiiv' ) } + ); } - if ( errorMessage ) { + if ( missingTemplate ) { return ( - - { renderSettingsLinkMessage( errorMessage, settingsUrl ) } + + { __( 'Pick a post template for this post.', 'beehiiv' ) } ); } + return null; +} + +/** + * Save or send error recorded by the server for this post. + * + * @param {Object} props + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta|null} props.beehiivMeta + * @param {string} props.settingsUrl + */ +function NewsletterErrorNotice( { beehiivMeta, settingsUrl } ) { if ( ! beehiivMeta?.newsletterError ) { return null; } const { newsletterError, newsletterErrorType } = beehiivMeta; + if ( newsletterErrorType === 'publication_fallback' ) { + return ( + + { newsletterError } + + ); + } + if ( newsletterErrorType === 'save' ) { return ( diff --git a/src/js/editor/post-settings/components/newsletter-template-select.js b/src/js/editor/post-settings/components/newsletter-template-select.js index c886511..8453cfc 100644 --- a/src/js/editor/post-settings/components/newsletter-template-select.js +++ b/src/js/editor/post-settings/components/newsletter-template-select.js @@ -16,15 +16,30 @@ import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; /** * @param {Object} props - * @param {string} props.value Saved template ID, or empty for plugin default. - * @param {(value: string) => void} props.onChange Called when the user picks a template. + * @param {string} props.value Saved template ID, or empty for the default. + * @param {(value: string) => void} props.onChange Called when the user picks a template. + * @param {string} props.publicationId Publication whose templates are listed. + * @param {boolean} props.isDefaultPublication Whether that is the site-wide default publication. + * @param {boolean} [props.disabled] Whether the choice is locked. */ -export default function NewsletterTemplateSelect( { value, onChange } ) { - const { publicationId, defaultPostTemplateId } = useBeehiivEditorConfig(); +export default function NewsletterTemplateSelect( { + value, + onChange, + publicationId, + isDefaultPublication, + disabled = false, +} ) { + const { defaultPostTemplateId: siteDefaultPostTemplateId } = + useBeehiivEditorConfig(); + // Only the site-wide default publication has a default template. + const defaultPostTemplateId = isDefaultPublication + ? siteDefaultPostTemplateId + : ''; const [ templates, setTemplates ] = useState( [] ); const [ isLoading, setIsLoading ] = useState( false ); const [ justRefreshed, setJustRefreshed ] = useState( false ); const noticeTimer = useRef( null ); + const requestId = useRef( 0 ); useEffect( () => { return () => { @@ -36,8 +51,12 @@ export default function NewsletterTemplateSelect( { value, onChange } ) { const loadTemplates = useCallback( ( { refresh = false } = {} ) => { + // Ignore responses for a publication the editor has since switched away from. + const currentRequest = ++requestId.current; + if ( ! publicationId ) { setTemplates( [] ); + setIsLoading( false ); return Promise.resolve(); } @@ -49,19 +68,26 @@ export default function NewsletterTemplateSelect( { value, onChange } ) { ) }${ refresh ? '&refresh=1' : '' }`, } ) .then( ( items ) => { - setTemplates( Array.isArray( items ) ? items : [] ); + if ( currentRequest === requestId.current ) { + setTemplates( Array.isArray( items ) ? items : [] ); + } } ) .catch( () => { - setTemplates( [] ); + if ( currentRequest === requestId.current ) { + setTemplates( [] ); + } } ) .finally( () => { - setIsLoading( false ); + if ( currentRequest === requestId.current ) { + setIsLoading( false ); + } } ); }, [ publicationId ] ); useEffect( () => { + setTemplates( [] ); loadTemplates(); }, [ loadTemplates ] ); @@ -81,19 +107,41 @@ export default function NewsletterTemplateSelect( { value, onChange } ) { } ); }, [ loadTemplates ] ); + // The default template shows as selected when the post has no template of its own. + const selectedValue = value || defaultPostTemplateId; + const options = useMemo( () => { - const opts = [ - { - value: '', - label: __( 'Default (from beehiiv settings)', 'beehiiv' ), - }, - ...templates - .filter( ( item ) => item?.id ) - .map( ( item ) => ( { - value: item.id, - label: item.name || item.id, - } ) ), - ]; + const opts = templates + .filter( ( item ) => item?.id ) + .map( ( item ) => ( { + value: item.id, + label: + item.id === defaultPostTemplateId + ? sprintf( + /* translators: %s: post template name */ + __( '%s (default)', 'beehiiv' ), + item.name || item.id + ) + : item.name || item.id, + } ) ); + + if ( opts.length === 0 && ! selectedValue ) { + return opts; + } + + if ( + defaultPostTemplateId && + ! opts.some( ( option ) => option.value === defaultPostTemplateId ) + ) { + opts.unshift( { + value: defaultPostTemplateId, + label: sprintf( + /* translators: %s: post template ID */ + __( '%s (default)', 'beehiiv' ), + defaultPostTemplateId + ), + } ); + } if ( value && ! opts.some( ( option ) => option.value === value ) ) { opts.push( { @@ -102,46 +150,15 @@ export default function NewsletterTemplateSelect( { value, onChange } ) { } ); } - return opts; - }, [ templates, value ] ); - - const defaultTemplateName = useMemo( () => { - if ( ! defaultPostTemplateId ) { - return ''; - } - - const match = templates.find( - ( item ) => item?.id === defaultPostTemplateId - ); - - return match?.name || defaultPostTemplateId; - }, [ templates, defaultPostTemplateId ] ); - - const helpText = useMemo( () => { - if ( value ) { - return undefined; - } - - if ( defaultTemplateName ) { - return sprintf( - /* translators: %s: post template name from beehiiv settings */ - __( 'Uses %s from beehiiv settings.', 'beehiiv' ), - defaultTemplateName - ); - } - - if ( defaultPostTemplateId ) { - return __( - 'Uses the template configured in beehiiv settings.', - 'beehiiv' - ); + if ( ! selectedValue ) { + opts.unshift( { + value: '', + label: __( 'Select a template', 'beehiiv' ), + } ); } - return __( - 'No site default is set. Choose a template or configure one in beehiiv settings.', - 'beehiiv' - ); - }, [ value, defaultTemplateName, defaultPostTemplateId ] ); + return opts; + }, [ templates, value, selectedValue, defaultPostTemplateId ] ); if ( isLoading && options.length === 0 ) { return ( @@ -159,17 +176,24 @@ export default function NewsletterTemplateSelect( { value, onChange } ) {
+ + { result && ( + +

+ { renderSettingsLinkMessage( + result.message, + settingsUrl + ) } +

+ { result.leftoverDraft && ( +

+ { __( + 'A leftover test draft may still be in beehiiv. You can delete it there.', + 'beehiiv' + ) } +

+ ) } +
+ ) } +
+ ); +} diff --git a/src/js/editor/post-settings/css/_newsletter-template.scss b/src/js/editor/post-settings/css/_newsletter-template.scss index 1cc1f09..1b7ec2a 100644 --- a/src/js/editor/post-settings/css/_newsletter-template.scss +++ b/src/js/editor/post-settings/css/_newsletter-template.scss @@ -16,4 +16,16 @@ margin: 0; } } + +.beehiiv-newsletter-publication { + display: flex; + flex-direction: column; + gap: 12px; + + &__refresh-notice { + color: var(--wp-admin-theme-color, #2271b1); + font-size: 12px; + margin: 0; + } +} /* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/css/_newsletter-wording.scss b/src/js/editor/post-settings/css/_newsletter-wording.scss new file mode 100644 index 0000000..0186706 --- /dev/null +++ b/src/js/editor/post-settings/css/_newsletter-wording.scss @@ -0,0 +1,8 @@ +/* stylelint-disable selector-class-pattern -- BEM + @wordpress/components */ +.beehiiv-newsletter-wording { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 24px; +} +/* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/css/_test-email.scss b/src/js/editor/post-settings/css/_test-email.scss new file mode 100644 index 0000000..38727f0 --- /dev/null +++ b/src/js/editor/post-settings/css/_test-email.scss @@ -0,0 +1,18 @@ +/* stylelint-disable selector-class-pattern -- BEM + @wordpress/components */ +.beehiiv-test-email { + display: flex; + flex-direction: column; + gap: 12px; + + &__note, + &__reason { + color: #757575; + font-size: 12px; + margin: 0; + } + + .components-button { + align-self: flex-start; + } +} +/* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/editor.scss b/src/js/editor/post-settings/editor.scss index 7b54ebf..22077a3 100644 --- a/src/js/editor/post-settings/editor.scss +++ b/src/js/editor/post-settings/editor.scss @@ -1,4 +1,6 @@ @use "css/sidebar"; @use "css/newsletter-date"; @use "css/newsletter-template"; +@use "css/newsletter-wording"; @use "css/omitted-blocks"; +@use "css/test-email"; diff --git a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js index f10a3b9..100bed7 100644 --- a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js +++ b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js @@ -13,6 +13,8 @@ const DEFAULT_CONFIG = { publicationId: '', defaultPostTemplateId: '', canPublishPosts: false, + defaultPublicationId: '', + publications: [], }; /** diff --git a/src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js b/src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js index a03a42e..c97c3f0 100644 --- a/src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js +++ b/src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js @@ -18,24 +18,43 @@ import { META_SEND_TO_NEWSLETTER, META_SEND_TO_NEWSLETTER_DATE, META_BEEHIIV_POST_TEMPLATE_ID, + META_BEEHIIV_PUBLICATION_ID, META_SEND_TO_NEWSLETTER_SNIPPET, + META_NEWSLETTER_TITLE, + META_NEWSLETTER_SUBTITLE, + META_NEWSLETTER_SHOW_TITLE_IN_EMAIL, } from '../../../shared/meta'; +/** + * Milliseconds before `scheduled_at` from which a scheduled newsletter counts as + * sent. Matches the server's `Sender::SENT_SAFETY_MARGIN`. + */ +const SENT_SAFETY_MARGIN_MS = 60 * 1000; + /** * @typedef {Object} BeehiivPostMeta - * @property {boolean} sendToNewsletter Whether this post is queued for beehiiv. - * @property {string|null} sendToNewsletterDate ISO 8601 datetime, or null to send on WP post publish. - * @property {boolean} sendToNewsletterSnippet Whether to send a snippet instead of the full post. - * @property {string} beehiivPostTemplateId beehiiv post template ID, or empty for plugin default. - * @property {boolean} newsletterAlreadySent Whether this post is linked to a beehiiv newsletter. - * @property {string|null} beehiivPostId Linked beehiiv post ID from post meta. - * @property {string|null} beehiivScheduledAt UTC ISO 8601 beehiiv send time, or null when sent immediately. - * @property {string|null} newsletterError User-facing save or send error from the server. - * @property {string|null} newsletterErrorType `save` or `send` when {@link newsletterError} is set. - * @property {(enabled: boolean) => void} setSendToNewsletter Enable or disable newsletter delivery. - * @property {(date: string|null) => void} setSendToNewsletterDate Set scheduled send time. - * @property {(enabled: boolean) => void} setSendToNewsletterSnippet Enable or disable snippet delivery. - * @property {(templateId: string) => void} setBeehiivPostTemplateId Set the post template for this post. + * @property {boolean} sendToNewsletter Whether this post is queued for beehiiv. + * @property {string|null} sendToNewsletterDate ISO 8601 datetime, or null to send on WP post publish. + * @property {boolean} sendToNewsletterSnippet Whether to send a snippet instead of the full post. + * @property {string} beehiivPostTemplateId beehiiv post template ID, or empty for plugin default. + * @property {string} beehiivPublicationId beehiiv publication ID chosen for this post, or empty for the site default. + * @property {boolean} newsletterAlreadySent Whether this post is linked to a beehiiv newsletter. + * @property {boolean} newsletterPublished Whether beehiiv has sent (or is about to send) the linked newsletter. + * @property {string|null} beehiivPostId Linked beehiiv post ID from post meta. + * @property {string|null} beehiivScheduledAt UTC ISO 8601 beehiiv send time, or null when sent immediately. + * @property {string|null} newsletterError User-facing save or send error from the server. + * @property {string|null} newsletterErrorType `save` or `send` when {@link newsletterError} is set. + * @property {string} newsletterTitle Newsletter title (email subject line), or empty to use the post title. + * @property {string} newsletterSubtitle Newsletter subtitle (beehiiv post subtitle), or empty for none. + * @property {boolean} newsletterShowTitleInEmail Whether the email shows the title and subtitle. False when never set. + * @property {(title: string) => void} setNewsletterTitle Set the newsletter title. No-op once published. + * @property {(subtitle: string) => void} setNewsletterSubtitle Set the newsletter subtitle. No-op once published. + * @property {(show: boolean) => void} setShowTitleInEmail Show or hide the title and subtitle in the email. No-op once published. + * @property {(enabled: boolean) => void} setSendToNewsletter Enable or disable newsletter delivery. + * @property {(date: string|null) => void} setSendToNewsletterDate Set scheduled send time. + * @property {(enabled: boolean) => void} setSendToNewsletterSnippet Enable or disable snippet delivery. + * @property {(templateId: string) => void} setBeehiivPostTemplateId Set the post template for this post. + * @property {(publicationId: string) => void} setBeehiivPublicationId Set the publication for this post and clear its template. */ /** @@ -132,6 +151,8 @@ export function useBeehiivPostMeta() { if ( linkStateChanged ) { syncedMeta[ META_SEND_TO_NEWSLETTER ] = !! serverMeta[ META_SEND_TO_NEWSLETTER ]; + syncedMeta[ META_BEEHIIV_PUBLICATION_ID ] = + serverMeta[ META_BEEHIIV_PUBLICATION_ID ] ?? ''; } setMeta( syncedMeta ); @@ -193,6 +214,9 @@ export function useBeehiivPostMeta() { const rawPostTemplateId = meta?.[ META_BEEHIIV_POST_TEMPLATE_ID ]; const beehiivPostTemplateId = typeof rawPostTemplateId === 'string' ? rawPostTemplateId : ''; + const rawPublicationId = meta?.[ META_BEEHIIV_PUBLICATION_ID ]; + const beehiivPublicationId = + typeof rawPublicationId === 'string' ? rawPublicationId : ''; const rawDate = meta?.[ META_SEND_TO_NEWSLETTER_DATE ]; const sendToNewsletterDate = typeof rawDate === 'string' && rawDate.length > 0 ? rawDate : null; @@ -208,6 +232,16 @@ export function useBeehiivPostMeta() { rawBeehiivScheduledAt.length > 0 ? rawBeehiivScheduledAt : null; + // Linked is not the same as published: a linked newsletter stays unsent until + // its scheduled time. No scheduled time on a linked post means it was sent + // immediately. The stored value is UTC with a `Z` suffix. + const scheduledAtTime = beehiivScheduledAt + ? new Date( beehiivScheduledAt ).getTime() + : NaN; + const newsletterPublished = + newsletterAlreadySent && + ( Number.isNaN( scheduledAtTime ) || + scheduledAtTime <= Date.now() + SENT_SAFETY_MARGIN_MS ); const rawNewsletterError = meta?.[ META_NEWSLETTER_ERROR ]; const newsletterError = typeof rawNewsletterError === 'string' && rawNewsletterError.length > 0 @@ -220,6 +254,15 @@ export function useBeehiivPostMeta() { ? rawNewsletterErrorType : null; + const rawNewsletterTitle = meta?.[ META_NEWSLETTER_TITLE ]; + const newsletterTitle = + typeof rawNewsletterTitle === 'string' ? rawNewsletterTitle : ''; + const rawNewsletterSubtitle = meta?.[ META_NEWSLETTER_SUBTITLE ]; + const newsletterSubtitle = + typeof rawNewsletterSubtitle === 'string' ? rawNewsletterSubtitle : ''; + const newsletterShowTitleInEmail = + !! meta?.[ META_NEWSLETTER_SHOW_TITLE_IN_EMAIL ]; + const patchMeta = ( patch ) => { setMeta( { ...meta, ...patch } ); }; @@ -229,11 +272,43 @@ export function useBeehiivPostMeta() { sendToNewsletterDate, sendToNewsletterSnippet, beehiivPostTemplateId, + beehiivPublicationId, newsletterAlreadySent, + newsletterPublished, beehiivPostId, beehiivScheduledAt, newsletterError, newsletterErrorType, + newsletterTitle, + newsletterSubtitle, + newsletterShowTitleInEmail, + setNewsletterTitle( title ) { + if ( newsletterPublished ) { + return; + } + + patchMeta( { + [ META_NEWSLETTER_TITLE ]: title ?? '', + } ); + }, + setNewsletterSubtitle( subtitle ) { + if ( newsletterPublished ) { + return; + } + + patchMeta( { + [ META_NEWSLETTER_SUBTITLE ]: subtitle ?? '', + } ); + }, + setShowTitleInEmail( show ) { + if ( newsletterPublished ) { + return; + } + + patchMeta( { + [ META_NEWSLETTER_SHOW_TITLE_IN_EMAIL ]: !! show, + } ); + }, setSendToNewsletter( enabled ) { if ( newsletterAlreadySent ) { return; @@ -269,7 +344,7 @@ export function useBeehiivPostMeta() { } ); }, setBeehiivPostTemplateId( templateId ) { - if ( newsletterAlreadySent ) { + if ( newsletterPublished ) { return; } @@ -277,5 +352,16 @@ export function useBeehiivPostMeta() { [ META_BEEHIIV_POST_TEMPLATE_ID ]: templateId ?? '', } ); }, + setBeehiivPublicationId( publicationId ) { + if ( newsletterPublished ) { + return; + } + + // A template belongs to one publication, so switching clears it. + patchMeta( { + [ META_BEEHIIV_PUBLICATION_ID ]: publicationId ?? '', + [ META_BEEHIIV_POST_TEMPLATE_ID ]: '', + } ); + }, }; } diff --git a/src/js/editor/post-settings/hooks/use-editor-publications.js b/src/js/editor/post-settings/hooks/use-editor-publications.js new file mode 100644 index 0000000..446c193 --- /dev/null +++ b/src/js/editor/post-settings/hooks/use-editor-publications.js @@ -0,0 +1,71 @@ +/** + * Connected beehiiv publications for the block editor, shared across components. + * + * Seeded from the localized editor config. A refresh replaces the list for every + * component reading it (selector, notices, pre-publish panel). + */ +import { useSyncExternalStore } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +import { useBeehiivEditorConfig } from './use-beehiiv-editor-config'; + +/** @type {Array<{id: string, name: string}>|null} */ +let refreshedPublications = null; +const listeners = new Set(); + +/** + * @param {() => void} listener Called when the list changes. + * @return {() => void} Unsubscribe. + */ +function subscribe( listener ) { + listeners.add( listener ); + + return () => listeners.delete( listener ); +} + +function getSnapshot() { + return refreshedPublications; +} + +/** + * Pull a fresh publication list from beehiiv, bypassing the server cache. + * + * An empty or failed response keeps the current list. + * + * @return {Promise} Whether the list was refreshed. + */ +export async function refreshEditorPublications() { + try { + const items = await apiFetch( { + path: '/beehiiv/v1/publications?refresh=1', + } ); + const publications = Array.isArray( items ) + ? items.filter( ( item ) => item?.id ) + : []; + + if ( publications.length === 0 ) { + return false; + } + + refreshedPublications = publications; + listeners.forEach( ( listener ) => listener() ); + + return true; + } catch { + return false; + } +} + +/** + * @return {Array<{id: string, name: string}>} Connected publications. + */ +export function useEditorPublications() { + const { publications } = useBeehiivEditorConfig(); + const refreshed = useSyncExternalStore( subscribe, getSnapshot ); + + if ( refreshed ) { + return refreshed; + } + + return Array.isArray( publications ) ? publications : []; +} diff --git a/src/js/editor/post-settings/hooks/use-post-publication.js b/src/js/editor/post-settings/hooks/use-post-publication.js new file mode 100644 index 0000000..18886ed --- /dev/null +++ b/src/js/editor/post-settings/hooks/use-post-publication.js @@ -0,0 +1,64 @@ +/** + * Per-post beehiiv publication state for the block editor. + * + * Mirrors the server's `PublicationResolver`: an empty choice uses the site-wide + * default publication, and a choice that is no longer connected falls back to it. + */ +import { useBeehiivEditorConfig } from './use-beehiiv-editor-config'; +import { useEditorPublications } from './use-editor-publications'; + +/** + * @typedef {Object} PostPublication + * @property {Array<{id: string, name: string}>} publications Connected publications. + * @property {string} defaultPublicationId Site-wide default publication ID. + * @property {string} storedPublicationId Publication saved on the post, or empty. + * @property {string} effectivePublicationId Publication the newsletter will use, or empty. + * @property {boolean} isDefaultPublication Whether the effective publication is the default. + * @property {boolean} isFallback Whether the saved publication is no longer connected. + * @property {boolean} missingPublication Whether no publication can be used yet. + * @property {boolean} missingTemplate Whether the post still needs a template. + * @property {boolean} isPostReady Whether the post has a publication and a template. + */ + +/** + * @param {import('./use-beehiiv-post-meta').BeehiivPostMeta|null} beehiivMeta Post meta. + * @return {PostPublication} Per-post publication state. + */ +export function usePostPublication( beehiivMeta ) { + const { defaultPublicationId, defaultPostTemplateId } = + useBeehiivEditorConfig(); + const publications = useEditorPublications().filter( ( item ) => item?.id ); + const storedPublicationId = beehiivMeta?.beehiivPublicationId || ''; + const templateId = beehiivMeta?.beehiivPostTemplateId || ''; + + const isFallback = + !! storedPublicationId && + storedPublicationId !== defaultPublicationId && + !! defaultPublicationId && + publications.length > 0 && + ! publications.some( ( item ) => item.id === storedPublicationId ); + + const effectivePublicationId = isFallback + ? defaultPublicationId + : storedPublicationId || defaultPublicationId || ''; + const isDefaultPublication = + !! effectivePublicationId && + effectivePublicationId === defaultPublicationId; + + // On fallback the server uses the default template only. + const hasTemplate = isFallback + ? !! defaultPostTemplateId + : !! templateId || ( isDefaultPublication && !! defaultPostTemplateId ); + + return { + publications, + defaultPublicationId: defaultPublicationId || '', + storedPublicationId, + effectivePublicationId, + isDefaultPublication, + isFallback, + missingPublication: ! effectivePublicationId, + missingTemplate: !! effectivePublicationId && ! hasTemplate, + isPostReady: !! effectivePublicationId && hasTemplate, + }; +} diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index cb83868..636895f 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -10,7 +10,7 @@ import { PluginPrePublishPanel, store as editorStore, } from '@wordpress/editor'; -import { PanelBody, ToggleControl } from '@wordpress/components'; +import { PanelBody, TextControl, ToggleControl } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -20,13 +20,16 @@ import NewsletterDatePicker, { } from './components/newsletter-date-picker'; import NewsletterLinkedNotice from './components/newsletter-linked-notice'; import NewsletterStatusNotices from './components/newsletter-status-notices'; +import NewsletterPublicationSelect from './components/newsletter-publication-select'; import NewsletterTemplateSelect from './components/newsletter-template-select'; import PostSettingsNotice from './components/post-settings-notice'; import SendNewsletterToggle from './components/send-newsletter-toggle'; +import TestEmailSend from './components/test-email-send'; import IncompleteAdvertisementNotice from './components/incomplete-advertisement-notice'; import { OmittedBlocksNoticeMessage } from './components/omitted-blocks-notice'; import { useBeehiivEditorConfig } from './hooks/use-beehiiv-editor-config'; import { useBeehiivPostMeta } from './hooks/use-beehiiv-post-meta'; +import { usePostPublication } from './hooks/use-post-publication'; import './filters/with-omitted-block-indicator'; import './editor.scss'; @@ -44,10 +47,79 @@ function usePostPublishDate() { ); } +/** + * Publication and template pickers for a post's newsletter. + * + * @param {Object} props + * @param {import('./hooks/use-beehiiv-post-meta').BeehiivPostMeta} props.beehiivMeta Post meta. + * @param {import('./hooks/use-post-publication').PostPublication} props.postPublication Per-post publication state. + */ +function NewsletterPublicationAndTemplate( { beehiivMeta, postPublication } ) { + const { + beehiivPostTemplateId, + newsletterAlreadySent, + setBeehiivPublicationId, + setBeehiivPostTemplateId, + } = beehiivMeta; + const { + publications, + storedPublicationId, + defaultPublicationId, + effectivePublicationId, + isDefaultPublication, + isFallback, + } = postPublication; + + return ( + <> + + + { effectivePublicationId && ( + + ) } + + ); +} + +/** + * Whether newsletters can be sent from this site at all. + * + * Per-post publication and template readiness is reported by + * {@link NewsletterStatusNotices} and enforced on save. + * + * @param {import('./hooks/use-post-publication').PostPublication} postPublication Per-post publication state. + * @return {boolean} Whether the send settings can be used. + */ +function useIsNewsletterReady( postPublication ) { + const { isConnected, canWritePosts } = useBeehiivEditorConfig(); + + return ( + isConnected && canWritePosts && postPublication.publications.length > 0 + ); +} + function BeehiivPostSettingsPanel() { const beehiivMeta = useBeehiivPostMeta(); - const { isConnected, canWritePosts, hasPublication, hasPostTemplate } = - useBeehiivEditorConfig(); + const { isConnected, canWritePosts } = useBeehiivEditorConfig(); + const postPublication = usePostPublication( beehiivMeta ); + const isNewsletterReady = useIsNewsletterReady( postPublication ); if ( ! beehiivMeta ) { return null; @@ -57,18 +129,73 @@ function BeehiivPostSettingsPanel() { sendToNewsletter, sendToNewsletterDate, sendToNewsletterSnippet, - beehiivPostTemplateId, newsletterAlreadySent, + newsletterTitle, + newsletterSubtitle, + newsletterShowTitleInEmail, + newsletterPublished, setSendToNewsletter, setSendToNewsletterDate, setSendToNewsletterSnippet, - setBeehiivPostTemplateId, + setNewsletterTitle, + setNewsletterSubtitle, + setShowTitleInEmail, } = beehiivMeta; - const isNewsletterReady = - isConnected && canWritePosts && hasPublication && hasPostTemplate; const showSendToggle = ! isConnected || canWritePosts; + // Test send follows the toggle's readiness: a post that still needs a + // template cannot send a test. Linked posts already carry theirs. + const isTemplateMissing = + postPublication.missingTemplate && ! newsletterAlreadySent; + + // Newsletter wording stays editable while a linked newsletter is still + // scheduled, so it also renders for linked posts, where the other send + // settings are hidden. A send turns "Send to newsletter" back off, so the + // linked case checks the link alone. + const newsletterWording = ( +
+ + + +
+ ); + return (
@@ -85,6 +212,15 @@ function BeehiivPostSettingsPanel() { + { newsletterAlreadySent && isNewsletterReady && ( + <> + + { newsletterWording } + + ) } { sendToNewsletter && ! newsletterAlreadySent && isNewsletterReady && ( @@ -109,11 +245,13 @@ function BeehiivPostSettingsPanel() { onChange={ setSendToNewsletterDate } /> - + { newsletterWording } + ) } + { isNewsletterReady && isTemplateMissing && ( + + + { __( + 'Pick a post template for this post to send a test email.', + 'beehiiv' + ) } + + + ) } + { isNewsletterReady && ! isTemplateMissing && ( + + + + ) }
); } @@ -186,8 +339,9 @@ function BeehiivSendNewsletterPrePublishPanel() { const { postType, canPublishPosts } = useBeehiivPostSettingsEligibility(); const beehiivMeta = useBeehiivPostMeta(); const postPublishDate = usePostPublishDate(); - const { isConnected, canWritePosts, hasPublication, hasPostTemplate } = - useBeehiivEditorConfig(); + const { isConnected, canWritePosts } = useBeehiivEditorConfig(); + const postPublication = usePostPublication( beehiivMeta ); + const isNewsletterReady = useIsNewsletterReady( postPublication ); if ( postType && postType !== 'post' ) { return null; @@ -207,8 +361,6 @@ function BeehiivSendNewsletterPrePublishPanel() { setSendToNewsletter, newsletterAlreadySent, } = beehiivMeta; - const isNewsletterReady = - isConnected && canWritePosts && hasPublication && hasPostTemplate; const showSendToggle = ! isConnected || canWritePosts; const sendDateValidation = getNewsletterSendDateValidation( sendToNewsletterDate, diff --git a/src/js/shared/meta.js b/src/js/shared/meta.js index 4c3f0ed..09bef02 100644 --- a/src/js/shared/meta.js +++ b/src/js/shared/meta.js @@ -8,7 +8,12 @@ export const META_SEND_TO_NEWSLETTER_DATE = '_beehiiv_send_to_newsletter_date'; export const META_SEND_TO_NEWSLETTER_SNIPPET = '_beehiiv_send_to_newsletter_snippet'; export const META_BEEHIIV_POST_TEMPLATE_ID = '_beehiiv_post_template_id'; +export const META_BEEHIIV_PUBLICATION_ID = '_beehiiv_publication_id'; export const META_BEEHIIV_POST_ID = '_beehiiv_post_id'; export const META_BEEHIIV_SCHEDULED_AT = '_beehiiv_scheduled_at'; export const META_NEWSLETTER_ERROR = '_beehiiv_newsletter_error'; export const META_NEWSLETTER_ERROR_TYPE = '_beehiiv_newsletter_error_type'; +export const META_NEWSLETTER_TITLE = '_beehiiv_newsletter_title'; +export const META_NEWSLETTER_SUBTITLE = '_beehiiv_newsletter_subtitle'; +export const META_NEWSLETTER_SHOW_TITLE_IN_EMAIL = + '_beehiiv_newsletter_show_title_in_email'; diff --git a/tests/e2e/plugins/beehiiv-options.php b/tests/e2e/plugins/beehiiv-options.php index c1b484e..56fe2ee 100644 --- a/tests/e2e/plugins/beehiiv-options.php +++ b/tests/e2e/plugins/beehiiv-options.php @@ -1,6 +1,366 @@ $mock ) { + if ( ! is_string( $key ) || '' === $key || ! is_array( $mock ) ) { + continue; + } + + // A mock may carry its own `needle` (so several mocks can share one + // URL substring, told apart by `method`); otherwise the key is the needle. + $needle = isset( $mock['needle'] ) && is_string( $mock['needle'] ) ? $mock['needle'] : $key; + + // Optional `pattern` (regex) replaces the substring match -- needed when + // several endpoints share a URL prefix (e.g. beehiiv post create, + // delete, and test_sends all live under /publications/{id}/posts). + if ( isset( $mock['pattern'] ) && is_string( $mock['pattern'] ) ) { + if ( ! preg_match( $mock['pattern'], $url ) ) { + continue; + } + } elseif ( '' === $needle || false === strpos( $url, $needle ) ) { + continue; + } + + if ( isset( $mock['method'] ) && strtoupper( (string) $mock['method'] ) !== $method ) { + continue; + } + + beehiiv_e2e_log_http( $method, $url, $parsed_args['body'] ?? null, $key ); + + if ( ! empty( $mock['delay_ms'] ) ) { + usleep( (int) $mock['delay_ms'] * 1000 ); + } + + // Optional `sequence`: one response per call, repeating the last one -- + // e.g. beehiiv answering 202 while a post is still being created, then 200. + if ( ! empty( $mock['sequence'] ) && is_array( $mock['sequence'] ) ) { + $sequence = array_values( $mock['sequence'] ); + $step = is_array( $sequence[0] ) ? $sequence[0] : []; + + if ( count( $sequence ) > 1 ) { + array_shift( $sequence ); + $mocks[ $key ]['sequence'] = $sequence; + update_option( 'beehiiv_e2e_http_mocks', $mocks, false ); + } + + $mock = array_merge( $mock, $step ); + } + + $body = $mock['body'] ?? []; + + return [ + 'headers' => isset( $mock['headers'] ) && is_array( $mock['headers'] ) ? $mock['headers'] : [], + 'body' => is_string( $body ) ? $body : wp_json_encode( $body ), + 'response' => [ + 'code' => $mock['status'] ?? 200, + 'message' => $mock['message'] ?? 'OK', + ], + 'cookies' => [], + 'filename' => null, + ]; + } + + return $preempt; + }, + 10, + 3 +); + +/** + * Registers a mocked HTTP response for any outbound wp_remote_request() + * whose URL contains $url_substring. + * + * Call via `wp eval`, e.g. from a spec's wp-cli helper: + * wp eval 'beehiiv_e2e_mock_http( "/workspaces/permissions", [ "body" => [ "data" => [ "posts" => [ "read", "write" ] ] ] ] );' + * wp eval 'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "qa-tok", "refresh_token" => "qa-refresh", "expires_in" => 3600 ] ] );' + * wp eval 'beehiiv_e2e_mock_http( "/users/identify", [ "body" => [ "first_name" => "QA" ] ] );' + * + * @param string $url_substring Substring matched against the outbound request URL. + * @param array{status?: int, message?: string, body?: array|string} $response Response to return. + */ +function beehiiv_e2e_mock_http( string $url_substring, array $response ): void { + $mocks = get_option( 'beehiiv_e2e_http_mocks', [] ); + $mocks = is_array( $mocks ) ? $mocks : []; + $mocks[ $url_substring ] = $response; + update_option( 'beehiiv_e2e_http_mocks', $mocks, false ); +} + +/** + * Records one mocked outbound request (method, URL, decoded JSON body, and + * the key of the mock that answered it) so specs can assert what the plugin + * actually sent to beehiiv. + * + * @param string $method HTTP method. + * @param string $url Request URL. + * @param mixed $body Raw request body (JSON string for POST/PATCH). + * @param string $mock Key of the mock that answered. + */ +function beehiiv_e2e_log_http( string $method, string $url, $body, string $mock = '' ): void { + $log = get_option( 'beehiiv_e2e_http_log', [] ); + $log = is_array( $log ) ? $log : []; + $log[] = [ + 'method' => $method, + 'url' => $url, + 'body' => is_string( $body ) && '' !== $body ? json_decode( $body, true ) : $body, + 'mock' => $mock, + ]; + update_option( 'beehiiv_e2e_http_log', $log, false ); +} + +/** Prints the mocked-request log as JSON (for `wp eval` from a spec). */ +function beehiiv_e2e_print_http_log(): void { + $log = get_option( 'beehiiv_e2e_http_log', [] ); + echo wp_json_encode( is_array( $log ) ? $log : [] ) . "\n"; +} + +/** Empties the mocked-request log. */ +function beehiiv_e2e_clear_http_log(): void { + delete_option( 'beehiiv_e2e_http_log' ); +} + +/** Clears one HTTP mock by its substring, or every mock if omitted. */ +function beehiiv_e2e_clear_http_mocks( string $url_substring = '' ): void { + if ( '' === $url_substring ) { + delete_option( 'beehiiv_e2e_http_mocks' ); + return; + } + + $mocks = get_option( 'beehiiv_e2e_http_mocks', [] ); + + if ( is_array( $mocks ) ) { + unset( $mocks[ $url_substring ] ); + update_option( 'beehiiv_e2e_http_mocks', $mocks, false ); + } +} + +/** + * Sets the mocked /workspaces/permissions response for the current test. + * Thin convenience wrapper over beehiiv_e2e_mock_http() -- kept for the + * specs already written against this exact signature. + * + * wp eval 'beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );' + * wp eval 'beehiiv_e2e_mock_permissions( [ "posts" => [ "read" ] ] );' // connected, read-only + * + * @param array> $permissions Resource => granted actions. + */ +function beehiiv_e2e_mock_permissions( array $permissions ): void { + beehiiv_e2e_mock_http( '/workspaces/permissions', [ 'body' => [ 'data' => $permissions ] ] ); +} + +/** Clears the permissions mock, restoring real network behavior. */ +function beehiiv_e2e_clear_permissions_mock(): void { + beehiiv_e2e_clear_http_mocks( '/workspaces/permissions' ); +} + +/** + * Seeds a fake OAuth connection. TokenStore encrypts its option at rest, so + * a raw `wp option update beehiiv_oauth` won't work -- this goes through + * the real class the same way a genuine OAuth callback would. + * + * @param array{ + * client_id?: string, + * access_token?: string, + * refresh_token?: string, + * expires_in?: int, + * connected_user?: array, + * } $args Overrides for the seeded token; every key has a fake default. + */ +function beehiiv_e2e_seed_connection( array $args = [] ): void { + \Beehiiv\OAuth\TokenStore::save_tokens( + (string) ( $args['client_id'] ?? 'qa-e2e-client' ), + [ + 'access_token' => (string) ( $args['access_token'] ?? 'qa-e2e-access-token' ), + 'refresh_token' => (string) ( $args['refresh_token'] ?? 'qa-e2e-refresh-token' ), + 'expires_in' => (int) ( $args['expires_in'] ?? 3600 ), + ], + is_array( $args['connected_user'] ?? null ) ? $args['connected_user'] : [] + ); +} + +/** Removes the seeded OAuth connection. */ +function beehiiv_e2e_clear_connection(): void { + \Beehiiv\OAuth\TokenStore::delete_all(); +} + +/** + * Seeds the publications list cache, skipping the real GET /publications call. + * + * @param array $publications + */ +function beehiiv_e2e_seed_publications( array $publications ): void { + \Beehiiv\API\Cache::set_publications( $publications ); +} + +/** + * Seeds the post-templates cache for one publication, skipping the real + * GET /publications/{id}/post_templates call. + * + * @param string $publication_id + * @param array $templates + */ +function beehiiv_e2e_seed_post_templates( string $publication_id, array $templates ): void { + \Beehiiv\API\Cache::set_post_templates( $publication_id, $templates ); +} + +/** + * Resets every seam this file installs: all HTTP mocks, OAuth connection, + * and all beehiiv transient caches. Call in afterAll() so one spec's + * mocked "connected" state never leaks into the next sequential + * qa-e2e-author run against this shared environment. + */ +function beehiiv_e2e_reset_all(): void { + beehiiv_e2e_stop_http_log(); + beehiiv_e2e_clear_http_mocks(); + beehiiv_e2e_clear_http_log(); + delete_option( 'beehiiv_e2e_force_settings_status' ); + delete_option( 'beehiiv_e2e_test_send_wait_seconds' ); + beehiiv_e2e_clear_connection(); + \Beehiiv\API\Cache::flush_all(); +} + +/** + * Records every outbound wp_remote_request() (method + URL) while logging is + * enabled, so specs can assert *which* beehiiv endpoint (e.g. which + * publication) a server-side action targeted. Runs at priority 5, before the + * mock filter above, and never short-circuits the request itself. + */ +add_filter( + 'pre_http_request', + static function ( $preempt, $parsed_args, $url ) { + if ( ! get_option( 'beehiiv_e2e_http_log_enabled' ) ) { + return $preempt; + } + + $log = get_option( 'beehiiv_e2e_http_log', [] ); + $log = is_array( $log ) ? $log : []; + $log[] = [ + 'method' => strtoupper( (string) ( $parsed_args['method'] ?? 'GET' ) ), + 'url' => (string) $url, + ]; + update_option( 'beehiiv_e2e_http_log', $log, false ); + + return $preempt; + }, + 5, + 3 +); + +/** Starts (and empties) the outbound HTTP request log. */ +function beehiiv_e2e_start_http_log(): void { + update_option( 'beehiiv_e2e_http_log', [], false ); + update_option( 'beehiiv_e2e_http_log_enabled', 1, false ); +} + +/** + * Logged outbound requests since the last start. + * + * @return array + */ +function beehiiv_e2e_get_http_log(): array { + $log = get_option( 'beehiiv_e2e_http_log', [] ); + + return is_array( $log ) ? $log : []; +} + +/** Stops logging and clears the log. */ +function beehiiv_e2e_stop_http_log(): void { + delete_option( 'beehiiv_e2e_http_log_enabled' ); + delete_option( 'beehiiv_e2e_http_log' ); +} + +/** + * Third-party filter stand-in for BR-005: when the + * `beehiiv_e2e_force_settings_status` option is set, the public + * `beehiiv_newsletter_post_settings` filter rewrites the payload to that + * status and adds a `scheduled_at`, the way a hostile/careless third-party + * filter could -- so specs can check the plugin forces test drafts back to + * draft *after* this filter runs. + */ +add_filter( + 'beehiiv_newsletter_post_settings', + static function ( $settings ) { + $status = get_option( 'beehiiv_e2e_force_settings_status', '' ); + + if ( ! is_array( $settings ) || ! is_string( $status ) || '' === $status ) { + return $settings; + } + + $settings['status'] = $status; + $settings['scheduled_at'] = gmdate( 'c', time() + DAY_IN_SECONDS ); + + return $settings; + }, + 999 +); + +/** + * Shortens how long a test send waits for beehiiv to create its temporary + * draft, so specs can reach the "still preparing" outcome without waiting the + * full 20 seconds. Opt-in: only applies while the + * `beehiiv_e2e_test_send_wait_seconds` option is set. + */ +add_filter( + 'beehiiv_test_send_creation_wait_seconds', + static function ( $seconds ) { + $override = get_option( 'beehiiv_e2e_test_send_wait_seconds', '' ); + + return is_numeric( $override ) ? (float) $override : $seconds; + } +); diff --git a/tests/e2e/report/generate-dashboard.js b/tests/e2e/report/generate-dashboard.js new file mode 100644 index 0000000..4696cdd --- /dev/null +++ b/tests/e2e/report/generate-dashboard.js @@ -0,0 +1,804 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- CLI script, console is its output. */ +/** + * Reads the Playwright JSON reporter output (tests/e2e/test-results/results.json) + * and writes a self-contained HTML dashboard (tests/e2e/test-results/dashboard.html): + * overall pass/fail counts and a by-area breakdown up top, then failing cases with + * their error details and screenshots (click to enlarge), the full pass/fail case + * table, and a "reproducing this run" command reference at the bottom. + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +const RESULTS_PATH = path.join( __dirname, '../test-results/results.json' ); +const OUTPUT_PATH = path.join( __dirname, '../test-results/dashboard.html' ); +// JSON reporter attachment paths are written relative to this directory when +// not already absolute. +const PLUGIN_ROOT = path.join( __dirname, '..', '..', '..' ); + +const CASE_ID_RE = /^([A-Z]+-\d+)\s*:\s*(.*)$/; + +function readResults() { + if ( ! fs.existsSync( RESULTS_PATH ) ) { + console.error( + `No results file at ${ RESULTS_PATH }. Run "npm run test:e2e" first.` + ); + process.exit( 1 ); + } + return JSON.parse( fs.readFileSync( RESULTS_PATH, 'utf8' ) ); +} + +function specFileTitle( fileTitle ) { + return fileTitle.replace( /\.spec\.js$/, '' ); +} + +// test.status is Playwright's own outcome classification for the JSON +// reporter: 'skipped' | 'expected' | 'unexpected' | 'flaky' (see +// JSONReportTest in playwright's testReporter.d.ts) -- not a raw pass/fail +// value, so it's used as-is rather than compared against expectedStatus. +function statusOf( spec ) { + const statuses = spec.tests.map( ( t ) => t.status ); + if ( statuses.includes( 'unexpected' ) ) { + return 'failed'; + } + if ( statuses.includes( 'flaky' ) ) { + return 'flaky'; + } + if ( statuses.every( ( s ) => s === 'skipped' ) ) { + return 'skipped'; + } + return 'passed'; +} + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi( str ) { + return str.replace( ANSI_RE, '' ); +} + +// Playwright's expect() failures print a fairly consistent shape (matcher +// name, the locator involved, then "Expected ...:" / "Received ...:" lines). +// Pull those out so the report can show a short summary instead of the full +// call log and stack trace by default; anything that doesn't match this +// shape (thrown errors, strict-mode violations, timeouts) just falls back to +// showing the raw message's own first line. +function parseFailure( message ) { + const clean = stripAnsi( message ).trim(); + const matcherMatch = clean.match( /expect\([^)]*\)\.(\w+)\(/ ); + const locatorMatch = clean.match( /^Locator:\s*(.+)$/m ); + const expectedMatch = clean.match( /^Expected[^:]*:\s*(.+)$/m ); + const receivedMatch = clean.match( /^Received[^:]*:\s*(.+)$/m ); + const firstLine = clean.split( '\n' )[ 0 ].replace( /^Error:\s*/, '' ); + return { + raw: clean, + summary: firstLine, + matcher: matcherMatch ? matcherMatch[ 1 ] : null, + locator: locatorMatch ? locatorMatch[ 1 ] : null, + expected: expectedMatch ? expectedMatch[ 1 ] : null, + received: receivedMatch ? receivedMatch[ 1 ] : null, + }; +} + +function collectErrors( spec ) { + const errors = []; + for ( const test of spec.tests ) { + for ( const result of test.results ) { + for ( const err of result.errors || [] ) { + if ( err.message ) { + errors.push( parseFailure( err.message ) ); + } + } + } + } + return errors; +} + +function collectScreenshots( spec ) { + const shots = []; + for ( const test of spec.tests ) { + test.results.forEach( ( result, attemptIndex ) => { + for ( const attachment of result.attachments || [] ) { + if ( + ! attachment.contentType || + ! attachment.contentType.startsWith( 'image/' ) + ) { + continue; + } + const dataUri = readAttachmentAsDataUri( attachment ); + if ( dataUri ) { + shots.push( { + dataUri, + project: test.projectName, + attempt: attemptIndex + 1, + retried: test.results.length > 1, + } ); + } + } + } ); + } + return shots; +} + +function readAttachmentAsDataUri( attachment ) { + try { + let buffer; + if ( attachment.body ) { + buffer = Buffer.from( attachment.body, 'base64' ); + } else if ( attachment.path ) { + const filePath = path.isAbsolute( attachment.path ) + ? attachment.path + : path.join( PLUGIN_ROOT, attachment.path ); + buffer = fs.readFileSync( filePath ); + } else { + return null; + } + return `data:${ attachment.contentType };base64,${ buffer.toString( + 'base64' + ) }`; + } catch { + return null; + } +} + +function projectsOf( spec ) { + return [ ...new Set( spec.tests.map( ( t ) => t.projectName ) ) ]; +} + +function durationOf( spec ) { + let total = 0; + for ( const test of spec.tests ) { + for ( const result of test.results ) { + total += result.duration || 0; + } + } + return total; +} + +function walkSuite( suite, area, out ) { + for ( const spec of suite.specs || [] ) { + const match = spec.title.match( CASE_ID_RE ); + out.push( { + area, + caseId: match ? match[ 1 ] : null, + title: match ? match[ 2 ] : spec.title, + status: statusOf( spec ), + projects: projectsOf( spec ), + duration: durationOf( spec ), + errors: collectErrors( spec ), + screenshots: collectScreenshots( spec ), + } ); + } + for ( const child of suite.suites || [] ) { + walkSuite( child, area, out ); + } +} + +function collectCases( results ) { + const cases = []; + for ( const file of results.suites ) { + walkSuite( file, specFileTitle( file.title ), cases ); + } + return cases; +} + +function byArea( cases ) { + const areas = new Map(); + for ( const c of cases ) { + if ( ! areas.has( c.area ) ) { + areas.set( c.area, [] ); + } + areas.get( c.area ).push( c ); + } + return areas; +} + +function esc( str ) { + return String( str ).replace( + /[&<>"']/g, + ( ch ) => + ( { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } )[ ch ] + ); +} + +function fmtDuration( ms ) { + if ( ms < 1000 ) { + return `${ Math.round( ms ) }ms`; + } + return `${ ( ms / 1000 ).toFixed( 1 ) }s`; +} + +function fmtTotalDuration( ms ) { + const totalSeconds = Math.round( ms / 1000 ); + const minutes = Math.floor( totalSeconds / 60 ); + const seconds = totalSeconds % 60; + if ( minutes === 0 ) { + return `${ seconds }s`; + } + return `${ minutes }m ${ seconds }s`; +} + +function renderShots( c, sizeClass ) { + if ( ! c.screenshots.length ) { + return ''; + } + return `
${ c.screenshots + .map( + ( s ) => ` + ` + ) + .join( '' ) }
`; +} + +function renderFailureError( e ) { + const rows = []; + if ( e.expected !== null ) { + rows.push( + `
Expected${ esc( + e.expected + ) }
` + ); + } + if ( e.received !== null ) { + rows.push( + `
Received${ esc( + e.received + ) }
` + ); + } + const expectation = rows.length + ? `
${ rows.join( '' ) }${ + e.locator + ? `
on ${ esc( + e.locator + ) }
` + : '' + }
` + : `

${ esc( e.summary ) }

`; + return ` +
+ ${ expectation } +
+ Full error log +
${ esc( e.raw ) }
+
+
`; +} + +function renderFailureCard( c ) { + const errorBlock = c.errors.map( renderFailureError ).join( '' ); + return ` +
+
+ ${ esc( c.area ) } + ${ c.caseId ? `${ esc( c.caseId ) }` : '' } +
+

${ esc( c.title ) }

+
${ c.projects + .map( esc ) + .join( ', ' ) } · ${ fmtDuration( c.duration ) }
+ ${ errorBlock } + ${ renderShots( c, 'shot-thumb-lg' ) } +
`; +} + +function renderCaseRow( c ) { + const badgeClass = `badge-${ c.status }`; + const idCell = c.caseId ? esc( c.caseId ) : '—'; + return ` + + ${ idCell } + ${ + c.status + } + ${ esc( c.title ) } + ${ c.projects.map( esc ).join( ', ' ) } + ${ fmtDuration( c.duration ) } + `; +} + +function renderAreaSummaryCard( area, cases ) { + const passed = cases.filter( ( c ) => c.status === 'passed' ).length; + const failed = cases.filter( ( c ) => c.status === 'failed' ).length; + const flaky = cases.filter( ( c ) => c.status === 'flaky' ).length; + const skipped = cases.filter( ( c ) => c.status === 'skipped' ).length; + const total = cases.length; + const pct = total ? Math.round( ( passed / total ) * 100 ) : 0; + const statusLine = [ + `${ passed }/${ total } passing`, + failed ? `${ failed } failing` : null, + flaky ? `${ flaky } flaky` : null, + skipped ? `${ skipped } skipped` : null, + ] + .filter( Boolean ) + .join( ' · ' ); + + return ` +
+

${ esc( area ) }

+
${ pct }%
+
${ statusLine }
+
`; +} + +function renderAreaTable( area, cases ) { + const rows = cases.map( renderCaseRow ).join( '' ); + return ` +
+

${ esc( area ) }

+ + + + + + + + + + + ${ rows } +
CaseResultTitleRan onDuration
+
`; +} + +function renderReproSection( exampleCase ) { + const oneFile = exampleCase + ? `npx playwright test tests/e2e/specs/${ exampleCase.area }.spec.js` + : 'npx playwright test tests/e2e/specs/smoke.spec.js'; + const oneCase = exampleCase + ? `npx playwright test --grep "${ + exampleCase.caseId || exampleCase.title + }"` + : 'npx playwright test --grep "AC-001"'; + const lines = [ + [ 'npm install', 'installs deps and the matching browsers' ], + [ 'npm run env:start', 'boots the wp-env tests environment' ], + [ 'npm run test:e2e', 'all cases, chromium' ], + [ oneFile, 'one spec file' ], + [ oneCase, 'one case' ], + [ 'npm run test:e2e:report', 'regenerate this dashboard' ], + ]; + const rows = lines + .map( ( [ cmd, note ] ) => `${ esc( cmd ) } # ${ esc( note ) }` ) + .join( '\n' ); + return ` +
+

Reproducing this run

+

Runs against wp-env's dedicated tests environment, never dev data.

+
${ rows }
+
`; +} + +function render( results ) { + const cases = collectCases( results ); + const total = cases.length; + const passed = cases.filter( ( c ) => c.status === 'passed' ).length; + const failed = cases.filter( ( c ) => c.status === 'failed' ).length; + const flaky = cases.filter( ( c ) => c.status === 'flaky' ).length; + const skipped = cases.filter( ( c ) => c.status === 'skipped' ).length; + const passPct = total ? ( passed / total ) * 100 : 0; + const flakyPct = total ? ( flaky / total ) * 100 : 0; + const failedPct = total ? ( failed / total ) * 100 : 0; + const skippedPct = total ? ( skipped / total ) * 100 : 0; + + const areas = byArea( cases ); + + const areaSummaryCards = [ ...areas.entries() ] + .map( ( [ area, areaCases ] ) => + renderAreaSummaryCard( area, areaCases ) + ) + .join( '' ); + + const failedCases = cases.filter( ( c ) => c.status === 'failed' ); + const failuresSection = failedCases.length + ? failedCases.map( renderFailureCard ).join( '' ) + : '

No failing cases in this run.

'; + + const areaTables = [ ...areas.entries() ] + .map( ( [ area, areaCases ] ) => renderAreaTable( area, areaCases ) ) + .join( '' ); + + const reproSection = renderReproSection( failedCases[ 0 ] || cases[ 0 ] ); + + const startTime = new Date( results.stats.startTime ); + const runAt = startTime.toLocaleString( 'en-US', { + dateStyle: 'medium', + timeStyle: 'short', + } ); + + const gradientStops = []; + let cursor = 0; + const seg = ( amount, color ) => { + if ( amount <= 0 ) { + return; + } + gradientStops.push( `${ color } ${ cursor }% ${ cursor + amount }%` ); + cursor += amount; + }; + seg( passPct, 'var(--c-pass)' ); + seg( flakyPct, 'var(--c-flaky)' ); + seg( failedPct, 'var(--c-fail)' ); + seg( skippedPct, 'var(--c-skip)' ); + const donutStyle = gradientStops.length + ? `conic-gradient(${ gradientStops.join( ', ' ) })` + : 'var(--border)'; + + return ` + + + +beehiiv E2E Results + + + + +
+
+

beehiiv E2E Results

+
Run started ${ esc( runAt ) } · ${ fmtTotalDuration( + results.stats.duration + ) } · ${ total } cases
+
+ + + +
+ +
+
${ passed }
Passed
+
${ failed }
Failed
+
${ flaky }
Flaky
+ +
+
+ +
${ areaSummaryCards }
+ +
+

Failing cases

+

Each with its error and the screenshot captured at the point of failure.

+ ${ failuresSection } +
+ +
+

Full results

+

Every case in this run, passed or failed.

+ ${ areaTables } +
+ + ${ reproSection } +
+ + + + +`; +} + +function main() { + const results = readResults(); + const html = render( results ); + fs.writeFileSync( OUTPUT_PATH, html ); + console.log( `Dashboard written to ${ OUTPUT_PATH }` ); +} + +main(); diff --git a/tests/e2e/specs/admin-interface-connection-status-card.spec.js b/tests/e2e/specs/admin-interface-connection-status-card.spec.js new file mode 100644 index 0000000..dcfa8ae --- /dev/null +++ b/tests/e2e/specs/admin-interface-connection-status-card.spec.js @@ -0,0 +1,351 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/08-admin-interface/1-settings-page/connection-status-card.prd.md + * + * The card (includes/Admin/Views/connection.php, included from + * includes/Admin/Views/settings-page.php) reads live OAuth connection state + * via Beehiiv\Connection\Manager / Beehiiv\OAuth\TokenStore (`beehiiv_oauth` + * option, encrypted at rest). "Connected" states here are seeded directly + * through TokenStore::save_tokens() via `wp eval` -- the encryption is real, + * so a hand-written option value would not decrypt; this reuses the actual + * storage code path instead. + * + * When connected, the page also calls the real beehiiv + * `/workspaces/permissions` API (Beehiiv\API\Resources\Workspace:: + * can_write_posts()) to decide whether the account can post. Most of this + * spec deliberately exercises that real network call (a seeded fake token + * reliably gets a 401, ~1.4s round trip) to prove the genuinely-unauthorized + * plan-gating state (AC-010/AC-011). For the one state that call can never + * produce here -- a successful "authorized to post" response -- the + * tests-only mu-plugin at tests/e2e/plugins/beehiiv-options.php (loaded only + * in wp-env's *tests* environment, see .wp-env.json's env.tests.mappings) + * exposes beehiiv_e2e_mock_permissions()/beehiiv_e2e_clear_permissions_mock(), + * which short-circuit just that one `/workspaces/permissions` call via + * `pre_http_request`. It's opt-in (a no-op until the option it reads is + * set), so it's used ONLY inside the "Connected + authorized to post" describe + * block below (AC-012/AC-013) -- every other test in this file still hits + * the real API unmocked, exactly as before. + * + * AC-006 (disconnect) exercises Beehiiv\OAuth\Revoker::disconnect(), which + * also deletes the *separate* `beehiiv_settings` option that sibling PRDs + * in this domain test against. Its value is captured in beforeAll and + * restored in afterAll to avoid corrupting shared wp-env tests-environment + * state for other agents' runs. + */ + +const OAUTH_OPTION = 'beehiiv_oauth'; +const SETTINGS_OPTION = 'beehiiv_settings'; +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; +const SETTINGS_URL_RE = /\/wp-admin\/admin\.php\?page=beehiiv/; + +/** + * Seeds a "connected" state through the plugin's real encrypted token + * storage (not a hand-written option value, which would fail to decrypt). + */ +function seedConnected() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-client-id", [ "access_token" => "qa-e2e-access-token", "refresh_token" => "qa-e2e-refresh-token", "expires_in" => 3600 ], [ "first_name" => "QA", "last_name" => "Tester", "email" => "qa-e2e@example.test" ] );'` + ); +} + +/** + * Seeds the same connected state, with the permissions call answered locally. + * + * For tests that check card content, not plan gating. The settings page then + * never waits on the real beehiiv API, so a slow network cannot time them out. + */ +function seedConnectedOffline() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-client-id", [ "access_token" => "qa-e2e-access-token", "refresh_token" => "qa-e2e-refresh-token", "expires_in" => 3600 ], [ "first_name" => "QA", "last_name" => "Tester", "email" => "qa-e2e@example.test" ] ); beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );'` + ); +} + +/** + * Clears any stored OAuth credentials and the permissions mock, back to the + * disconnected, real-network baseline. + */ +function clearConnection() { + wpCliSafe( + `eval '\\Beehiiv\\OAuth\\TokenStore::delete_all(); beehiiv_e2e_clear_permissions_mock();'` + ); +} + +test.beforeAll( () => { + ensurePluginActive(); + + // Known clean baseline for every test in this file. This is wp-env's + // dedicated *tests* environment (never dev/production -- see + // playwright.config.js), so there is no real state worth preserving + // here; the only contract other agents' specs need from this file is + // "leave beehiiv_oauth/beehiiv_settings absent", not "restore whatever + // was here before" (which just perpetuates any earlier run's leftovers). + clearConnection(); + wpCliSafe( `option delete ${ SETTINGS_OPTION }` ); +} ); + +test.afterAll( () => { + // Clears the permissions mock (used only by the AC-012/AC-013 describe + // block below) plus the OAuth connection and API caches, so nothing + // this file seeded leaks into whichever agent's spec runs next against + // this shared tests environment. Every step here is a safe delete (never + // throws, even if already absent), so one failing step can never skip + // another -- unlike restoring a captured "original" value, which can. + wpCliSafe( `eval 'beehiiv_e2e_reset_all();'` ); + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + wpCliSafe( `option delete ${ SETTINGS_OPTION }` ); +} ); + +test.describe( 'Connection status card', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'AC-001: status icon visually distinguishes connected from disconnected states', async ( { + page, + } ) => { + clearConnection(); + await page.goto( SETTINGS_PATH ); + const disconnectedIcon = page.locator( + '.beehiiv-connection-status__icon' + ); + await expect( disconnectedIcon ).toHaveClass( + /beehiiv-connection-status__icon--disconnected/ + ); + await expect( disconnectedIcon ).toHaveClass( /dashicons-marker/ ); + + seedConnectedOffline(); + await page.goto( SETTINGS_PATH ); + const connectedIcon = page.locator( + '.beehiiv-connection-status__icon' + ); + await expect( connectedIcon ).toHaveClass( + /beehiiv-connection-status__icon--connected/ + ); + await expect( connectedIcon ).toHaveClass( /dashicons-yes-alt/ ); + + clearConnection(); + } ); + + test( 'AC-002: status label displays as "Connected" or "Disconnected"', async ( { + page, + } ) => { + // Beehiiv\Connection\Manager::get_status_label() actually returns + // "Not connected", not "Disconnected" -- this assertion follows the + // PRD's literal AC text, so it is a known failure (real PRD/copy + // mismatch, not flakiness); see the coverage report. Remove + // test.fail() once the label or the PRD is reconciled. + test.fail(); + + clearConnection(); + await page.goto( SETTINGS_PATH ); + await expect( + page.locator( '.beehiiv-connection-status strong' ) + ).toHaveText( 'Disconnected' ); + + seedConnected(); + await page.goto( SETTINGS_PATH ); + await expect( + page.locator( '.beehiiv-connection-status strong' ) + ).toHaveText( 'Connected' ); + + clearConnection(); + } ); + + test( 'AC-003: "Connect to beehiiv" button is visible when disconnected', async ( { + page, + } ) => { + clearConnection(); + await page.goto( SETTINGS_PATH ); + await expect( + page.getByRole( 'link', { name: 'Connect to beehiiv' } ) + ).toBeVisible(); + } ); + + test( 'AC-004: button initiates the OAuth authorization flow', async ( { + page, + } ) => { + clearConnection(); + await page.goto( SETTINGS_PATH ); + + await page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(); + await page.waitForLoadState( 'load' ); + + // This environment's build has no registered OAuth client + // credentials, so Authorization::get_authorize_url() returns a + // WP_Error and AdminActions::handle_connect() redirects back with an + // admin notice instead of reaching the real beehiiv authorize + // endpoint. Either outcome (external redirect, or a bounce-back + // notice) proves the button drives the server-side OAuth-initiation + // handler rather than being a dead link -- full external OAuth + // completion is explicitly out of scope for this PRD. + if ( SETTINGS_URL_RE.test( page.url() ) ) { + // `.notice` alone also matches the unrelated `.beehiiv-plans-notice` + // info box rendered above the card, so scope to the error notice. + await expect( page.locator( '.notice-error' ) ).toContainText( + /not configured/i + ); + } else { + expect( page.url() ).not.toContain( 'wp-admin' ); + } + } ); + + test( 'AC-005: "Disconnect" button is visible when connected', async ( { + page, + } ) => { + seedConnectedOffline(); + await page.goto( SETTINGS_PATH ); + await expect( + page.getByRole( 'link', { name: 'Disconnect' } ) + ).toBeVisible(); + clearConnection(); + } ); + + test( 'AC-006: disconnect action removes the authorization token', async ( { + page, + } ) => { + seedConnected(); + await page.goto( SETTINGS_PATH ); + await expect( + page.getByRole( 'link', { name: 'Disconnect' } ) + ).toBeVisible(); + + await page.getByRole( 'link', { name: 'Disconnect' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + + await expect( + page.getByRole( 'link', { name: 'Connect to beehiiv' } ) + ).toBeVisible(); + + const stillConnected = wpCliSafe( + `eval 'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";'` + ); + expect( stillConnected ).toBe( 'no' ); + } ); + + test( "AC-007: connected user's account identifier displays when available", async ( { + page, + } ) => { + seedConnectedOffline(); + await page.goto( SETTINGS_PATH ); + await expect( + page.locator( '.beehiiv-connection-status__account' ) + ).toHaveText( 'QA Tester (qa-e2e@example.test)' ); + clearConnection(); + } ); + + test( 'AC-008: signup link is shown to unconnected users without an existing beehiiv account', async ( { + page, + } ) => { + clearConnection(); + await page.goto( SETTINGS_PATH ); + const signupLink = page.locator( '.beehiiv-connection-signup a' ); + await expect( signupLink ).toBeVisible(); + await expect( signupLink ).toHaveText( 'Create a beehiiv account now' ); + await expect( signupLink ).toHaveAttribute( + 'href', + /^https:\/\/www\.beehiiv\.com\// + ); + } ); + + test( 'AC-009: documentation link always appears in the card', async ( { + page, + } ) => { + clearConnection(); + await page.goto( SETTINGS_PATH ); + let docsLink = page.locator( '.beehiiv-connection-docs a' ); + await expect( docsLink ).toBeVisible(); + await expect( docsLink ).toHaveAttribute( + 'href', + 'https://wordpress.org/plugins/beehiiv/' + ); + + seedConnectedOffline(); + await page.goto( SETTINGS_PATH ); + docsLink = page.locator( '.beehiiv-connection-docs a' ); + await expect( docsLink ).toBeVisible(); + await expect( docsLink ).toHaveAttribute( + 'href', + 'https://wordpress.org/plugins/beehiiv/' + ); + + clearConnection(); + } ); + + test( 'AC-010: plan-gating notice appears when the connected account lacks post-writing permissions', async ( { + page, + } ) => { + seedConnected(); + await page.goto( SETTINGS_PATH ); + const notice = page.locator( '.beehiiv-plans-notice' ); + await expect( notice ).toBeVisible(); + await expect( notice ).toContainText( + "Your connected beehiiv account doesn't have access to send newsletters." + ); + clearConnection(); + } ); + + test( 'AC-011: messaging indicates the required plan tier (Max or Enterprise)', async ( { + page, + } ) => { + seedConnected(); + await page.goto( SETTINGS_PATH ); + const notice = page.locator( '.beehiiv-plans-notice' ); + await expect( notice ).toContainText( 'Max' ); + await expect( notice ).toContainText( 'Enterprise' ); + clearConnection(); + } ); +} ); + +/** + * AC-012/AC-013 need a connection where Workspace::can_write_posts() is + * true -- unreachable with a real beehiiv account in this environment. The + * tests-only mu-plugin's beehiiv_e2e_mock_permissions() short-circuits just + * the one GET /workspaces/permissions call to make that state reachable. + * Kept in its own describe block with its own beforeEach/afterEach (rather + * than folded into "Connection status card" above) so the mock's lifetime + * never overlaps the other 11 tests, which depend on genuine + * connected/disconnected and real-401 behavior. + */ +test.describe( 'Connected + authorized to post (mocked permissions)', () => { + test.beforeEach( async ( { page } ) => { + wpCli( + `eval 'beehiiv_e2e_seed_connection( [ "connected_user" => [ "first_name" => "QA", "last_name" => "Tester", "email" => "qa-e2e@example.test" ] ] ); beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );'` + ); + await loginAsAdmin( page ); + } ); + + test.afterEach( () => { + wpCliSafe( `eval 'beehiiv_e2e_reset_all();'` ); + } ); + + test( 'AC-012: a next-steps list appears when connected and authorized for posting', async ( { + page, + } ) => { + await page.goto( SETTINGS_PATH ); + + // The unauthorized plan-gating notice must NOT appear once the + // mocked permissions grant posts:write. + await expect( page.locator( '.beehiiv-plans-notice' ) ).toHaveCount( + 0 + ); + + const nextSteps = page.locator( '.beehiiv-connection-next-steps' ); + await expect( nextSteps ).toBeVisible(); + await expect( nextSteps.locator( 'ol > li' ) ).toHaveCount( 3 ); + } ); + + test( 'AC-013: next-steps guide includes creating/editing a post and enabling newsletter sending', async ( { + page, + } ) => { + await page.goto( SETTINGS_PATH ); + + const nextSteps = page.locator( '.beehiiv-connection-next-steps' ); + await expect( nextSteps ).toContainText( 'Create' ); + await expect( nextSteps ).toContainText( 'edit' ); + await expect( nextSteps ).toContainText( 'a post in the block editor' ); + await expect( nextSteps ).toContainText( 'Send to newsletter' ); + } ); +} ); diff --git a/tests/e2e/specs/admin-styling.spec.js b/tests/e2e/specs/admin-styling.spec.js new file mode 100644 index 0000000..35df92f --- /dev/null +++ b/tests/e2e/specs/admin-styling.spec.js @@ -0,0 +1,177 @@ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/08-admin-interface/4-admin-assets/admin-styling.prd.md + * + * Brownfield-mapped PRD (no PLAN/EXECUTION trail) -- real code read directly: + * - includes/Admin/Assets.php (enqueue logic: HANDLE_ADMIN='beehiiv-admin' + * entry 'admin' is enqueued on EVERY admin_enqueue_scripts call with no + * hook_suffix check -- i.e. truly global across all of wp-admin, not just + * the plugin's own page; HANDLE_ADMIN_SETTINGS='beehiiv-admin-settings' + * entry 'admin-settings' is only enqueued when hook_suffix equals + * 'toplevel_page_beehiiv'. Both go through the same private + * enqueue_build_style() which requires both the *.asset.php AND *.css + * build files to exist before calling wp_enqueue_style(), else it + * silently returns.) + * - build/admin.asset.php, build/admin-settings.asset.php (version hashes + * used as the wp_enqueue_style() $ver argument for cache-busting) + * - includes/Admin/Views/settings-page.php (renders the same

regardless + * of beehiiv connection state, so the settings page is safe to use as a + * fixture for AC-003 without needing a live OAuth connection) + * + * AC-003's edge case ("Metadata exists but CSS file is missing -> enqueuing + * skipped silently, page loads normally") is exercised by temporarily + * renaming build/admin-settings.css out of the way. That handle is scoped to + * a single hook_suffix ('toplevel_page_beehiiv') via the code above, so the + * blast radius of the rename is limited to the beehiiv settings page only -- + * every other wp-admin page (including ones sibling QA agents' specs may + * exercise) is unaffected. The rename/restore is wrapped in try/finally so + * the file is put back even if an assertion throws. + */ + +const PLUGIN_ROOT = path.resolve( __dirname, '../../..' ); +const ADMIN_SETTINGS_CSS = path.join( PLUGIN_ROOT, 'build/admin-settings.css' ); +const ADMIN_SETTINGS_CSS_BAK = path.join( + PLUGIN_ROOT, + 'build/admin-settings.css.qa-bak' +); +const ADMIN_ASSET_PHP = path.join( PLUGIN_ROOT, 'build/admin.asset.php' ); + +/** + * Reads the `version` value out of a webpack-generated `*.asset.php` file + * (a literal ` array(...), 'version' => + * '');`) without needing a PHP runtime -- the format is static/generated, + * never hand-authored. + * + * @param {string} assetPhpPath + */ +function readAssetVersion( assetPhpPath ) { + const contents = fs.readFileSync( assetPhpPath, 'utf8' ); + const match = contents.match( /'version'\s*=>\s*'([^']+)'/ ); + if ( ! match ) { + throw new Error( + `Could not find a version string in ${ assetPhpPath }` + ); + } + return match[ 1 ]; +} + +test.beforeAll( () => { + ensurePluginActive(); + + // Defensive: if a previous run crashed mid-test and left the backup file + // in place without restoring the original, restore it now before this + // run starts, so we always begin from a clean/complete build directory. + if ( + fs.existsSync( ADMIN_SETTINGS_CSS_BAK ) && + ! fs.existsSync( ADMIN_SETTINGS_CSS ) + ) { + fs.renameSync( ADMIN_SETTINGS_CSS_BAK, ADMIN_SETTINGS_CSS ); + } +} ); + +test.afterAll( () => { + // Final safety net matching the above. + if ( + fs.existsSync( ADMIN_SETTINGS_CSS_BAK ) && + ! fs.existsSync( ADMIN_SETTINGS_CSS ) + ) { + fs.renameSync( ADMIN_SETTINGS_CSS_BAK, ADMIN_SETTINGS_CSS ); + } +} ); + +test.describe( 'Admin styling', () => { + test( "AC-001: global admin styles load on every wp-admin page, not just the plugin's own page", async ( { + page, + } ) => { + await loginAsAdmin( page ); + + // A core WP admin screen with no relation to beehiiv at all. + await page.goto( '/wp-admin/edit.php' ); + await expect( + page.locator( 'link#beehiiv-admin-css' ) + ).toHaveAttribute( 'href', /build\/admin\.css/ ); + + // The WP dashboard. + await page.goto( '/wp-admin/' ); + await expect( + page.locator( 'link#beehiiv-admin-css' ) + ).toHaveAttribute( 'href', /build\/admin\.css/ ); + + // The plugin's own settings page. + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + await expect( + page.locator( 'link#beehiiv-admin-css' ) + ).toHaveAttribute( 'href', /build\/admin\.css/ ); + } ); + + test( 'AC-002: the global stylesheet URL carries the build version as a cache-busting query arg', async ( { + page, + } ) => { + const expectedVersion = readAssetVersion( ADMIN_ASSET_PHP ); + + await loginAsAdmin( page ); + await page.goto( '/wp-admin/' ); + + const href = await page + .locator( 'link#beehiiv-admin-css' ) + .getAttribute( 'href' ); + expect( href ).toContain( `ver=${ expectedVersion }` ); + } ); + + test( 'AC-003: a missing CSS build file is skipped silently -- no console errors/warnings and the page still renders', async ( { + page, + } ) => { + expect( fs.existsSync( ADMIN_SETTINGS_CSS ) ).toBe( true ); + + const consoleIssues = []; + page.on( 'console', ( msg ) => { + if ( msg.type() === 'error' || msg.type() === 'warning' ) { + consoleIssues.push( `[${ msg.type() }] ${ msg.text() }` ); + } + } ); + const pageErrors = []; + page.on( 'pageerror', ( err ) => pageErrors.push( err.message ) ); + + fs.renameSync( ADMIN_SETTINGS_CSS, ADMIN_SETTINGS_CSS_BAK ); + + try { + const response = await loginAsAdminAndGoToSettings( page ); + + expect( response.status() ).toBe( 200 ); + // Page still renders normally. + await expect( page.locator( 'h1' ) ).toHaveText( + 'beehiiv Settings' + ); + // The style tag for the missing bundle was never emitted -- proves + // the PHP-side file_exists() guard actually skipped enqueuing + // rather than emitting a link to a 404ing asset. + await expect( + page.locator( 'link#beehiiv-admin-settings-css' ) + ).toHaveCount( 0 ); + + expect( + consoleIssues, + `Console issues: ${ consoleIssues.join( '; ' ) }` + ).toHaveLength( 0 ); + expect( + pageErrors, + `Page errors: ${ pageErrors.join( '; ' ) }` + ).toHaveLength( 0 ); + } finally { + fs.renameSync( ADMIN_SETTINGS_CSS_BAK, ADMIN_SETTINGS_CSS ); + } + } ); +} ); + +/** + * @param {import('@playwright/test').Page} page + */ +async function loginAsAdminAndGoToSettings( page ) { + await loginAsAdmin( page ); + return page.goto( '/wp-admin/admin.php?page=beehiiv' ); +} diff --git a/tests/e2e/specs/automatic-token-refresh.spec.js b/tests/e2e/specs/automatic-token-refresh.spec.js new file mode 100644 index 0000000..18ad590 --- /dev/null +++ b/tests/e2e/specs/automatic-token-refresh.spec.js @@ -0,0 +1,269 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/03-authentication/2-token-management/automatic-token-refresh.prd.md + * (brownfield-mapped -- no PLAN/EXECUTION artifacts exist for this feature; + * the real files below were located by direct code search, not a plan/ + * execution trail. Verified by reading: includes/OAuth/TokenRefresher.php + * (unit under test), includes/OAuth/TokenStore.php (storage it reads/writes), + * includes/OAuth/Config.php (REFRESH_BUFFER_SECONDS), includes/OAuth/HttpClient.php + * (the /oauth/token transport, mocked here). + * + * TokenRefresher is a pure backend service with no UI -- exercised directly + * via wp-cli against the real running site, the same way its real callers + * (Beehiiv\API\Client) do. The one live /oauth/token network call is mocked + * via the test-only mu-plugin seam's beehiiv_e2e_mock_http() + * (tests/e2e/plugins/beehiiv-options.php). + * + * IMPORTANT: Config::REFRESH_BUFFER_SECONDS is actually 300 in this codebase, + * not the 60 seconds BR-001 states as the default -- confirmed by reading + * includes/OAuth/Config.php directly. Test values below (100s / 3600s + * remaining) are chosen specifically to only make sense under the real 300s + * buffer, which doubles as a check that the real buffer value is what the + * code claims. + */ + +const OAUTH_OPTION = 'beehiiv_oauth'; +const CLIENT_ID = 'qa-e2e-refresh-client'; + +/** + * Seeds a connection with a controllable remaining lifetime. + * @param {string} accessToken + * @param {string} refreshToken + * @param {number} expiresIn + * @param {string} clientId + */ +function seedToken( + accessToken, + refreshToken, + expiresIn, + clientId = CLIENT_ID +) { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_tokens( "${ clientId }", [ "access_token" => "${ accessToken }", "refresh_token" => "${ refreshToken }", "expires_in" => ${ expiresIn } ] );'` + ); +} + +function mockTokenEndpointSuccess( newAccessToken, newRefreshToken ) { + wpCli( + `eval 'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "${ newAccessToken }", "refresh_token" => "${ newRefreshToken }", "expires_in" => 3600 ] ] );'` + ); +} + +function mockTokenEndpointFailure() { + wpCli( + 'eval \'beehiiv_e2e_mock_http( "/oauth/token", [ "status" => 400, "body" => [ "error" => "invalid_grant" ] ] );\'' + ); +} + +test.beforeAll( () => { + ensurePluginActive(); + + // Known clean baseline for every test in this file. This is wp-env's + // dedicated *tests* environment (never dev/production), so there is no + // real state worth preserving here. + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.afterAll( () => { + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.describe( 'Automatic Token Refresh', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test.afterEach( () => { + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); + } ); + + test( 'AC-001: a method exists to retrieve a valid access token, automatically refreshing it if approaching expiration', async () => { + seedToken( 'qa-e2e-ac1-old-access', 'qa-e2e-ac1-refresh', 100 ); // 100s remaining < 300s buffer. + mockTokenEndpointSuccess( + 'qa-e2e-ac1-new-access', + 'qa-e2e-ac1-new-refresh' + ); + + const token = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + expect( token.trim() ).toBe( 'qa-e2e-ac1-new-access' ); + } ); + + test( 'AC-002: the refresh buffer time is configurable and prevents unnecessary refreshes of tokens still valid for a reasonable period', async () => { + seedToken( 'qa-e2e-ac2-original-access', 'qa-e2e-ac2-refresh', 3600 ); // Well beyond the 300s buffer. + mockTokenEndpointSuccess( + 'qa-e2e-ac2-should-not-appear', + 'qa-e2e-ac2-should-not-appear' + ); + + const token = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + + // If refresh incorrectly triggered despite being outside the buffer, + // this would return the mocked NEW token instead of the original. + expect( token.trim() ).toBe( 'qa-e2e-ac2-original-access' ); + + const stillStored = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( stillStored.trim() ).toBe( 'qa-e2e-ac2-original-access' ); + } ); + + test( 'AC-003: when refresh is triggered, the OAuth provider is contacted using the stored refresh token', async () => { + seedToken( 'qa-e2e-ac3-old-access', 'qa-e2e-ac3-refresh', 100 ); + mockTokenEndpointSuccess( + 'qa-e2e-ac3-new-access', + 'qa-e2e-ac3-new-refresh' + ); + + const result = wpCli( + "eval 'var_dump( \\Beehiiv\\OAuth\\TokenRefresher::refresh() );'" + ); + expect( result.trim() ).toBe( 'bool(true)' ); + + // The refresh only succeeds (mocked 200) if the request reached the + // matched endpoint at all -- refresh() returns a WP_Error before ever + // building the request if client_id/refresh_token were missing (see + // AC-008), so a real `true` here proves it read and sent the stored + // credentials to build that request. + const newAccess = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( newAccess.trim() ).toBe( 'qa-e2e-ac3-new-access' ); + } ); + + test( 'AC-004: recursive refresh calls during the same request cycle are prevented', async () => { + // TokenRefresher::$refreshing is a private static flag, reset per PHP + // process -- it only ever guards re-entrant calls within a single + // request/process, not genuinely concurrent requests (each gets its + // own process/statics). Set it directly via Reflection to simulate + // "a refresh is already in progress" and confirm a second call is + // rejected without attempting another HTTP request. + seedToken( 'qa-e2e-ac4-access', 'qa-e2e-ac4-refresh', 100 ); + + const result = wpCli( + "eval '" + + '$ref = new ReflectionClass( "\\Beehiiv\\OAuth\\TokenRefresher" );' + + '$prop = $ref->getProperty( "refreshing" );' + + '$prop->setAccessible( true );' + + '$prop->setValue( null, true );' + + '$result = \\Beehiiv\\OAuth\\TokenRefresher::refresh();' + + '$prop->setValue( null, false );' + + 'echo is_wp_error( $result ) && "beehiiv_refresh_loop" === $result->get_error_code() ? "yes" : "no";' + + "'" + ); + + expect( result.trim() ).toBe( 'yes' ); + + // The original token must be untouched -- the blocked call never + // reached the token endpoint or storage layer. + const unchanged = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( unchanged.trim() ).toBe( 'qa-e2e-ac4-access' ); + } ); + + test( 'AC-005: token refresh happens without requiring the caller to implement refresh logic', async () => { + seedToken( 'qa-e2e-ac5-old-access', 'qa-e2e-ac5-refresh', 100 ); + mockTokenEndpointSuccess( + 'qa-e2e-ac5-new-access', + 'qa-e2e-ac5-new-refresh' + ); + + // A single call to the read-only accessor -- no separate refresh() + // call, no error handling -- is sufficient for the caller to receive + // a fresh token transparently. + const token = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + expect( token.trim() ).toBe( 'qa-e2e-ac5-new-access' ); + + // A second call immediately after (now well within the fresh 3600s + // window) returns the same token again without erroring or needing + // any special handling -- confirms the caller never has to know a + // refresh happened at all. + const secondCall = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + expect( secondCall.trim() ).toBe( 'qa-e2e-ac5-new-access' ); + } ); + + test( 'AC-006: after a successful refresh, the new access token is stored and returned to the caller', async () => { + seedToken( 'qa-e2e-ac6-old-access', 'qa-e2e-ac6-refresh', 100 ); + mockTokenEndpointSuccess( + 'qa-e2e-ac6-new-access', + 'qa-e2e-ac6-new-refresh' + ); + + const returned = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + const stored = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + const storedRefresh = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_refresh_token();'" + ); + + expect( returned.trim() ).toBe( 'qa-e2e-ac6-new-access' ); + expect( stored.trim() ).toBe( 'qa-e2e-ac6-new-access' ); + expect( storedRefresh.trim() ).toBe( 'qa-e2e-ac6-new-refresh' ); + } ); + + test( 'AC-007: if a refresh call fails (network error, HTTP error, or invalid response), the existing access token is returned to allow the caller to attempt to use it', async () => { + seedToken( 'qa-e2e-ac7-original-access', 'qa-e2e-ac7-refresh', 100 ); + mockTokenEndpointFailure(); // 400 response. + + const token = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + expect( token.trim() ).toBe( 'qa-e2e-ac7-original-access' ); + + // Storage must be untouched by the failed attempt. + const stillStored = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( stillStored.trim() ).toBe( 'qa-e2e-ac7-original-access' ); + } ); + + test( 'AC-008: if stored credentials (refresh token or client ID) are missing or incomplete, the system indicates that no valid token is available instead of attempting a refresh', async () => { + // This assertion follows the AC's literal wording. Actual behavior + // (verified below, not assumed): TokenRefresher::refresh() does + // correctly detect the missing refresh_token/client_id and returns a + // WP_Error('beehiiv_refresh_missing') without attempting any HTTP + // call -- but get_valid_access_token()'s caller-facing fallback for + // ANY refresh WP_Error (missing-credentials included) is identical to + // AC-007's generic failure path: it returns the existing (soon truly + // expired) access token, not an empty-string/"no valid token" signal. + // So this AC's specific claim -- that missing refresh credentials are + // surfaced distinctly as "no valid token available" -- does not match + // the real code, which is indistinguishable from a generic transient + // failure to the caller. Kept as test.fail() so this stays documented + // and re-flags if the fallback behavior changes. + test.fail(); + + // save_tokens() itself rejects an empty client_id (returns false, + // saves nothing), so this precondition can't be seeded through the + // normal API -- seed a complete, valid token first, then clear just + // the client_id field directly on the raw stored array to reproduce + // "access token present, client_id missing" without going through + // that rejection. + seedToken( 'qa-e2e-ac8-access', 'qa-e2e-ac8-refresh', 100 ); + wpCli( + 'eval \'$d = get_option( "beehiiv_oauth", [] ); $d["client_id"] = ""; update_option( "beehiiv_oauth", $d, false );\'' + ); + + const token = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenRefresher::get_valid_access_token();'" + ); + expect( token.trim() ).toBe( '' ); + } ); +} ); diff --git a/tests/e2e/specs/menu-registration.spec.js b/tests/e2e/specs/menu-registration.spec.js new file mode 100644 index 0000000..f9318a2 --- /dev/null +++ b/tests/e2e/specs/menu-registration.spec.js @@ -0,0 +1,97 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin, loginAs } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/08-admin-interface/2-admin-menu/menu-registration.prd.md + * + * Non-admin fixture user for AC-004. Created idempotently (reused across + * repeated runs against this shared wp-env tests environment, matching the + * `ensurePluginActive`-style pattern in utils/wp-cli.js) with the Editor + * role, which does not carry `manage_options` -- the capability the beehiiv + * menu item is registered under (includes/Admin/Menu.php). + */ +const NON_ADMIN_USERNAME = 'qa_e2e_menu_editor'; +const NON_ADMIN_PASSWORD = 'qa-e2e-menu-editor-pw-1'; + +test.beforeAll( () => { + ensurePluginActive(); + + if ( wpCliSafe( `user get ${ NON_ADMIN_USERNAME } --field=ID` ) === null ) { + wpCli( + `user create ${ NON_ADMIN_USERNAME } ${ NON_ADMIN_USERNAME }@example.test ` + + `--role=editor --user_pass=${ NON_ADMIN_PASSWORD }` + ); + } +} ); + +test.describe( 'Admin menu registration', () => { + test( 'AC-001: the beehiiv menu item appears in the admin sidebar for an administrator', async ( { + page, + } ) => { + await loginAsAdmin( page ); + await page.goto( '/wp-admin/' ); + + const menuItem = page.locator( '#toplevel_page_beehiiv' ); + await expect( menuItem ).toBeVisible(); + await expect( menuItem.locator( '.wp-menu-name' ) ).toHaveText( + 'beehiiv' + ); + } ); + + test( 'AC-002: clicking the menu item navigates to the beehiiv Settings page', async ( { + page, + } ) => { + await loginAsAdmin( page ); + await page.goto( '/wp-admin/' ); + + await page.locator( '#toplevel_page_beehiiv > a.menu-top' ).click(); + + await expect( page ).toHaveURL( + /\/wp-admin\/admin\.php\?page=beehiiv/ + ); + await expect( page.locator( 'h1' ) ).toHaveText( 'beehiiv Settings' ); + } ); + + test( 'AC-003: the menu item displays an icon alongside the label', async ( { + page, + } ) => { + await loginAsAdmin( page ); + await page.goto( '/wp-admin/' ); + + const iconBox = page.locator( '#toplevel_page_beehiiv .wp-menu-image' ); + await expect( iconBox ).toBeVisible(); + + // Icon is a CSS mask on the ::before pseudo-element (admin.scss), not an + // , so assert on its computed box rather than an image src/alt. + const beforeStyle = await iconBox.evaluate( ( el ) => { + const style = window.getComputedStyle( el, '::before' ); + return { + content: style.content, + width: style.width, + height: style.height, + }; + } ); + + expect( beforeStyle.content ).not.toBe( 'none' ); + expect( parseFloat( beforeStyle.width ) ).toBeGreaterThan( 0 ); + expect( parseFloat( beforeStyle.height ) ).toBeGreaterThan( 0 ); + } ); + + test( 'AC-004: the menu item is hidden from, and the settings page blocked for, non-administrator users', async ( { + page, + } ) => { + await loginAs( page, NON_ADMIN_USERNAME, NON_ADMIN_PASSWORD ); + await page.goto( '/wp-admin/' ); + + await expect( page.locator( '#toplevel_page_beehiiv' ) ).toHaveCount( + 0 + ); + + const response = await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + expect( response.status() ).toBe( 403 ); + await expect( page.locator( 'body' ) ).toContainText( + /not allowed to access this page/i + ); + } ); +} ); diff --git a/tests/e2e/specs/newsletter-send.spec.js b/tests/e2e/specs/newsletter-send.spec.js new file mode 100644 index 0000000..e9c8772 --- /dev/null +++ b/tests/e2e/specs/newsletter-send.spec.js @@ -0,0 +1,1246 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/07-newsletter-publishing/1-newsletter-send/newsletter-send.prd.md (v1.3) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - includes/Newsletter/Sender.php -- lifecycle hooks (REST insert, + * status transitions, delete), send/update/reschedule/cancel, error meta. + * - includes/Newsletter/PostSettingsBuilder.php -- create/update payloads, + * subject line/subtitle/display choice, scheduled_at (UTC) and validation. + * - includes/Editor/Meta.php -- post meta keys. + * + * Every save goes through the same REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), which is what fires + * `rest_after_insert_post`. Every outbound beehiiv request is answered by + * the test-only `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php + * and recorded in its request log, so specs assert on the exact payload the + * plugin sent to beehiiv. Nothing reaches the live API. + */ + +const META = { + send: '_beehiiv_send_to_newsletter', + sendDate: '_beehiiv_send_to_newsletter_date', + postId: '_beehiiv_post_id', + scheduledAt: '_beehiiv_scheduled_at', + error: '_beehiiv_newsletter_error', + errorType: '_beehiiv_newsletter_error_type', + title: '_beehiiv_newsletter_title', + subtitle: '_beehiiv_newsletter_subtitle', + show: '_beehiiv_newsletter_show_title_in_email', +}; + +const PUBLICATION_ID = 'pub_qa_e2e_send'; +const TEMPLATE_ID = 'tpl_qa_e2e_send'; +const MOCK_CREATED_ID = 'post_qa_e2e_created'; +const LINKED_ID = 'post_qa_e2e_linked'; +const CONTENT = + '

QA E2E newsletter body.

'; + +const createdPosts = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded so no quoting issues. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Resets beehiiv seams and seeds a "ready to send" site (connected, + * publication + default template configured), then registers HTTP mocks in + * order (first match wins). The generic `/posts` mock (create/update/delete + * all succeed, create returns MOCK_CREATED_ID) is always registered last. + * + * @param {Object} [options] Seed options. + * @param {boolean} [options.connected] Whether to seed a beehiiv connection. + * @param {string} [options.publication] Publication ID saved in plugin settings. + * @param {Array} [options.mocks] Extra `[ needle, response ]` HTTP mocks, matched first. + * @param {Object|null} [options.postsMock] Response for the generic `/posts` mock. + * @param {string} [options.timezone] Site timezone string; empty keeps UTC. + */ +function seed( { + connected = true, + publication = PUBLICATION_ID, + mocks = [], + postsMock = null, + timezone = '', +} = {} ) { + const allMocks = [ + ...mocks, + [ '/posts', postsMock || { body: { data: { id: MOCK_CREATED_ID } } } ], + ]; + wpEvalWith( + { connected, publication, mocks: allMocks, timezone }, + `beehiiv_e2e_reset_all(); + if ( $a["connected"] ) { beehiiv_e2e_seed_connection(); } + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ "publication_id" => $a["publication"], "post_template_id" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); } + update_option( "timezone_string", $a["timezone"] ); + update_option( "gmt_offset", 0 ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a post fixture directly in the DB, then sets its meta/tags and + * empties the request log so only the action under test is recorded. + * + * @param {Object} options Post fields. + * @param {string} options.title Post title. + * @param {string} [options.type] Post type. + * @param {string} [options.status] Post status. + * @param {string|null} [options.dateGmt] GMT post date, or null for now. + * @param {string} [options.excerpt] Post excerpt. + * @param {Object} [options.meta] Post meta to set. + * @param {string[]} [options.tags] Tag names to assign. + * @param {Object} [options.extra] Extra `wp_insert_post` fields. + * @return {number} Post ID. + */ +function createPost( { + title, + type = 'post', + status = 'draft', + dateGmt = null, + excerpt = '', + meta = {}, + tags = [], + extra = {}, +} ) { + const post = { + post_type: type, + post_title: title, + post_content: CONTENT, + post_status: status, + post_excerpt: excerpt, + post_author: 1, + ...extra, + }; + if ( dateGmt ) { + post.post_date_gmt = dateGmt; + post.post_date = dateGmt; // Site timezone is UTC unless a test changes it afterwards. + } + const id = wpEvalWith( + { post, meta, tags }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + if ( ! empty( $a["tags"] ) ) { wp_set_post_tags( $id, $a["tags"] ); } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * Creates a published post already linked to a beehiiv newsletter. + * + * @param {string} title Post title. + * @param {Object} [options] Extra {@link createPost} options. + * @param {Object} [options.meta] Post meta merged over the linked defaults. + * @return {number} Post ID. + */ +function createLinkedPost( title, { meta = {}, ...rest } = {} ) { + return createPost( { + title, + status: 'publish', + meta: { [ META.postId ]: LINKED_ID, [ META.send ]: '', ...meta }, + ...rest, + } ); +} + +function getLog() { + return JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); +} + +function clearLog() { + wpCli( `eval 'beehiiv_e2e_clear_http_log();'` ); +} + +/** + * All meta for a post, single values. + * + * @param {number} id Post ID. + * @return {Object} Meta keyed by meta key. + */ +function getMeta( id ) { + const raw = wpCli( + `eval '$m = get_post_meta( ${ id } ); $o = []; foreach ( $m as $k => $v ) { $o[ $k ] = $v[0]; } echo wp_json_encode( (object) $o ) . "\\n";'` + ); + return JSON.parse( raw ); +} + +const creates = ( log ) => + log.filter( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); +const updates = ( log ) => log.filter( ( r ) => r.method === 'PATCH' ); +const deletes = ( log ) => log.filter( ( r ) => r.method === 'DELETE' ); + +function isoGmt( msFromNow ) { + return new Date( Date.now() + msFromNow ) + .toISOString() + .replace( /\.\d{3}Z$/, '' ); +} +const DAY = 24 * 60 * 60 * 1000; + +/** + * Collects every [key, value] pair in a nested payload. + * + * @param {*} obj Payload to walk. + * @param {Array} [out] Accumulator. + * @return {Array} `[ key, value ]` pairs. + */ +function entries( obj, out = [] ) { + if ( obj && typeof obj === 'object' ) { + for ( const [ k, v ] of Object.entries( obj ) ) { + out.push( [ k, v ] ); + entries( v, out ); + } + } + return out; +} + +/** + * Logs in as admin and returns a REST caller bound to that session (cookie + nonce). + * + * @param {import('@playwright/test').Page} page Playwright page. + * @return {Promise} `( method, path, data ) => { status, body }`. + */ +async function restAs( page ) { + await loginAsAdmin( page ); + const nonceRes = await page.request.get( + '/wp-admin/admin-ajax.php?action=rest-nonce' + ); + const nonce = ( await nonceRes.text() ).trim(); + return async ( method, path, data ) => { + const res = await page.request.fetch( `/wp-json${ path }`, { + method, + headers: { 'X-WP-Nonce': nonce }, + data, + } ); + let body = null; + try { + body = await res.json(); + } catch { + // Non-JSON response; leave body null. + } + return { status: res.status(), body }; + }; +} + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); +} ); + +test.afterAll( async () => { + // Unlink first so deleting never tries to cancel a beehiiv newsletter. + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "${ + META.postId + }" ); wp_delete_post( $id, true ); } + update_option( "timezone_string", "" ); update_option( "gmt_offset", 0 ); + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: send published posts', () => { + test( 'AC-001: publishing a post with "Send to Newsletter" on creates the beehiiv newsletter', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-001', + meta: { [ META.send ]: '1' }, + } ); + const offId = createPost( { + title: 'QA E2E send AC-001 (toggle off)', + } ); + const rest = await restAs( page ); + + expect( + ( + await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ) + ).status + ).toBe( 200 ); + const log = getLog(); + expect( creates( log ) ).toHaveLength( 1 ); + expect( creates( log )[ 0 ].url ).toContain( + `/publications/${ PUBLICATION_ID }/posts` + ); + expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + + // Control: the same publish with the toggle off sends nothing. + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ offId }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + expect( getMeta( offId )[ META.postId ] ).toBeUndefined(); + } ); + + test( 'AC-002: only the "post" post type sends newsletters', async ( { + page, + } ) => { + seed(); + const pageId = createPost( { + title: 'QA E2E send AC-002 page', + type: 'page', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + expect( + ( + await rest( 'POST', `/wp/v2/pages/${ pageId }`, { + status: 'publish', + } ) + ).status + ).toBe( 200 ); + expect( getLog() ).toHaveLength( 0 ); + expect( getMeta( pageId )[ META.postId ] ).toBeUndefined(); + } ); + + test( 'AC-003: sending requires an active beehiiv connection', async ( { + page, + } ) => { + seed( { connected: false } ); + const id = createPost( { + title: 'QA E2E send AC-003', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toContain( + 'Connect your beehiiv account' + ); + } ); + + test( 'AC-004: sending requires a configured beehiiv publication', async ( { + page, + } ) => { + seed( { publication: '' } ); + const id = createPost( { + title: 'QA E2E send AC-004', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toContain( 'Choose a publication' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: schedule newsletter delivery', () => { + test( 'AC-005: scheduling a post for a future date schedules the newsletter', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-005', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + const future = isoGmt( 2 * DAY ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'future', + date_gmt: future, + } ); + expect( res.body.status ).toBe( 'future' ); + const created = creates( getLog() ); + expect( created ).toHaveLength( 1 ); + expect( created[ 0 ].body.scheduled_at ).toBe( `${ future }Z` ); + expect( getMeta( id )[ META.scheduledAt ] ).toBe( `${ future }Z` ); + } ); + + test( 'AC-006: the scheduled send time is sent to beehiiv in UTC', async ( { + page, + } ) => { + // A non-UTC site timezone proves the local publish time is converted. + seed( { timezone: 'America/New_York' } ); + const id = createPost( { + title: 'QA E2E send AC-006', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + const future = isoGmt( 3 * DAY ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'future', + date_gmt: future, + } ); + expect( res.body.date ).not.toBe( future ); // Local time really differs from UTC. + const created = creates( getLog() ); + expect( created ).toHaveLength( 1 ); + expect( created[ 0 ].body.scheduled_at ).toBe( `${ future }Z` ); + expect( created[ 0 ].body.scheduled_at ).toMatch( + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/ + ); + } ); + + test( 'AC-007: moving the send time later deletes the old beehiiv post and recreates it', async ( { + page, + } ) => { + seed(); + const oneDay = isoGmt( DAY ); + const id = createPost( { + title: 'QA E2E send AC-007', + status: 'future', + dateGmt: oneDay.replace( 'T', ' ' ), + meta: { + [ META.postId ]: LINKED_ID, + [ META.scheduledAt ]: `${ oneDay }Z`, + [ META.send ]: '', + }, + } ); + const rest = await restAs( page ); + const later = isoGmt( 3 * DAY ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { date_gmt: later } ); + const log = getLog(); + const deleted = deletes( log ); + const created = creates( log ); + expect( deleted ).toHaveLength( 1 ); + expect( deleted[ 0 ].url ).toContain( `/posts/${ LINKED_ID }` ); + expect( created ).toHaveLength( 1 ); + expect( created[ 0 ].body.scheduled_at ).toBe( `${ later }Z` ); + expect( log.indexOf( deleted[ 0 ] ) ).toBeLessThan( + log.indexOf( created[ 0 ] ) + ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBe( MOCK_CREATED_ID ); + expect( meta[ META.scheduledAt ] ).toBe( `${ later }Z` ); + } ); + + test( 'AC-008: a send time that has already passed is rejected', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-008', + meta: { + [ META.send ]: '1', + [ META.sendDate ]: '2020-01-01 10:00:00', + }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toContain( 'already passed' ); + } ); + + test( 'AC-009: a send time before the post publishes is rejected', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-009', + meta: { + [ META.send ]: '1', + [ META.sendDate ]: isoGmt( DAY ).replace( 'T', ' ' ), + }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'future', + date_gmt: isoGmt( 3 * DAY ), + } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toContain( + "can't send before this post publishes" + ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: sync post edits before send', () => { + test( 'AC-010: editing a linked post syncs the change to beehiiv', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-010' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-010 edited', + content: + '

QA E2E edited body AC-010.

', + } ); + const patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + expect( patched[ 0 ].body.title ).toBe( 'QA E2E send AC-010 edited' ); + expect( JSON.stringify( patched[ 0 ].body.blocks ) ).toContain( + 'QA E2E edited body AC-010.' + ); + } ); + + test( 'AC-011: a sync keeps the existing beehiiv post reference', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-011' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-011 edited', + } ); + const log = getLog(); + expect( updates( log ) ).toHaveLength( 1 ); + expect( updates( log )[ 0 ].url ).toContain( `/posts/${ LINKED_ID }` ); + expect( creates( log ) ).toHaveLength( 0 ); + expect( deletes( log ) ).toHaveLength( 0 ); + expect( getMeta( id )[ META.postId ] ).toBe( LINKED_ID ); + } ); + + test( 'AC-012: saving a draft does not contact beehiiv', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-012', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-012 edited', + } ); + expect( res.body.status ).toBe( 'draft' ); + expect( getLog() ).toHaveLength( 0 ); + expect( getMeta( id )[ META.postId ] ).toBeUndefined(); + } ); + + test( 'AC-013: a REST save with an empty beehiiv post reference keeps the stored one', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-013' ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + meta: { [ META.postId ]: '' }, + } ); + expect( res.status ).toBe( 200 ); + expect( getMeta( id )[ META.postId ] ).toBe( LINKED_ID ); + + // And a save that omits the reference entirely also keeps it. + await rest( 'POST', `/wp/v2/posts/${ id }`, { + meta: { [ META.subtitle ]: 'AC-013 sub' }, + } ); + expect( getMeta( id )[ META.postId ] ).toBe( LINKED_ID ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: cancel scheduled newsletters', () => { + test( 'AC-014: unpublishing a post cancels its beehiiv newsletter', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-014' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'draft' } ); + const deleted = deletes( getLog() ); + expect( deleted ).toHaveLength( 1 ); + expect( deleted[ 0 ].url ).toContain( + `/publications/${ PUBLICATION_ID }/posts/${ LINKED_ID }` + ); + } ); + + test( 'AC-015: trashing or permanently deleting a post cancels its beehiiv newsletter', async ( { + page, + } ) => { + seed(); + const trashId = createLinkedPost( 'QA E2E send AC-015 trash' ); + const rest = await restAs( page ); + + await rest( 'DELETE', `/wp/v2/posts/${ trashId }` ); + let deleted = deletes( getLog() ); + expect( deleted ).toHaveLength( 1 ); + expect( deleted[ 0 ].url ).toContain( `/posts/${ LINKED_ID }` ); + + const forceId = createLinkedPost( 'QA E2E send AC-015 force delete' ); + await rest( 'DELETE', `/wp/v2/posts/${ forceId }?force=true` ); + deleted = deletes( getLog() ); + expect( deleted ).toHaveLength( 1 ); + expect( deleted[ 0 ].url ).toContain( `/posts/${ LINKED_ID }` ); + } ); + + test( 'AC-016: cancelling clears the beehiiv post link from WordPress', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-016', { + meta: { [ META.scheduledAt ]: '2099-01-01T00:00:00Z' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'draft' } ); + expect( deletes( getLog() ) ).toHaveLength( 1 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.scheduledAt ] ).toBeUndefined(); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-005: understand newsletter errors', () => { + test( 'AC-017: a failed send is stored on the post and shown in the block editor', async ( { + page, + } ) => { + seed( { + postsMock: { + status: 500, + message: 'Server Error', + body: { message: 'boom' }, + }, + } ); + const id = createPost( { + title: 'QA E2E send AC-017', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + const meta = getMeta( id ); + expect( meta[ META.errorType ] ).toBe( 'send' ); + expect( meta[ META.error ] ).toContain( 'temporarily unavailable' ); + + await openPostEditor( page, id ); + await openBeehiivSidebar( page ); + const sidebar = page.locator( '.beehiiv-post-settings' ); + await expect( + sidebar.getByText( 'Could not send this post to beehiiv:' ) + ).toBeVisible( { timeout: 15000 } ); + await expect( + sidebar.getByText( /temporarily unavailable/ ) + ).toBeVisible(); + } ); + + test( 'AC-018: error messages name the specific failure reason', async ( { + page, + } ) => { + const cases = [ + [ { connected: false }, /Connect your beehiiv account/ ], + [ { publication: '' }, /Choose a publication/ ], + [ + { postsMock: { status: 403, body: { message: 'Forbidden' } } }, + /connection expired/, + ], + [ + { + postsMock: { + status: 422, + body: { errors: [ { message: 'Subject too long' } ] }, + }, + }, + /beehiiv rejected this newsletter: Subject too long/, + ], + ]; + const rest = await restAs( page ); + const seen = new Set(); + for ( const [ seedArgs, expected ] of cases ) { + seed( seedArgs ); + const id = createPost( { + title: 'QA E2E send AC-018', + meta: { [ META.send ]: '1' }, + } ); + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + const message = getMeta( id )[ META.error ]; + expect( message ).toMatch( expected ); + seen.add( message ); + } + expect( seen.size ).toBe( cases.length ); + } ); + + test( 'AC-019: a post with a send error can be retried after the issue is fixed', async ( { + page, + } ) => { + seed( { postsMock: { status: 500, body: { message: 'boom' } } } ); + const id = createPost( { + title: 'QA E2E send AC-019', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( getMeta( id )[ META.error ] ).toBeTruthy(); + + seed(); // beehiiv recovers. + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-019 retry', + } ); + expect( creates( getLog() ) ).toHaveLength( 1 ); + expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + } ); + + test( 'AC-020: the error is cleared when a retry succeeds', async ( { + page, + } ) => { + seed( { postsMock: { status: 500, body: { message: 'boom' } } } ); + const id = createPost( { + title: 'QA E2E send AC-020', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( getMeta( id )[ META.error ] ).toBeTruthy(); + + seed(); + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-020 retry', + } ); + const meta = getMeta( id ); + expect( meta[ META.error ] ).toBeUndefined(); + expect( meta[ META.errorType ] ).toBeUndefined(); + } ); +} ); + +// --------------------------------------------------------------------------- +// AC-021, AC-022 and AC-024 to AC-026 are marked fixme: the sync and +// sent-newsletter work they cover is built on feature/19812403-sync-wp-post-edit +// (intake 39b635e9), not on this branch. Switch them back to `test` once that +// branch is merged in. +test.describe( 'US-006: sync every newsletter field on save', () => { + test.fixme( + 'AC-021: each sync sends search/social title and description (no SEO plugin: post title and excerpt)', + async ( { page } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-021', { + excerpt: 'QA E2E excerpt AC-021', + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-021 seo', + } ); + const patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + // Field-name agnostic: some SEO/social-ish field must carry the title and the excerpt. + const seoValues = entries( patched[ 0 ].body ) + .filter( + ( [ k, v ] ) => + typeof v === 'string' && + /seo|meta|og_|twitter|social_(title|description)|description/i.test( + k + ) + ) + .map( ( [ , v ] ) => v ); + expect( seoValues ).toContain( 'QA E2E send AC-021 seo' ); + expect( seoValues ).toContain( 'QA E2E excerpt AC-021' ); + } + ); + + test.fixme( + 'AC-022: each sync makes the beehiiv tags match the WordPress tags', + async ( { page } ) => { + seed(); + const tagged = createLinkedPost( 'QA E2E send AC-022 tagged', { + tags: [ 'qa-alpha', 'qa-beta' ], + } ); + const untagged = createLinkedPost( 'QA E2E send AC-022 untagged' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ tagged }`, { + title: 'QA E2E send AC-022 tagged edit', + } ); + let patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + let tagFields = entries( patched[ 0 ].body ).filter( + ( [ k, v ] ) => /tag/i.test( k ) && Array.isArray( v ) + ); + expect( tagFields.length ).toBeGreaterThan( 0 ); + expect( [ ...tagFields[ 0 ][ 1 ] ].sort() ).toEqual( [ + 'qa-alpha', + 'qa-beta', + ] ); + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ untagged }`, { + title: 'QA E2E send AC-022 untagged edit', + } ); + patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + tagFields = entries( patched[ 0 ].body ).filter( + ( [ k, v ] ) => /tag/i.test( k ) && Array.isArray( v ) + ); + expect( tagFields.length ).toBeGreaterThan( 0 ); + expect( tagFields[ 0 ][ 1 ] ).toEqual( [] ); + } + ); + + test( 'AC-023: WordPress data with no beehiiv equivalent is ignored and never blocks a sync', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-023', { + meta: { qa_e2e_unsupported_field: 'QA-E2E-UNSUPPORTED-VALUE' }, + extra: { comment_status: 'closed', post_password: '' }, + } ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-023 edited', + sticky: true, + format: 'aside', + } ); + expect( res.status ).toBe( 200 ); + const patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + expect( JSON.stringify( patched[ 0 ].body ) ).not.toContain( + 'QA-E2E-UNSUPPORTED-VALUE' + ); + expect( getMeta( id )[ META.error ] ).toBeUndefined(); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-007: protect newsletters that already sent', () => { + const SENT_ID = 'post_qa_e2e_sent'; + const pastUnix = Math.floor( ( Date.now() - 2 * DAY ) / 1000 ); + const sentMocks = [ + [ + 'GET sent post', + { + needle: `/posts/${ SENT_ID }`, + method: 'GET', + body: { + data: { + id: SENT_ID, + status: 'confirmed', + publish_date: pastUnix, + displayed_date: pastUnix, + }, + }, + }, + ], + ]; + const sentFixture = ( title ) => + createLinkedPost( title, { + meta: { + [ META.postId ]: SENT_ID, + [ META.scheduledAt ]: new Date( pastUnix * 1000 ) + .toISOString() + .replace( /\.\d{3}Z$/, 'Z' ), + }, + } ); + + test.fixme( + 'AC-024: a newsletter beehiiv already sent is never updated; the WordPress edit still saves', + async ( { page } ) => { + seed( { mocks: sentMocks } ); + const id = sentFixture( 'QA E2E send AC-024' ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-024 edited after send', + } ); + expect( res.status ).toBe( 200 ); + expect( res.body.title.raw ).toBe( + 'QA E2E send AC-024 edited after send' + ); + const log = getLog(); + expect( updates( log ) ).toHaveLength( 0 ); + expect( creates( log ) ).toHaveLength( 0 ); + expect( deletes( log ) ).toHaveLength( 0 ); + } + ); + + test.fixme( + 'AC-025: when the send time has passed, the sync confirms the state with beehiiv first', + async ( { page } ) => { + seed( { mocks: sentMocks } ); + const id = sentFixture( 'QA E2E send AC-025' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-025 edited', + } ); + const log = getLog(); + const lookups = log.filter( + ( r ) => + r.method === 'GET' && + r.url.includes( `/posts/${ SENT_ID }` ) + ); + expect( lookups ).toHaveLength( 1 ); + expect( updates( log ) ).toHaveLength( 0 ); + } + ); + + test.fixme( + 'AC-026: once a post is marked as sent, later saves make no contact with beehiiv', + async ( { page } ) => { + seed( { mocks: sentMocks } ); + const id = sentFixture( 'QA E2E send AC-026' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-026 first edit', + } ); + expect( updates( getLog() ) ).toHaveLength( 0 ); // First save: confirmed sent, no update. + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E send AC-026 second edit', + } ); + expect( getLog() ).toHaveLength( 0 ); + } + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( "US-008: use the post's newsletter title and subtitle", () => { + test( 'AC-027: subject line is the newsletter title when set, else the post title (send and sync)', async ( { + page, + } ) => { + seed(); + const withTitle = createPost( { + title: 'QA E2E send AC-027 post', + meta: { + [ META.send ]: '1', + [ META.title ]: 'AC-027 inbox subject', + }, + } ); + const without = createPost( { + title: 'QA E2E send AC-027 plain', + meta: { [ META.send ]: '1' }, + } ); + const linked = createLinkedPost( 'QA E2E send AC-027 linked', { + meta: { [ META.title ]: 'AC-027 linked subject' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ withTitle }`, { + status: 'publish', + } ); + await rest( 'POST', `/wp/v2/posts/${ without }`, { + status: 'publish', + } ); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { content: CONTENT } ); + const log = getLog(); + const created = creates( log ); + expect( created ).toHaveLength( 2 ); + expect( created[ 0 ].body.email_settings.email_subject_line ).toBe( + 'AC-027 inbox subject' + ); + expect( created[ 1 ].body.email_settings.email_subject_line ).toBe( + 'QA E2E send AC-027 plain' + ); + expect( + updates( log )[ 0 ].body.email_settings.email_subject_line + ).toBe( 'AC-027 linked subject' ); + } ); + + test( 'AC-028: headline and web title stay the WordPress post title', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-028 post', + meta: { + [ META.send ]: '1', + [ META.title ]: 'AC-028 inbox subject', + }, + } ); + const linked = createLinkedPost( 'QA E2E send AC-028 linked', { + meta: { [ META.title ]: 'AC-028 linked subject' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { + title: 'QA E2E send AC-028 linked renamed', + } ); + const log = getLog(); + expect( creates( log )[ 0 ].body.title ).toBe( + 'QA E2E send AC-028 post' + ); + expect( updates( log )[ 0 ].body.title ).toBe( + 'QA E2E send AC-028 linked renamed' + ); + expect( + updates( log )[ 0 ].body.email_settings.email_subject_line + ).toBe( 'AC-028 linked subject' ); + // The newsletter title appears nowhere except the subject line. + const elsewhere = entries( creates( log )[ 0 ].body ).filter( + ( [ k, v ] ) => + v === 'AC-028 inbox subject' && k !== 'email_subject_line' + ); + expect( elsewhere ).toHaveLength( 0 ); + } ); + + test( 'AC-029: the newsletter subtitle is sent as the beehiiv subtitle; none when empty', async ( { + page, + } ) => { + seed(); + const withSub = createPost( { + title: 'QA E2E send AC-029 sub', + meta: { [ META.send ]: '1', [ META.subtitle ]: 'AC-029 subtitle' }, + } ); + const without = createPost( { + title: 'QA E2E send AC-029 nosub', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ withSub }`, { + status: 'publish', + } ); + await rest( 'POST', `/wp/v2/posts/${ without }`, { + status: 'publish', + } ); + const created = creates( getLog() ); + expect( created ).toHaveLength( 2 ); + expect( created[ 0 ].body.subtitle ).toBe( 'AC-029 subtitle' ); + expect( created[ 1 ].body ).not.toHaveProperty( 'subtitle' ); + } ); + + test( 'AC-030: changing or clearing the newsletter title updates a linked newsletter subject on save', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-030 post', { + meta: { [ META.title ]: 'AC-030 original subject' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + meta: { [ META.title ]: 'AC-030 changed subject' }, + } ); + expect( + updates( getLog() ).pop().body.email_settings.email_subject_line + ).toBe( 'AC-030 changed subject' ); + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ id }`, { + meta: { [ META.title ]: '' }, + } ); + expect( + updates( getLog() ).pop().body.email_settings.email_subject_line + ).toBe( 'QA E2E send AC-030 post' ); + } ); + + test( 'AC-031: changing the newsletter subtitle updates a linked newsletter subtitle on save', async ( { + page, + } ) => { + seed(); + const id = createLinkedPost( 'QA E2E send AC-031', { + meta: { [ META.subtitle ]: 'AC-031 original' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + meta: { [ META.subtitle ]: 'AC-031 changed' }, + } ); + const patched = updates( getLog() ); + expect( patched ).toHaveLength( 1 ); + expect( patched[ 0 ].body.subtitle ).toBe( 'AC-031 changed' ); + } ); + + test( 'AC-032: email preview text is not set from either field', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-032', + meta: { + [ META.send ]: '1', + [ META.title ]: 'AC-032 subject', + [ META.subtitle ]: 'AC-032 subtitle', + }, + } ); + const linked = createLinkedPost( 'QA E2E send AC-032 linked', { + meta: { + [ META.title ]: 'AC-032 subject', + [ META.subtitle ]: 'AC-032 subtitle', + }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { content: CONTENT } ); + const log = getLog(); + for ( const req of [ creates( log )[ 0 ], updates( log )[ 0 ] ] ) { + const previewKeys = entries( req.body ).filter( ( [ k ] ) => + /preview/i.test( k ) + ); + expect( previewKeys ).toHaveLength( 0 ); + } + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( "US-009: follow the post's email title/subtitle display choice", () => { + test( 'AC-033: the title is no longer always shown; it follows the post choice', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-033', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( + creates( getLog() )[ 0 ].body.email_settings.display_title_in_email + ).toBe( false ); + } ); + + test( 'AC-034: choice on shows title and subtitle; off or never set hides both', async ( { + page, + } ) => { + seed(); + const on = createPost( { + title: 'QA E2E send AC-034 on', + meta: { [ META.send ]: '1', [ META.show ]: '1' }, + } ); + const off = createPost( { + title: 'QA E2E send AC-034 off', + meta: { [ META.send ]: '1', [ META.show ]: '' }, + } ); + const unset = createPost( { + title: 'QA E2E send AC-034 unset', + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + + for ( const id of [ on, off, unset ] ) { + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + } + const [ cOn, cOff, cUnset ] = creates( getLog() ).map( + ( r ) => r.body.email_settings + ); + expect( cOn ).toMatchObject( { + display_title_in_email: true, + display_subtitle_in_email: true, + } ); + expect( cOff ).toMatchObject( { + display_title_in_email: false, + display_subtitle_in_email: false, + } ); + expect( cUnset ).toMatchObject( { + display_title_in_email: false, + display_subtitle_in_email: false, + } ); + } ); + + test( 'AC-035: the choice applies on first send and every sync; changing it updates a linked newsletter', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E send AC-035 first send', + meta: { [ META.send ]: '1', [ META.show ]: '1' }, + } ); + const linked = createLinkedPost( 'QA E2E send AC-035 linked' ); // Never set -> hidden on next sync. + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( + creates( getLog() )[ 0 ].body.email_settings.display_title_in_email + ).toBe( true ); + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { content: CONTENT } ); + expect( updates( getLog() )[ 0 ].body.email_settings ).toMatchObject( { + display_title_in_email: false, + display_subtitle_in_email: false, + } ); + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { + meta: { [ META.show ]: true }, + } ); + expect( updates( getLog() )[ 0 ].body.email_settings ).toMatchObject( { + display_title_in_email: true, + display_subtitle_in_email: true, + } ); + + clearLog(); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { + meta: { [ META.show ]: false }, + } ); + expect( updates( getLog() )[ 0 ].body.email_settings ).toMatchObject( { + display_title_in_email: false, + display_subtitle_in_email: false, + } ); + } ); + + test( 'AC-036: the byline stays hidden whatever the choice', async ( { + page, + } ) => { + seed(); + const on = createPost( { + title: 'QA E2E send AC-036 on', + meta: { [ META.send ]: '1', [ META.show ]: '1' }, + } ); + const off = createPost( { + title: 'QA E2E send AC-036 off', + meta: { [ META.send ]: '1' }, + } ); + const linked = createLinkedPost( 'QA E2E send AC-036 linked', { + meta: { [ META.show ]: '1' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ on }`, { status: 'publish' } ); + await rest( 'POST', `/wp/v2/posts/${ off }`, { status: 'publish' } ); + await rest( 'POST', `/wp/v2/posts/${ linked }`, { content: CONTENT } ); + const log = getLog(); + for ( const req of [ ...creates( log ), ...updates( log ) ] ) { + expect( req.body.email_settings.display_byline_in_email ).toBe( + false + ); + } + expect( creates( log ) ).toHaveLength( 2 ); + expect( updates( log ) ).toHaveLength( 1 ); + } ); +} ); diff --git a/tests/e2e/specs/oauth-authorization.spec.js b/tests/e2e/specs/oauth-authorization.spec.js new file mode 100644 index 0000000..8c85f86 --- /dev/null +++ b/tests/e2e/specs/oauth-authorization.spec.js @@ -0,0 +1,296 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/03-authentication/1-oauth-flow/oauth-authorization.prd.md + * (brownfield-mapped -- no PLAN/EXECUTION artifacts exist for this feature; + * the real files below were located by direct code search, not a plan/ + * execution trail. Verified by reading: includes/OAuth/Authorization.php, + * includes/OAuth/Pkce.php, includes/OAuth/ClientRegistrar.php, + * includes/OAuth/Config.php, includes/OAuth/AdminActions.php, + * includes/Connection/Manager.php (builds the nonced connect URL) and + * includes/Admin/Views/connection.php (renders the "Connect to beehiiv" + * button consumed here).) + * + * Authorization::get_authorize_url() is the unit under test: + * - if TokenStore::get_client_id() is already set, it skips + * ClientRegistrar::register() entirely and builds the authorize URL + * locally (no network call) -- so AC-001/002/004 are testable by + * seeding a client_id via TokenStore::save_client_id() (the same real + * storage path a genuine registration would use) and driving the real + * "Connect to beehiiv" button. + * - if no client_id exists, it calls ClientRegistrar::register(), which + * in this environment always fails with 'beehiiv_registration_token_missing' + * because BEEHIIV_REGISTRATION_TOKEN is an unreplaced build placeholder + * (Config::has_registration_token() is false) -- this is a real, + * deterministic, reachable code path here, not a gap. AC-003 exercises + * that path directly rather than a real network round-trip, since no + * real beehiiv registration credentials exist in this environment. + * + * The external authorize navigation (https://app.beehiiv.com/oauth/authorize) + * is intercepted and aborted via page.route() before any real network call + * is dispatched -- this both keeps the test hermetic (no outbound call to + * beehiiv) and lets the test inspect the exact redirect URL WordPress built, + * which is what AC-001/AC-002 are actually asserting. + * + * PKCE verifier/state transients are stored per-user + * (beehiiv_oauth_verifier_{user_id} / beehiiv_oauth_state_{user_id}), plain + * WordPress transients backed by wp_options in this environment (no + * object-cache.php drop-in present) -- AC-004 fast-forwards past the real + * 600s TTL by rewriting the stored `_transient_timeout_*` option directly, + * rather than sleeping 10 real minutes in a test. + */ + +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; +const SETTINGS_URL_RE = /\/wp-admin\/admin\.php\?page=beehiiv/; +const CLIENT_ID = 'qa-e2e-oauth-authz-client'; +const STATE_PREFIX = 'beehiiv_oauth_state_'; +const VERIFIER_PREFIX = 'beehiiv_oauth_verifier_'; + +let adminUserId; + +/** Removes any stored OAuth connection/client registration. */ +function clearConnection() { + wpCliSafe( `eval '\\Beehiiv\\OAuth\\TokenStore::delete_all();'` ); +} + +/** + * Seeds only a client_id (no access token) through the plugin's real + * storage path -- TokenStore::save_client_id() is exactly what + * ClientRegistrar::register() calls on a real successful registration, so + * this reproduces "already registered" state without a network call. + */ +function seedClientId() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_client_id( "${ CLIENT_ID }" );'` + ); +} + +/** + * Deletes the PKCE verifier and CSRF state transients for one user. + * @param {number|string} userId + */ +function clearAuthTransients( userId ) { + wpCliSafe( `option delete _transient_${ VERIFIER_PREFIX }${ userId }` ); + wpCliSafe( + `option delete _transient_timeout_${ VERIFIER_PREFIX }${ userId }` + ); + wpCliSafe( `option delete _transient_${ STATE_PREFIX }${ userId }` ); + wpCliSafe( + `option delete _transient_timeout_${ STATE_PREFIX }${ userId }` + ); +} + +/** + * Clicks "Connect to beehiiv" and captures the intercepted redirect request + * to the external authorize endpoint, aborting it before any real network + * call reaches beehiiv. + * + * @param {import('@playwright/test').Page} page + * @return {Promise} The parsed authorize URL WordPress redirected to. + */ +async function clickConnectAndCaptureAuthorizeUrl( page ) { + await page.route( '**/oauth/authorize**', ( route ) => route.abort() ); + + const [ request ] = await Promise.all( [ + page.waitForRequest( ( req ) => + req.url().includes( '/oauth/authorize' ) + ), + page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(), + ] ); + + return new URL( request.url() ); +} + +test.beforeAll( () => { + ensurePluginActive(); + adminUserId = wpCli( 'user get admin --field=ID' ).trim(); + + // Known clean baseline for every test in this file. This is wp-env's + // dedicated *tests* environment (never dev/production), so there is no + // real state worth preserving -- the only contract other agents' specs + // need from this file is "leave beehiiv_oauth and any oauth transients + // absent", not "restore whatever was here before". + clearConnection(); + clearAuthTransients( adminUserId ); +} ); + +test.afterAll( () => { + clearConnection(); + clearAuthTransients( adminUserId ); +} ); + +test.describe( 'OAuth Authorization Initiation', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'AC-001: admin can trigger an authorization flow that generates a beehiiv login URL', async ( { + page, + } ) => { + clearConnection(); + seedClientId(); + + await page.goto( SETTINGS_PATH ); + const authorizeUrl = await clickConnectAndCaptureAuthorizeUrl( page ); + + expect( authorizeUrl.origin + authorizeUrl.pathname ).toBe( + 'https://app.beehiiv.com/oauth/authorize' + ); + expect( authorizeUrl.searchParams.get( 'client_id' ) ).toBe( + CLIENT_ID + ); + expect( authorizeUrl.searchParams.get( 'response_type' ) ).toBe( + 'code' + ); + expect( authorizeUrl.searchParams.get( 'redirect_uri' ) ).toContain( + 'page=beehiiv-oauth-callback' + ); + + clearConnection(); + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-002: the generated URL includes PKCE challenge and CSRF state token for security', async ( { + page, + } ) => { + clearConnection(); + seedClientId(); + + await page.goto( SETTINGS_PATH ); + const authorizeUrl = await clickConnectAndCaptureAuthorizeUrl( page ); + + const state = authorizeUrl.searchParams.get( 'state' ); + const challenge = authorizeUrl.searchParams.get( 'code_challenge' ); + const method = authorizeUrl.searchParams.get( 'code_challenge_method' ); + + // BR-002: CSRF state is a random 32-character string. + // wp_generate_password( 32, false, false ) -> alphanumeric only. + expect( state ).toMatch( /^[A-Za-z0-9]{32}$/ ); + + // BR-001: PKCE code verifier/challenge are 43-char base64url (RFC 7636 + // S256: base64url( sha256( verifier ) ), padding stripped). + expect( challenge ).toMatch( /^[A-Za-z0-9_-]{43}$/ ); + expect( method ).toBe( 'S256' ); + + // BR-003: scopes cover identify, publications, and posts. + expect( authorizeUrl.searchParams.get( 'scope' ) ).toBe( + 'identify:read publications:read posts:read posts:write' + ); + + // Cross-check the state param actually matches what Authorization + // persisted server-side (not just a client-visible value) -- this is + // the same value the callback handler will validate against later. + const storedState = wpCli( + `option get _transient_${ STATE_PREFIX }${ adminUserId }` + ); + expect( storedState.trim() ).toBe( state ); + + clearConnection(); + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-003: the system automatically registers the site as an OAuth client if one does not exist', async ( { + page, + } ) => { + clearConnection(); // No client_id -> get_authorize_url() must attempt registration. + + await page.goto( SETTINGS_PATH ); + await page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + + // This exact message is only emitted from ClientRegistrar::register()'s + // has_registration_token() branch, which only executes when + // get_authorize_url() found no existing client_id -- reaching it proves + // automatic registration was attempted, not skipped. A real successful + // registration round-trip cannot be exercised here: this environment's + // BEEHIIV_REGISTRATION_TOKEN is an unreplaced build placeholder. + await expect( page.locator( '.notice-error' ) ).toContainText( + /not configured for this plugin build/i + ); + + const clientIdAfter = wpCli( + `eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_client_id();'` + ); + expect( clientIdAfter.trim() ).toBe( '' ); + } ); + + test( 'AC-004: authorization session data expires after 10 minutes to prevent replay attacks', async ( { + page, + } ) => { + clearConnection(); + seedClientId(); + + await page.goto( SETTINGS_PATH ); + const authorizeUrl = await clickConnectAndCaptureAuthorizeUrl( page ); + const state = authorizeUrl.searchParams.get( 'state' ); + + // BR-004 / Config::PKCE_TRANSIENT_TTL: session transients are created + // with a 600-second TTL. + const timeoutOption = wpCli( + `option get _transient_timeout_${ STATE_PREFIX }${ adminUserId }` + ); + const secondsRemaining = + parseInt( timeoutOption.trim(), 10 ) - + Math.floor( Date.now() / 1000 ); + expect( secondsRemaining ).toBeGreaterThan( 590 ); + expect( secondsRemaining ).toBeLessThanOrEqual( 600 ); + + // Sanity check: the freshly-issued state is still valid right now. + // Authorization::validate_state() keys off get_current_user_id(), so + // this must run as the same admin user the browser session used to + // generate it -- plain `wp eval` runs as user 0 by default, which + // would silently check the wrong transient key + // (beehiiv_oauth_state_0, never created) and always report invalid + // regardless of real expiry behavior. + const validNow = wpCli( + `eval --user=admin 'echo \\Beehiiv\\OAuth\\Authorization::validate_state( "${ state }" ) ? "yes" : "no";'` + ); + expect( validNow.trim() ).toBe( 'yes' ); + + // Fast-forward past expiry by rewriting the stored timeout into the + // past -- equivalent to 10+ real minutes elapsing -- rather than + // sleeping in the test. WordPress transients (DB-backed here, no + // object-cache.php present) self-expire once now() passes this value. + wpCli( + `option update _transient_timeout_${ STATE_PREFIX }${ adminUserId } ${ + Math.floor( Date.now() / 1000 ) - 5 + }` + ); + + const validAfterExpiry = wpCli( + `eval --user=admin 'echo \\Beehiiv\\OAuth\\Authorization::validate_state( "${ state }" ) ? "yes" : "no";'` + ); + expect( validAfterExpiry.trim() ).toBe( 'no' ); + + clearConnection(); + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-005: OAuth base URL can be overridden via wp-config.php for local and staging environments', async () => { + // Config::get_oauth_base_url() reads the BEEHIIV_OAUTH_BASE_URL + // constant via defined()/constant() at call time -- defining it earlier + // in the same PHP process (as `wp eval` does here) exercises the exact + // same resolution code wp-config.php would trigger, without touching + // the shared environment's actual wp-config.php or requiring a + // wp-env restart that could affect other agents' concurrent specs. + // The defined constant does not persist beyond this single `wp eval` + // process, so no cleanup is needed. + const overrideHost = 'oauth.staging.example.test'; + const result = wpCli( + `eval 'define( "BEEHIIV_OAUTH_BASE_URL", "https://${ overrideHost }" ); echo \\Beehiiv\\OAuth\\Config::get_oauth_base_url() . "|" . implode( ",", \\Beehiiv\\OAuth\\Config::get_oauth_redirect_hosts() );'` + ); + const [ overriddenBaseUrl, hosts ] = result.trim().split( '|' ); + + expect( overriddenBaseUrl ).toBe( `https://${ overrideHost }` ); + expect( hosts.split( ',' ) ).toContain( overrideHost ); + + // Unset (default/production) behavior, proving the override is + // opt-in rather than baked into the default resolution. + const defaultResult = wpCli( + `eval 'echo \\Beehiiv\\OAuth\\Config::get_oauth_base_url();'` + ); + expect( defaultResult.trim() ).toBe( 'https://app.beehiiv.com' ); + } ); +} ); diff --git a/tests/e2e/specs/oauth-callback.spec.js b/tests/e2e/specs/oauth-callback.spec.js new file mode 100644 index 0000000..04bc0ec --- /dev/null +++ b/tests/e2e/specs/oauth-callback.spec.js @@ -0,0 +1,321 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin, loginAs } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/03-authentication/1-oauth-flow/oauth-callback.prd.md + * (brownfield-mapped -- no PLAN/EXECUTION artifacts exist for this feature; + * the real files below were located by direct code search, not a plan/ + * execution trail. Verified by reading: includes/OAuth/CallbackHandler.php + * (unit under test), includes/OAuth/Authorization.php (state/PKCE + * validation, consumed here), includes/OAuth/HttpClient.php (token + * exchange transport), includes/OAuth/TokenStore.php (credential storage), + * includes/OAuth/AdminActions.php (renders the resulting admin notice), + * includes/API/Client.php (the /users/identify call), includes/API/Cache.php. + * + * CallbackHandler::maybe_handle() runs on admin_init (priority 1) and only + * engages when $_GET['page'] === Config::CALLBACK_PAGE + * ('beehiiv-oauth-callback') -- a hidden submenu (add_submenu_page with a + * null parent), reached at /wp-admin/admin.php?page=beehiiv-oauth-callback. + * + * A REAL state+PKCE verifier pair is required for most ACs here (the + * handler validates against the live transient, not a value this spec can + * fabricate) -- obtained by driving the real "Connect to beehiiv" button + * (seeding a client_id first, same technique oauth-authorization.spec.js + * uses) and capturing the state param from the intercepted redirect, + * exactly as beehiiv itself would hand it back on a real callback. + * + * AC-001/AC-005/AC-006 (successful redirect, token storage, user identity) + * require the full success path, which needs beehiiv's real /oauth/token + * and /users/identify endpoints -- mocked via the test-only mu-plugin + * seam's generic beehiiv_e2e_mock_http() (tests/e2e/plugins/beehiiv-options.php), + * since this wp-env build has no real beehiiv account to complete a + * genuine exchange against. + */ + +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; +const SETTINGS_URL_RE = /\/wp-admin\/admin\.php\?page=beehiiv(&|$)/; +const CALLBACK_PATH = '/wp-admin/admin.php?page=beehiiv-oauth-callback'; +const CLIENT_ID = 'qa-e2e-oauth-callback-client'; +const STATE_PREFIX = 'beehiiv_oauth_state_'; +const VERIFIER_PREFIX = 'beehiiv_oauth_verifier_'; + +const NON_ADMIN_USERNAME = 'qa_e2e_oauth_callback_contributor'; +const NON_ADMIN_PASSWORD = 'qa-e2e-password'; + +let adminUserId; + +/** Removes any stored OAuth connection/client registration. */ +function clearConnection() { + wpCliSafe( `eval '\\Beehiiv\\OAuth\\TokenStore::delete_all();'` ); +} + +/** Seeds only a client_id, the same real storage path a successful registration would use. */ +function seedClientId() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_client_id( "${ CLIENT_ID }" );'` + ); +} + +/** + * Deletes the PKCE verifier and CSRF state transients for one user. + * @param {number|string} userId + */ +function clearAuthTransients( userId ) { + wpCliSafe( `option delete _transient_${ VERIFIER_PREFIX }${ userId }` ); + wpCliSafe( + `option delete _transient_timeout_${ VERIFIER_PREFIX }${ userId }` + ); + wpCliSafe( `option delete _transient_${ STATE_PREFIX }${ userId }` ); + wpCliSafe( + `option delete _transient_timeout_${ STATE_PREFIX }${ userId }` + ); +} + +/** + * Drives the real "Connect to beehiiv" button and captures the real `state` + * WordPress issued, aborting the external navigation before any real + * request reaches beehiiv (same technique as oauth-authorization.spec.js). + * + * @param {import('@playwright/test').Page} page + * @return {Promise} The real, server-validated state value. + */ +async function seedRealAuthorizationState( page ) { + clearConnection(); + seedClientId(); + + await page.route( '**/oauth/authorize**', ( route ) => route.abort() ); + await page.goto( SETTINGS_PATH ); + + const [ request ] = await Promise.all( [ + page.waitForRequest( ( req ) => + req.url().includes( '/oauth/authorize' ) + ), + page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(), + ] ); + + return new URL( request.url() ).searchParams.get( 'state' ); +} + +/** + * Builds a callback URL with the given query params appended. + * @param {Object} params + */ +function callbackUrl( params ) { + return `${ CALLBACK_PATH }&${ new URLSearchParams( params ).toString() }`; +} + +test.beforeAll( () => { + ensurePluginActive(); + adminUserId = wpCli( 'user get admin --field=ID' ).trim(); + + // Idempotent fixture user for the AC-007 permission-gate test. + wpCliSafe( + `user create ${ NON_ADMIN_USERNAME } ${ NON_ADMIN_USERNAME }@example.test ` + + `--role=contributor --user_pass=${ NON_ADMIN_PASSWORD }` + ); + + // Known clean baseline for every test in this file. This is wp-env's + // dedicated *tests* environment (never dev/production), so there is no + // real state worth preserving here. + clearConnection(); + clearAuthTransients( adminUserId ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.afterAll( () => { + clearConnection(); + clearAuthTransients( adminUserId ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.describe( 'OAuth Callback & Token Exchange', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'AC-002: invalid or missing authorization codes show an error message and prevent incomplete connections', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + // No `code` param at all -- CallbackHandler::process() rejects on + // `'' === $code` before even reaching Authorization::validate_state(). + await page.goto( callbackUrl( { state } ) ); + await page.waitForURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-error' ) ).toContainText( + /invalid beehiiv connection response/i + ); + + const connectedAfter = wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ); + expect( connectedAfter.trim() ).toBe( 'no' ); + + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-004: CSRF state validation prevents authorization codes obtained through redirects to other sites from being accepted', async ( { + page, + } ) => { + await seedRealAuthorizationState( page ); + + // A tampered/foreign state value must be rejected even with a + // well-formed `code` present. Note: this hits the exact same + // "Invalid beehiiv connection response" message as AC-002 -- the + // handler uses one combined branch for `'' === $code || + // !validate_state($state)`, not separate messages per failure mode. + await page.goto( + callbackUrl( { + code: 'qa-e2e-fake-code', + state: 'not-the-real-state-value-000000', + } ) + ); + await page.waitForURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-error' ) ).toContainText( + /invalid beehiiv connection response/i + ); + + const connectedAfter = wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ); + expect( connectedAfter.trim() ).toBe( 'no' ); + + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-003: expired authorization sessions (older than 10 minutes) display a clear error message', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + // BR-002 / Authorization::get_authorize_url(): the state and PKCE + // verifier transients are written with the SAME TTL and therefore + // always expire together in practice -- since validate_state() (state + // only) runs BEFORE consume_code_verifier() (verifier), a fully + // elapsed 10-minute window would hit the generic "Invalid beehiiv + // connection response" branch (AC-002/AC-004's message), never this + // one. The "session expired" message is only reachable when the + // verifier specifically is gone while state is still valid -- e.g. + // this exact asymmetric loss, or a same-code replay after a first + // successful exchange already consumed both (BR-002's single-use + // guarantee). Reproduced precisely here rather than waiting out the + // shared TTL, which would not exercise this code path at all. + wpCliSafe( + `option delete _transient_${ VERIFIER_PREFIX }${ adminUserId }` + ); + wpCliSafe( + `option delete _transient_timeout_${ VERIFIER_PREFIX }${ adminUserId }` + ); + + await page.goto( callbackUrl( { code: 'qa-e2e-fake-code', state } ) ); + await page.waitForURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-error' ) ).toContainText( + /connection session expired/i + ); + + clearAuthTransients( adminUserId ); + } ); + + test( 'AC-001: after granting permission on beehiiv, the admin is redirected back to WordPress settings', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + wpCli( + 'eval \'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "qa-e2e-ac1-access", "refresh_token" => "qa-e2e-ac1-refresh", "expires_in" => 3600 ] ] ); beehiiv_e2e_mock_http( "/users/identify", [ "body" => [] ] );\'' + ); + + await page.goto( callbackUrl( { code: 'qa-e2e-real-code', state } ) ); + await expect( page ).toHaveURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-success' ) ).toContainText( + /successfully connected/i + ); + + clearConnection(); + clearAuthTransients( adminUserId ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); + } ); + + test( 'AC-005: successfully exchanged tokens are stored and available for authenticated API requests', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + wpCli( + 'eval \'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "qa-e2e-ac5-access", "refresh_token" => "qa-e2e-ac5-refresh", "expires_in" => 3600 ] ] ); beehiiv_e2e_mock_http( "/users/identify", [ "body" => [] ] );\'' + ); + + await page.goto( callbackUrl( { code: 'qa-e2e-real-code', state } ) ); + await page.waitForURL( SETTINGS_URL_RE ); + + const accessToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( accessToken.trim() ).toBe( 'qa-e2e-ac5-access' ); + + clearConnection(); + clearAuthTransients( adminUserId ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); + } ); + + test( "AC-006: the authenticated user's identity is saved and displayed in the connection status", async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + wpCli( + 'eval \'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "qa-e2e-ac6-access", "refresh_token" => "qa-e2e-ac6-refresh", "expires_in" => 3600 ] ] ); beehiiv_e2e_mock_http( "/users/identify", [ "body" => [ "first_name" => "QA", "last_name" => "Tester", "email" => "qa-e2e-ac6@example.test" ] ] );\'' + ); + + await page.goto( callbackUrl( { code: 'qa-e2e-real-code', state } ) ); + await page.waitForURL( SETTINGS_URL_RE ); + + await expect( + page.locator( '.beehiiv-connection-status__account' ) + ).toContainText( 'QA Tester (qa-e2e-ac6@example.test)' ); + + clearConnection(); + clearAuthTransients( adminUserId ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); + } ); + + test( 'AC-007: only users with admin permissions can complete the OAuth connection', async ( { + page, + } ) => { + // CallbackHandler::process()'s own current_user_can('manage_options') + // check -- and its custom "You do not have permission to connect + // beehiiv." message -- is unreachable for this exact scenario. The + // callback page is registered via add_submenu_page(null, ..., + // 'manage_options', ...) (CallbackHandler::register_page(), hooked to + // admin_menu). WordPress core's own capability gate for a + // nopriv-registered page runs synchronously inside + // wp-admin/includes/menu.php, required from wp-admin/menu.php at + // admin.php's line 163 -- *before* `do_action('admin_init')` at line + // 180, which is what CallbackHandler::maybe_handle() hooks. So a + // non-admin never reaches CallbackHandler::process() at all here; + // WP core's generic "Sorry, you are not allowed to access this page." + // (403) always wins first. Verified directly via wp-admin/menu.php + // and wp-admin/includes/menu.php source, not just inferred from this + // test's own result. The AC's actual guarantee (non-admins cannot + // complete the connection) still holds -- just via WP core's page + // registration, not the plugin's own redundant check. + await loginAs( page, NON_ADMIN_USERNAME, NON_ADMIN_PASSWORD ); + const response = await page.goto( + callbackUrl( { + code: 'qa-e2e-any-code', + state: 'qa-e2e-any-state', + } ) + ); + + expect( response.status() ).toBe( 403 ); + await expect( + page.getByText( /sorry, you are not allowed to access this page/i ) + ).toBeVisible(); + + const connectedAfter = wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ); + expect( connectedAfter.trim() ).toBe( 'no' ); + } ); +} ); diff --git a/tests/e2e/specs/oauth-connection-actions.spec.js b/tests/e2e/specs/oauth-connection-actions.spec.js new file mode 100644 index 0000000..ca28005 --- /dev/null +++ b/tests/e2e/specs/oauth-connection-actions.spec.js @@ -0,0 +1,331 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin, loginAs } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/03-authentication/3-oauth-admin-actions/oauth-connection-actions.prd.md + * (brownfield-mapped -- no PLAN/EXECUTION artifacts exist for this feature; + * the real files below were located by direct code search, not a plan/ + * execution trail. Verified by reading: includes/OAuth/AdminActions.php + * (unit under test -- handle_connect/handle_disconnect/verify_request/ + * render_notice), includes/OAuth/Authorization.php, includes/OAuth/Revoker.php, + * includes/OAuth/TokenStore.php. + * + * This PRD substantially overlaps three PRDs already independently tested + * this session: + * - authentication/oauth-flow/oauth-authorization (AC-001/AC-003 here) + * - authentication/oauth-flow/oauth-callback (AC-004/AC-005/AC-006 here) + * - admin-interface/settings-page/connection-status-card (AC-007 here) + * Rather than re-deriving identical coverage, this spec writes lightweight, + * direct tests for each AC (cheap given the existing test-only mocking + * seam) and additionally covers BR-001/BR-002 (capability + nonce + * enforcement on these specific admin_post handlers), which no prior spec + * exercised -- AdminActions::verify_request() is shared plumbing for both + * handle_connect() and handle_disconnect() and was untested until now. + */ + +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; +const SETTINGS_URL_RE = /\/wp-admin\/admin\.php\?page=beehiiv(&|$)/; +const SETTINGS_OPTION = 'beehiiv_settings'; +const CONNECT_URL = '/wp-admin/admin-post.php?action=beehiiv_oauth_connect'; +const DISCONNECT_URL = + '/wp-admin/admin-post.php?action=beehiiv_oauth_disconnect'; + +const NON_ADMIN_USERNAME = 'qa_e2e_oauth_actions_contributor'; +const NON_ADMIN_PASSWORD = 'qa-e2e-password'; + +function clearConnection() { + wpCliSafe( `eval '\\Beehiiv\\OAuth\\TokenStore::delete_all();'` ); +} + +function seedClientId() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_client_id( "qa-e2e-conn-actions-client" );'` + ); +} + +function seedConnected() { + wpCli( + `eval '\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-conn-actions-client", [ "access_token" => "qa-e2e-conn-actions-access", "refresh_token" => "qa-e2e-conn-actions-refresh", "expires_in" => 3600 ] );'` + ); +} + +test.beforeAll( () => { + ensurePluginActive(); + + wpCliSafe( + `user create ${ NON_ADMIN_USERNAME } ${ NON_ADMIN_USERNAME }@example.test ` + + `--role=contributor --user_pass=${ NON_ADMIN_PASSWORD }` + ); + + // Known clean baseline. This is wp-env's dedicated *tests* environment + // (never dev/production), so there is no real state worth preserving. + clearConnection(); + wpCliSafe( `option delete ${ SETTINGS_OPTION }` ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.afterAll( () => { + clearConnection(); + wpCliSafe( `option delete ${ SETTINGS_OPTION }` ); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); +} ); + +test.describe( 'OAuth Connection Actions', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'AC-001: connect action initiates OAuth authorization flow when the connect button is clicked', async ( { + page, + } ) => { + clearConnection(); + seedClientId(); + + await page.route( '**/oauth/authorize**', ( route ) => route.abort() ); + await page.goto( SETTINGS_PATH ); + + const [ request ] = await Promise.all( [ + page.waitForRequest( ( req ) => + req.url().includes( '/oauth/authorize' ) + ), + page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(), + ] ); + + expect( new URL( request.url() ).origin ).toBe( + 'https://app.beehiiv.com' + ); + + clearConnection(); + } ); + + test( 'AC-002: authorization URL is fetched before redirecting to beehiiv', async ( { + page, + } ) => { + clearConnection(); + seedClientId(); + + // Server-side: hitting the connect endpoint directly must respond + // with a real 302 Location header pointing at the authorize URL -- + // proving the URL is built (fetched) server-side as part of the + // redirect response itself, not client-side after landing somewhere + // else first. + const nonce = await getConnectNonce( page ); + const response = await page.request.get( + `${ CONNECT_URL }&_wpnonce=${ nonce }`, + { maxRedirects: 0 } + ); + + expect( response.status() ).toBe( 302 ); + expect( response.headers().location ).toContain( + 'https://app.beehiiv.com/oauth/authorize' + ); + + clearConnection(); + } ); + + test( 'AC-003: if authorization URL generation fails, an error message is displayed instead of a broken redirect', async ( { + page, + } ) => { + clearConnection(); // No client_id -> ClientRegistrar::register() fails (no real registration token here). + + await page.goto( SETTINGS_PATH ); + await page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + + await expect( page.locator( '.notice-error' ) ).toContainText( + /not configured for this plugin build/i + ); + expect( page.url() ).toMatch( SETTINGS_URL_RE ); + + clearConnection(); + } ); + + test( 'AC-004: after successful OAuth callback, user returns to the settings page with a success message', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + wpCli( + 'eval \'beehiiv_e2e_mock_http( "/oauth/token", [ "body" => [ "access_token" => "qa-e2e-ca-ac4-access", "refresh_token" => "qa-e2e-ca-ac4-refresh", "expires_in" => 3600 ] ] ); beehiiv_e2e_mock_http( "/users/identify", [ "body" => [] ] );\'' + ); + + await page.goto( + `/wp-admin/admin.php?page=beehiiv-oauth-callback&code=qa-e2e-real-code&state=${ state }` + ); + + await expect( page ).toHaveURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-success' ) ).toContainText( + /successfully connected/i + ); + + clearConnection(); + wpCliSafe( "eval 'beehiiv_e2e_reset_all();'" ); + } ); + + test( 'AC-005: error messages are displayed in the admin interface', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + await page.goto( + `/wp-admin/admin.php?page=beehiiv-oauth-callback&state=${ state }` // No `code` param. + ); + await page.waitForURL( SETTINGS_URL_RE ); + + await expect( page.locator( '.notice-error' ) ).toBeVisible(); + } ); + + test( 'AC-006: error messages provide sufficient information to troubleshoot the issue', async ( { + page, + } ) => { + const state = await seedRealAuthorizationState( page ); + + await page.goto( + `/wp-admin/admin.php?page=beehiiv-oauth-callback&state=${ state }` + ); + await page.waitForURL( SETTINGS_URL_RE ); + + // Not just "an error occurred" -- names the specific, actionable + // problem (invalid response) and invites retrying. + await expect( page.locator( '.notice-error' ) ).toContainText( + /invalid beehiiv connection response/i + ); + await expect( page.locator( '.notice-error' ) ).toContainText( + /please try again/i + ); + } ); + + test( "AC-007: disconnect action removes the site's stored connection and credentials", async ( { + page, + } ) => { + seedConnected(); + expect( + wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ).trim() + ).toBe( 'yes' ); + + await page.goto( SETTINGS_PATH ); + await page.getByRole( 'link', { name: 'Disconnect' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + + expect( + wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ).trim() + ).toBe( 'no' ); + } ); + + test( 'AC-008: after disconnect, a success message confirms the action', async ( { + page, + } ) => { + seedConnected(); + + await page.goto( SETTINGS_PATH ); + await page.getByRole( 'link', { name: 'Disconnect' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + + await expect( page.locator( '.notice-success' ) ).toContainText( + 'Disconnected from beehiiv.' + ); + } ); + + test( 'AC-009: admin notice messages appear on the settings page after redirect', async ( { + page, + } ) => { + // The notice is a one-time transient rendered on admin_notices, keyed + // per-user and deleted on read -- confirm it survives exactly one + // redirect+render, then is gone on a subsequent load. + seedConnected(); + + await page.goto( SETTINGS_PATH ); + await page.getByRole( 'link', { name: 'Disconnect' } ).click(); + await page.waitForURL( SETTINGS_URL_RE ); + await expect( page.locator( '.notice-success' ) ).toBeVisible(); + + await page.reload(); + await expect( page.locator( '.notice-success' ) ).toHaveCount( 0 ); + } ); + + test( 'Security (BR-001/BR-002): connect and disconnect actions require a valid capability and a valid nonce', async ( { + page, + } ) => { + // BR-002: a missing/invalid nonce is rejected by WordPress core's own + // check_admin_referer() -- a "confirm this action" die page, not the + // plugin's own redirect-with-notice flow. + const badNonceConnect = await page.request.get( + `${ CONNECT_URL }&_wpnonce=not-a-real-nonce`, + { + maxRedirects: 0, + } + ); + expect( badNonceConnect.status() ).toBe( 403 ); + + const badNonceDisconnect = await page.request.get( + `${ DISCONNECT_URL }&_wpnonce=not-a-real-nonce`, + { + maxRedirects: 0, + } + ); + expect( badNonceDisconnect.status() ).toBe( 403 ); + + // BR-001: a non-admin is rejected by the plugin's own + // current_user_can('manage_options') check inside verify_request() + // -- which runs BEFORE the nonce check, so this fires even with an + // invalid nonce. wp_die() with no explicit response code returns 500 + // in this environment (verified directly via curl, not assumed). + await loginAs( page, NON_ADMIN_USERNAME, NON_ADMIN_PASSWORD ); + const nonAdminConnect = await page.request.get( + `${ CONNECT_URL }&_wpnonce=not-a-real-nonce`, + { + maxRedirects: 0, + } + ); + expect( nonAdminConnect.status() ).toBe( 500 ); + await expect( nonAdminConnect.text() ).resolves.toContain( + 'You do not have permission to manage beehiiv settings.' + ); + + const connectedAfter = wpCli( + 'eval \'echo \\Beehiiv\\Connection\\Manager::is_connected() ? "yes" : "no";\'' + ); + expect( connectedAfter.trim() ).toBe( 'no' ); + } ); +} ); + +/** + * Reads the real nonce for the connect action out of the live rendered settings page. + * @param {import('@playwright/test').Page} page + */ +async function getConnectNonce( page ) { + await page.goto( SETTINGS_PATH ); + const href = await page + .getByRole( 'link', { name: 'Connect to beehiiv' } ) + .getAttribute( 'href' ); + return new URL( href, page.url() ).searchParams.get( '_wpnonce' ); +} + +/** + * Drives the real "Connect to beehiiv" button and captures the real `state` + * WordPress issued, aborting the external navigation before any real + * request reaches beehiiv. + * + * @param {import('@playwright/test').Page} page + * @return {Promise} The `state` value WordPress issued. + */ +async function seedRealAuthorizationState( page ) { + clearConnection(); + seedClientId(); + + await page.route( '**/oauth/authorize**', ( route ) => route.abort() ); + await page.goto( SETTINGS_PATH ); + + const [ request ] = await Promise.all( [ + page.waitForRequest( ( req ) => + req.url().includes( '/oauth/authorize' ) + ), + page.getByRole( 'link', { name: 'Connect to beehiiv' } ).click(), + ] ); + + return new URL( request.url() ).searchParams.get( 'state' ); +} diff --git a/tests/e2e/specs/post-level-newsletter-overrides.spec.js b/tests/e2e/specs/post-level-newsletter-overrides.spec.js new file mode 100644 index 0000000..3d0ff7b --- /dev/null +++ b/tests/e2e/specs/post-level-newsletter-overrides.spec.js @@ -0,0 +1,683 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAs, loginAsAdmin } = require( '../utils/auth' ); +const { + openPostEditor, + openBeehiivSidebar, + saveDraft, +} = require( '../utils/editor' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/06-editor-integration/8-post-level-newsletter-overrides/post-level-newsletter-overrides.prd.md (v1.2) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at the paths listed in + * PLAN/EXECUTION's file lists: + * - includes/Editor/Meta.php, includes/Editor/PostSettings.php -- the three + * meta keys and their publish_posts write gate. + * - src/js/shared/meta.js, src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js, + * src/js/editor/post-settings/index.js -- the two TextControls + one + * ToggleControl, and the "published by beehiiv" lock. + * + * The wording block only renders when the panel is "newsletter ready" + * (connected + write permission + publication + default template) and + * "Send to newsletter" is on, so beforeAll seeds that state through the + * test-only mu-plugin seams in tests/e2e/plugins/beehiiv-options.php. Every + * outbound beehiiv call is mocked; nothing reaches the live API. + */ + +const META_TITLE = '_beehiiv_newsletter_title'; +const META_SUBTITLE = '_beehiiv_newsletter_subtitle'; +const META_SHOW = '_beehiiv_newsletter_show_title_in_email'; +const META_SEND = '_beehiiv_send_to_newsletter'; +const META_POST_ID = '_beehiiv_post_id'; +const META_SCHEDULED_AT = '_beehiiv_scheduled_at'; + +const PUBLICATION_ID = 'pub_qa_e2e_overrides'; +const TEMPLATE_ID = 'tpl_qa_e2e_overrides'; +const MOCK_BEEHIIV_POST_ID = 'post_qa_e2e_overrides'; + +const AUTHOR_USERNAME = 'qa_e2e_author_overrides'; +const AUTHOR_PASSWORD = 'qa-e2e-author-overrides-1!'; +const CONTRIBUTOR_USERNAME = 'qa_e2e_contributor_overrides'; +const CONTRIBUTOR_PASSWORD = 'qa-e2e-contrib-overrides-1!'; + +const SIDEBAR = '.beehiiv-post-settings'; +const TITLE_LABEL = 'Newsletter title'; +const SUBTITLE_LABEL = 'Newsletter subtitle'; +const SHOW_LABEL = 'Show title and subtitle in email'; + +const createdPosts = []; +let authorId; +let contributorId; + +function seedNewsletterReady() { + wpCli( + `eval 'beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ "publication_id" => "${ PUBLICATION_ID }", "post_template_id" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "${ MOCK_BEEHIIV_POST_ID }" ] ] ] );'` + ); +} + +function ensureUser( username, password, role ) { + let id = wpCliSafe( `user get ${ username } --field=ID` ); + if ( ! id ) { + id = wpCli( + `user create ${ username } ${ username }@example.test --role=${ role } --user_pass="${ password }" --porcelain` + ); + } else { + wpCli( + `user update ${ id } --user_pass="${ password }" --role=${ role }` + ); + } + return id; +} + +/** + * Creates a draft post with "Send to newsletter" on (so the wording block renders). + * + * @param {string} title Post title. + * @param {Object} [options] Post options. + * @param {number} [options.author] Author user ID. + * @param {boolean} [options.send] Whether "Send to newsletter" is on. + * @param {Object} [options.meta] Extra post meta to set. + * @return {string} Post ID. + */ +function createPost( title, { author = 1, send = true, meta = {} } = {} ) { + const id = wpCli( + `post create --post_type=post --post_title="${ title }" --post_content="

QA E2E body content.

" --post_status=draft --post_author=${ author } --porcelain` + ); + createdPosts.push( id ); + if ( send ) { + wpCli( `post meta update ${ id } ${ META_SEND } 1` ); + } + for ( const [ key, value ] of Object.entries( meta ) ) { + wpCli( `post meta update ${ id } ${ key } '${ value }'` ); + } + return id; +} + +function getMeta( id, key ) { + const value = wpCliSafe( `post meta get ${ id } ${ key }` ); + return value === null ? '' : value; +} + +function isoFromNow( ms ) { + return new Date( Date.now() + ms ) + .toISOString() + .replace( /\.\d{3}Z$/, 'Z' ); +} + +async function openSidebarFor( page, postId ) { + await openPostEditor( page, postId ); + await openBeehiivSidebar( page ); + const sidebar = page.locator( SIDEBAR ); + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toBeVisible( { + timeout: 15000, + } ); + return sidebar; +} + +/** + * Posts to the core REST posts endpoint from inside wp-admin, with the user's own cookie + nonce. + * + * @param {import('@playwright/test').Page} page Playwright page, logged in. + * @param {string|number} postId Post ID. + * @param {Object} meta Meta patch to send. + * @return {Promise} `{ ok, meta }` on success, `{ ok: false, ... }` on error. + */ +async function restUpdateMeta( page, postId, meta ) { + return page.evaluate( + async ( { id, metaPatch } ) => { + try { + const res = await window.wp.apiFetch( { + path: `/wp/v2/posts/${ id }`, + method: 'POST', + data: { meta: metaPatch }, + } ); + return { ok: true, meta: res.meta }; + } catch ( e ) { + return { ok: false, code: e.code, status: e.data?.status }; + } + }, + { id: postId, metaPatch: meta } + ); +} + +// Each test shells out to wp-cli several times (fixtures + DB assertions) +// on top of multiple editor loads, so the 30s default is too tight. +test.describe.configure( { timeout: 120 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); + authorId = ensureUser( AUTHOR_USERNAME, AUTHOR_PASSWORD, 'author' ); + contributorId = ensureUser( + CONTRIBUTOR_USERNAME, + CONTRIBUTOR_PASSWORD, + 'contributor' + ); +} ); + +test.beforeEach( async () => { + seedNewsletterReady(); +} ); + +test.afterAll( async () => { + for ( const id of createdPosts ) { + // Unlink first so deleting never tries to cancel a beehiiv newsletter. + wpCliSafe( `post meta delete ${ id } ${ META_POST_ID }` ); + wpCliSafe( `post meta delete ${ id } ${ META_SCHEDULED_AT }` ); + wpCliSafe( `post delete ${ id } --force` ); + } + wpCliSafe( `user delete ${ authorId } --yes` ); + wpCliSafe( `user delete ${ contributorId } --yes` ); + wpCliSafe( 'option delete beehiiv_settings' ); + wpCliSafe( `eval 'beehiiv_e2e_reset_all();'` ); +} ); + +test.describe( 'US-001: newsletter title', () => { + test( 'AC-001: the newsletter title field is in the beehiiv sidebar panel, not next to the post title', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-001' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toBeVisible(); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + await expect( canvas.getByText( TITLE_LABEL ) ).toHaveCount( 0 ); + } ); + + test( 'AC-002: the newsletter title starts empty and is not pre-filled from the post title', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-002 post title' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toHaveValue( '' ); + } ); + + test( 'AC-003: the newsletter title is saved per post and shown again on reopen', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-003' ); + const otherId = createPost( 'QA E2E overrides AC-003 (other post)' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar.getByLabel( TITLE_LABEL ).fill( 'Inbox subject AC-003' ); + await saveDraft( page ); + expect( getMeta( id, META_TITLE ) ).toBe( 'Inbox subject AC-003' ); + + const reopened = await openSidebarFor( page, id ); + await expect( reopened.getByLabel( TITLE_LABEL ) ).toHaveValue( + 'Inbox subject AC-003' + ); + + // Per post: another post is unaffected. + const other = await openSidebarFor( page, otherId ); + await expect( other.getByLabel( TITLE_LABEL ) ).toHaveValue( '' ); + } ); + + test( 'AC-004: post title and newsletter title never change each other', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-004 original' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const postTitle = canvas.getByRole( 'textbox', { name: 'Add title' } ); + + // Editing the post title leaves an empty newsletter title empty. + await postTitle.fill( 'QA E2E overrides AC-004 edited post title' ); + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toHaveValue( '' ); + + // Editing the newsletter title leaves the post title alone. + await sidebar + .getByLabel( TITLE_LABEL ) + .fill( 'Separate inbox wording' ); + await expect( postTitle ).toHaveText( + 'QA E2E overrides AC-004 edited post title' + ); + + // And editing the post title again leaves a set newsletter title alone. + await postTitle.fill( 'QA E2E overrides AC-004 second edit' ); + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toHaveValue( + 'Separate inbox wording' + ); + + await saveDraft( page ); + expect( wpCli( `post get ${ id } --field=post_title` ) ).toBe( + 'QA E2E overrides AC-004 second edit' + ); + expect( getMeta( id, META_TITLE ) ).toBe( 'Separate inbox wording' ); + } ); + + test( 'AC-005: the newsletter title help text says it sets the email subject line', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-005' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + const field = sidebar.locator( '.beehiiv-newsletter-wording__title' ); + + await expect( field ).toContainText( /email subject line/i ); + await expect( field ).not.toContainText( /headline|web version/i ); + } ); +} ); + +test.describe( 'US-002: newsletter subtitle', () => { + test( 'AC-006: the newsletter subtitle field is in the beehiiv sidebar panel', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-006' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toBeVisible(); + } ); + + test( 'AC-007: the newsletter subtitle starts empty', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-007' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toHaveValue( '' ); + } ); + + test( 'AC-008: the newsletter subtitle is saved per post and shown again on reopen', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-008' ); + const otherId = createPost( 'QA E2E overrides AC-008 (other post)' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar + .getByLabel( SUBTITLE_LABEL ) + .fill( 'A subtitle for AC-008' ); + await saveDraft( page ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( 'A subtitle for AC-008' ); + + const reopened = await openSidebarFor( page, id ); + await expect( reopened.getByLabel( SUBTITLE_LABEL ) ).toHaveValue( + 'A subtitle for AC-008' + ); + + const other = await openSidebarFor( page, otherId ); + await expect( other.getByLabel( SUBTITLE_LABEL ) ).toHaveValue( '' ); + } ); + + test( 'AC-009: the newsletter subtitle help text says it is the beehiiv subtitle, not preview text', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-009' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + const field = sidebar.locator( + '.beehiiv-newsletter-wording__subtitle' + ); + + await expect( field ).toContainText( /beehiiv post subtitle/i ); + await expect( field ).not.toContainText( /preview text/i ); + } ); +} ); + +test.describe( 'US-003: empty fields are safe', () => { + test( 'AC-010 / AC-011: empty title and subtitle never block saving or sending', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-010-011' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + // Saving with both fields empty succeeds. + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toHaveValue( '' ); + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toHaveValue( '' ); + await page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'QA E2E overrides AC-010-011 saved' ); + await saveDraft( page ); + expect( wpCli( `post get ${ id } --field=post_title` ) ).toBe( + 'QA E2E overrides AC-010-011 saved' + ); + expect( getMeta( id, META_TITLE ) ).toBe( '' ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( '' ); + + // Sending with both fields empty succeeds (beehiiv create call mocked). + wpCli( `post update ${ id } --post_status=publish` ); + wpCli( + `eval '\\Beehiiv\\Newsletter\\Sender::send( ${ id } );' --user=1` + ); + expect( getMeta( id, '_beehiiv_newsletter_error' ) ).toBe( '' ); + expect( getMeta( id, META_POST_ID ) ).toBe( MOCK_BEEHIIV_POST_ID ); + } ); + + test( 'AC-012: empty fields display as empty, with help text saying the post title is used', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-012' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toHaveValue( '' ); + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toHaveValue( '' ); + await expect( + sidebar.locator( '.beehiiv-newsletter-wording__title' ) + ).toContainText( /post title/i ); + } ); + + test( 'AC-013 / AC-026: saved title, subtitle and display choice are exposed on the post for Newsletter Send', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-013-026' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar.getByLabel( TITLE_LABEL ).fill( 'Send-side title' ); + await sidebar.getByLabel( SUBTITLE_LABEL ).fill( 'Send-side subtitle' ); + await sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ).check(); + await saveDraft( page ); + + // Stored as post meta (what Newsletter Send reads server-side)... + expect( getMeta( id, META_TITLE ) ).toBe( 'Send-side title' ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( 'Send-side subtitle' ); + expect( getMeta( id, META_SHOW ) ).toBe( '1' ); + + // ...and readable through the REST post meta. + const meta = await page.evaluate( + async ( postId ) => + ( + await window.wp.apiFetch( { + path: `/wp/v2/posts/${ postId }?context=edit`, + } ) + ).meta, + id + ); + expect( meta[ META_TITLE ] ).toBe( 'Send-side title' ); + expect( meta[ META_SUBTITLE ] ).toBe( 'Send-side subtitle' ); + expect( meta[ META_SHOW ] ).toBe( true ); + } ); +} ); + +test.describe( 'US-004: only publishers edit them', () => { + test( 'AC-014: a user with publish rights (author role) can see and edit both fields', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-014', { + author: authorId, + } ); + await loginAs( page, AUTHOR_USERNAME, AUTHOR_PASSWORD ); + const sidebar = await openSidebarFor( page, id ); + + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toBeEnabled(); + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toBeEnabled(); + await sidebar.getByLabel( TITLE_LABEL ).fill( 'Author title' ); + await sidebar.getByLabel( SUBTITLE_LABEL ).fill( 'Author subtitle' ); + await saveDraft( page ); + + expect( getMeta( id, META_TITLE ) ).toBe( 'Author title' ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( 'Author subtitle' ); + } ); + + test( 'AC-015 / AC-024: a user without publish rights cannot change the title, subtitle or display choice through REST', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-015-024', { + author: contributorId, + meta: { + [ META_TITLE ]: 'Publisher title', + [ META_SUBTITLE ]: 'Publisher subtitle', + }, + } ); + await loginAs( page, CONTRIBUTOR_USERNAME, CONTRIBUTOR_PASSWORD ); + await openPostEditor( page, id ); + + for ( const patch of [ + { [ META_TITLE ]: 'Contributor title' }, + { [ META_SUBTITLE ]: 'Contributor subtitle' }, + { [ META_SHOW ]: true }, + ] ) { + const result = await restUpdateMeta( page, id, patch ); + expect( + result.ok, + `write of ${ Object.keys( patch )[ 0 ] } must be refused` + ).toBe( false ); + expect( result.status ).toBe( 403 ); + } + + expect( getMeta( id, META_TITLE ) ).toBe( 'Publisher title' ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( 'Publisher subtitle' ); + expect( [ '', '0' ] ).toContain( getMeta( id, META_SHOW ) ); + } ); + + test( 'AC-015 / AC-016: the beehiiv panel stays hidden for users without publish rights and shown for publishers', async ( { + page, + } ) => { + const contribPost = createPost( 'QA E2E overrides AC-016 contributor', { + author: contributorId, + } ); + await loginAs( page, CONTRIBUTOR_USERNAME, CONTRIBUTOR_PASSWORD ); + await openPostEditor( page, contribPost ); + await expect( + page.getByRole( 'button', { name: 'beehiiv', exact: true } ) + ).toHaveCount( 0 ); + await expect( page.getByLabel( TITLE_LABEL ) ).toHaveCount( 0 ); + + await page.context().clearCookies(); + const adminPost = createPost( 'QA E2E overrides AC-016 admin' ); + await loginAsAdmin( page ); + await openPostEditor( page, adminPost ); + await expect( + page.getByRole( 'button', { name: 'beehiiv', exact: true } ) + ).toBeVisible(); + } ); +} ); + +test.describe( 'US-005: edits reach an unsent beehiiv newsletter', () => { + test( 'AC-019 / AC-025: title, subtitle and display toggle stay editable while a linked newsletter is still scheduled', async ( { + page, + } ) => { + // A real send turns "Send to newsletter" back off once the post is linked. + const id = createPost( 'QA E2E overrides AC-019-025 scheduled', { + send: false, + meta: { + [ META_POST_ID ]: 'post_qa_linked_scheduled', + [ META_SCHEDULED_AT ]: isoFromNow( 7 * 24 * 60 * 60 * 1000 ), + }, + } ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + // The panel's other controls lock once linked... + await expect( + sidebar.getByRole( 'checkbox', { name: 'Send to newsletter' } ) + ).toBeDisabled(); + + // ...but the wording fields and display toggle do not. + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toBeEnabled(); + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toBeEnabled(); + await expect( + sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).toBeEnabled(); + + await sidebar.getByLabel( TITLE_LABEL ).fill( 'Scheduled edit title' ); + await sidebar + .getByLabel( SUBTITLE_LABEL ) + .fill( 'Scheduled edit subtitle' ); + await sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ).check(); + await saveDraft( page ); + + expect( getMeta( id, META_TITLE ) ).toBe( 'Scheduled edit title' ); + expect( getMeta( id, META_SUBTITLE ) ).toBe( + 'Scheduled edit subtitle' + ); + expect( getMeta( id, META_SHOW ) ).toBe( '1' ); + } ); + + test( 'Newsletter Send AC-049: the post template picker is shown locked once the newsletter is in beehiiv', async ( { + page, + } ) => { + const id = createPost( 'QA E2E template locked once linked', { + send: false, + meta: { + [ META_POST_ID ]: 'post_qa_linked_template', + [ META_SCHEDULED_AT ]: isoFromNow( 7 * 24 * 60 * 60 * 1000 ), + }, + } ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + // Sync never changes a newsletter's template, so it is visible but locked. + const template = sidebar.getByLabel( 'Post template' ); + await expect( template ).toBeVisible(); + await expect( template ).toBeDisabled(); + await expect( + sidebar.getByRole( 'button', { name: 'Refresh templates' } ) + ).toBeDisabled(); + await expect( + sidebar.getByText( + "The template can't be changed once the newsletter is in beehiiv." + ) + ).toBeVisible(); + } ); + + test( 'AC-018 / AC-019 / AC-025: title, subtitle and display toggle lock once beehiiv has published the newsletter', async ( { + page, + } ) => { + // Sent immediately: linked with no scheduled time. + const sentNow = createPost( 'QA E2E overrides AC-018 sent now', { + send: false, + meta: { + [ META_POST_ID ]: 'post_qa_linked_sent', + [ META_TITLE ]: 'Sent title', + }, + } ); + // Scheduled time already passed. + const sentLater = createPost( + 'QA E2E overrides AC-018 schedule passed', + { + send: false, + meta: { + [ META_POST_ID ]: 'post_qa_linked_past', + [ META_SCHEDULED_AT ]: isoFromNow( -60 * 60 * 1000 ), + }, + } + ); + await loginAsAdmin( page ); + + for ( const id of [ sentNow, sentLater ] ) { + const sidebar = await openSidebarFor( page, id ); + await expect( sidebar.getByLabel( TITLE_LABEL ) ).toBeDisabled(); + await expect( sidebar.getByLabel( SUBTITLE_LABEL ) ).toBeDisabled(); + await expect( + sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).toBeDisabled(); + } + expect( getMeta( sentNow, META_TITLE ) ).toBe( 'Sent title' ); + } ); +} ); + +test.describe( 'US-006: show or hide the title and subtitle in the email', () => { + test( 'AC-020 / AC-023: one "show title and subtitle in email" toggle covers both, with explanatory help text', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-020-023' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).toHaveCount( 1 ); + // No separate per-field show/hide controls. + await expect( + sidebar.getByRole( 'checkbox', { name: /show.*(title|subtitle)/i } ) + ).toHaveCount( 1 ); + await expect( + sidebar.locator( '.beehiiv-newsletter-wording__show-title' ) + ).toContainText( /title and subtitle.*email/i ); + } ); + + test( 'AC-021: the toggle is off for a post where it was never set (including pre-existing posts)', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-021' ); + // A post created "before the feature" has no row for the meta at all. + wpCliSafe( `post meta delete ${ id } ${ META_SHOW }` ); + expect( + wpCliSafe( `post meta get ${ id } ${ META_SHOW }` ) || '' + ).toBe( '' ); + + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).not.toBeChecked(); + const meta = await page.evaluate( + async ( postId ) => + ( + await window.wp.apiFetch( { + path: `/wp/v2/posts/${ postId }?context=edit`, + } ) + ).meta, + id + ); + expect( meta[ META_SHOW ] ).toBe( false ); + } ); + + test( 'AC-022: the display choice is saved per post and shown again on reopen', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-022' ); + const otherId = createPost( 'QA E2E overrides AC-022 (other post)' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ).check(); + await saveDraft( page ); + expect( getMeta( id, META_SHOW ) ).toBe( '1' ); + + const reopened = await openSidebarFor( page, id ); + await expect( + reopened.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).toBeChecked(); + + // Turning it back off also persists. + await reopened.getByRole( 'checkbox', { name: SHOW_LABEL } ).uncheck(); + await saveDraft( page ); + expect( [ '', '0' ] ).toContain( getMeta( id, META_SHOW ) ); + + const other = await openSidebarFor( page, otherId ); + await expect( + other.getByRole( 'checkbox', { name: SHOW_LABEL } ) + ).not.toBeChecked(); + } ); + + test( 'AC-024: a publisher (author role) can change the display choice', async ( { + page, + } ) => { + const id = createPost( 'QA E2E overrides AC-024 author', { + author: authorId, + } ); + await loginAs( page, AUTHOR_USERNAME, AUTHOR_PASSWORD ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar.getByRole( 'checkbox', { name: SHOW_LABEL } ).check(); + await saveDraft( page ); + expect( getMeta( id, META_SHOW ) ).toBe( '1' ); + } ); +} ); diff --git a/tests/e2e/specs/post-publication-selector.spec.js b/tests/e2e/specs/post-publication-selector.spec.js new file mode 100644 index 0000000..abc037f --- /dev/null +++ b/tests/e2e/specs/post-publication-selector.spec.js @@ -0,0 +1,1184 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin, loginAs } = require( '../utils/auth' ); +const { openPostEditor } = require( '../utils/editor' ); +const { + wpCli, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/06-editor-integration/8-post-level-newsletter-overrides/post-publication-selector.prd.md + * (PRD-06.8.02 Post-Level Publication Selector) + * + * Authored by qa-e2e-author from the PRD's Acceptance Criteria and the real + * code at the files listed in PLAN/EXECUTION (Create/Modify lists only). + * + * Seams (all test-only, tests/e2e/plugins/beehiiv-options.php, wp-env tests + * environment only): + * - beehiiv_e2e_seed_connection() + beehiiv_e2e_mock_permissions() make the + * site genuinely "connected with Send API" for Manager / Workspace. + * - beehiiv_e2e_seed_publications() / beehiiv_e2e_seed_post_templates() seed + * the real transient caches the editor config and template REST route read. + * - beehiiv_e2e_mock_http( '/publications/{id}/posts', ... ) answers the + * beehiiv create/update/delete post calls per publication, and + * beehiiv_e2e_start_http_log() records every outbound request (method + + * URL) so the spec can assert *which publication* each server-side action + * (create, move, update, cancel) actually targeted. + * + * Every editor interaction (publication/template pickers, send toggle, save, + * publish, unpublish) goes through the real block editor and its real REST + * save path; wp-cli is used only to arrange starting state and read results. + */ + +const PUB_A = { id: 'pub_qa_alpha', name: 'Alpha Daily' }; +const PUB_B = { id: 'pub_qa_beta', name: 'Beta Weekly' }; +const PUB_GONE = 'pub_qa_removed'; +const TEMPLATES = { + [ PUB_A.id ]: [ + { id: 'tpl_a1', name: 'Alpha Standard' }, + { id: 'tpl_a2', name: 'Alpha Promo' }, + ], + [ PUB_B.id ]: [ + { id: 'tpl_b1', name: 'Beta Classic' }, + { id: 'tpl_b2', name: 'Beta Digest' }, + ], +}; +const CREATED_ID = { + [ PUB_A.id ]: 'bh_alpha_new', + [ PUB_B.id ]: 'bh_beta_new', +}; + +const PUB_SELECT = '.beehiiv-newsletter-publication select'; +const TPL_SELECT = '.beehiiv-newsletter-template select'; +const CONTENT = + '

QA newsletter body.

'; + +const createdPostIds = []; + +/** + * Runs arbitrary PHP inside the tests-cli container (base64 avoids shell quoting issues). + * + * @param {string} code PHP to run, without the opening tag. + * @return {string} The command's output. + */ +function php( code ) { + const b64 = Buffer.from( code ).toString( 'base64' ); + return wpCli( `eval 'eval( base64_decode( "${ b64 }" ) );'` ); +} + +function phpJson( value ) { + return `json_decode( base64_decode( "${ Buffer.from( + JSON.stringify( value ) + ).toString( 'base64' ) }" ), true )`; +} + +/** + * Seeds connection, permissions, publications, templates, post-API mocks and settings in one call. + * + * @param {Object} [options] + * @param {Array<{id: string, name: string}>} [options.publications] Connected publications to seed. + * @param {Object} [options.settings] beehiiv_settings option value. + */ +function seedWorld( { + publications = [ PUB_A, PUB_B ], + settings = { publication_id: PUB_A.id, post_template_id: 'tpl_a1' }, +} = {} ) { + let code = ` + beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + beehiiv_e2e_seed_publications( ${ phpJson( publications ) } ); + update_option( "beehiiv_settings", ${ phpJson( settings ) } ); + `; + for ( const [ pubId, templates ] of Object.entries( TEMPLATES ) ) { + code += `beehiiv_e2e_seed_post_templates( "${ pubId }", ${ phpJson( + templates + ) } );`; + code += `beehiiv_e2e_mock_http( "/publications/${ pubId }/posts", [ "body" => [ "data" => [ "id" => "${ CREATED_ID[ pubId ] }" ] ] ] );`; + } + code += 'beehiiv_e2e_start_http_log();'; + php( code ); +} + +function setSettings( settings ) { + php( `update_option( "beehiiv_settings", ${ phpJson( settings ) } );` ); +} + +/** + * Creates a post (status/meta arranged server-side) and returns its ID. + * + * @param {Object} options + * @param {string} options.title Post title. + * @param {string} [options.status] Post status. + * @param {Object} [options.meta] Post meta to set after insert. + * @param {string|null} [options.date] Post date (also used as the GMT date). + * @return {number} The new post's ID. + */ +function createPost( { title, status = 'draft', meta = {}, date = null } ) { + const postArr = { + post_type: 'post', + post_title: title, + post_content: CONTENT, + post_status: status, + ...( date ? { post_date: date, post_date_gmt: date } : {} ), + }; + const out = php( ` + $id = wp_insert_post( wp_slash( ${ phpJson( postArr ) } ) ); + foreach ( ${ phpJson( + meta + ) } as $k => $v ) { update_post_meta( $id, $k, $v ); } + beehiiv_e2e_start_http_log(); + echo "ID=" . $id; + ` ); + const id = Number( /ID=(\d+)/.exec( out )[ 1 ] ); + createdPostIds.push( id ); + return id; +} + +function readMeta( postId ) { + const keys = [ + '_beehiiv_publication_id', + '_beehiiv_post_template_id', + '_beehiiv_post_id', + '_beehiiv_linked_publication_id', + '_beehiiv_linked_post_template_id', + '_beehiiv_newsletter_error', + '_beehiiv_newsletter_error_type', + '_beehiiv_send_to_newsletter', + ]; + const out = php( ` + $m = []; + foreach ( ${ phpJson( + keys + ) } as $k ) { $m[ $k ] = get_post_meta( ${ postId }, $k, true ); } + echo "JSON=" . wp_json_encode( $m ); + ` ); + return JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ); +} + +/** beehiiv post-API calls (create/update/delete) logged since the last log start. */ +function postApiCalls() { + const out = php( + 'echo "JSON=" . wp_json_encode( beehiiv_e2e_get_http_log() );' + ); + const log = JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ); + // A mocked request is logged twice: once by the request logger and once, + // with a `mock` key, by the mock that answered it. Count only the first. + return log + .filter( + ( entry ) => + ! ( 'mock' in entry ) && + /\/publications\/[^/]+\/posts(\/|$|\?)/.test( entry.url ) + ) + .map( ( entry ) => { + const match = /\/publications\/([^/]+)\/posts(?:\/([^/?]+))?/.exec( + entry.url + ); + return { + method: entry.method, + publication: decodeURIComponent( match[ 1 ] ), + beehiivPostId: match[ 2 ] || '', + }; + } ); +} + +function resetLog() { + php( 'beehiiv_e2e_start_http_log();' ); +} + +function isoInFuture( days ) { + return new Date( Date.now() + days * 86400 * 1000 ) + .toISOString() + .replace( /\.\d{3}Z$/, 'Z' ); +} + +/** + * Site-local (UTC in wp-env) "Y-m-d\TH:i:s" for the custom send date meta. + * + * @param {number} days Days from now. + * @return {string} Local datetime string. + */ +function localInFuture( days ) { + return isoInFuture( days ).replace( 'Z', '' ); +} + +/** + * Meta for a post already linked to a beehiiv post (scheduled unless scheduledAt is empty). + * + * @param {Object} options + * @param {string} options.pub Publication the beehiiv post lives in. + * @param {string} options.template Template it was created with. + * @param {string} options.beehiivPostId Linked beehiiv post ID. + * @param {number} [options.scheduledDays] Days until the send; 0 means already sent. + * @return {Object} Post meta. + */ +function linkedMeta( { pub, template, beehiivPostId, scheduledDays = 3 } ) { + const meta = { + _beehiiv_publication_id: pub, + _beehiiv_post_template_id: template, + _beehiiv_post_id: beehiivPostId, + _beehiiv_linked_publication_id: pub, + _beehiiv_linked_post_template_id: template, + _beehiiv_send_to_newsletter: '', + }; + if ( scheduledDays ) { + meta._beehiiv_scheduled_at = isoInFuture( scheduledDays ); + meta._beehiiv_send_to_newsletter_date = localInFuture( scheduledDays ); + } + return meta; +} + +async function openSidebar( page ) { + const content = page.locator( '.beehiiv-post-settings-content' ); + if ( await content.isVisible().catch( () => false ) ) { + return; + } + await page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first() + .click(); + await expect( content ).toBeVisible(); +} + +async function openEditorWithSidebar( page, postId ) { + await openPostEditor( page, postId ); + await openSidebar( page ); +} + +async function selectedText( locator ) { + return locator.evaluate( ( el ) => + el.selectedIndex >= 0 ? el.options[ el.selectedIndex ].text : '' + ); +} + +async function optionTexts( locator ) { + return locator.evaluate( ( el ) => + Array.from( el.options ).map( ( o ) => o.text ) + ); +} + +async function waitForTemplateOptions( page, expectedText ) { + await expect( page.locator( TPL_SELECT ) ).toBeVisible( { + timeout: 15000, + } ); + await expect( + page.locator( `${ TPL_SELECT } option`, { hasText: expectedText } ) + ).toHaveCount( 1, { timeout: 15000 } ); + await expect( page.locator( TPL_SELECT ) ).toBeEnabled(); +} + +/** + * Saves through the real editor save path (optionally editing post fields first). + * + * @param {import('@playwright/test').Page} page Editor page. + * @param {Object} [edits] Post fields to edit before saving. + */ +async function editorSave( page, edits = {} ) { + const ok = await page.evaluate( async ( postEdits ) => { + const { dispatch, select } = window.wp.data; + if ( Object.keys( postEdits ).length ) { + dispatch( 'core/editor' ).editPost( postEdits ); + } + await dispatch( 'core/editor' ).savePost(); + return select( 'core/editor' ).didPostSaveRequestSucceed(); + }, edits ); + expect( ok ).toBe( true ); +} + +test.describe.configure( { mode: 'serial' } ); + +test.describe( 'Post-level publication selector (PRD-06.8.02)', () => { + test.beforeAll( () => { + ensurePluginActive(); + ensurePrettyPermalinks(); + } ); + + test.beforeEach( async ( { page } ) => { + seedWorld(); + await loginAsAdmin( page ); + } ); + + test.afterAll( () => { + const ids = createdPostIds.join( ',' ); + php( ` + beehiiv_e2e_reset_all(); + foreach ( array_filter( explode( ",", "${ ids }" ) ) as $id ) { wp_delete_post( (int) $id, true ); } + $u = get_user_by( "login", "qa_pps_contrib" ); if ( $u ) { require_once ABSPATH . "wp-admin/includes/user.php"; wp_delete_user( $u->ID ); } + delete_option( "beehiiv_settings" ); + ` ); + } ); + + // US-001 --------------------------------------------------------------- + + test( 'AC-001: Publication selector sits directly above the Post template dropdown and lists every connected publication by name', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-001', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + await expect( + page + .locator( '.beehiiv-newsletter-publication' ) + .getByText( 'Publication', { exact: true } ) + ).toBeVisible(); + const texts = await optionTexts( pub ); + expect( texts ).toEqual( + expect.arrayContaining( [ PUB_A.name, PUB_B.name ] ) + ); + + await waitForTemplateOptions( page, 'Alpha Standard' ); + // Directly above: the template block is the publication block's next sibling. + const nextIsTemplate = await page + .locator( '.beehiiv-newsletter-publication' ) + .evaluate( + ( el ) => + !! el.nextElementSibling && + el.nextElementSibling.classList.contains( + 'beehiiv-newsletter-template' + ) + ); + expect( nextIsTemplate ).toBe( true ); + } ); + + test( 'AC-002: selector is shown when only one publication is connected', async ( { + page, + } ) => { + seedWorld( { publications: [ PUB_A ] } ); + const id = createPost( { + title: 'QA AC-002', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + expect( await optionTexts( pub ) ).toEqual( [ PUB_A.name ] ); + expect( await selectedText( pub ) ).toBe( PUB_A.name ); + } ); + + test( 'AC-003: a new post preselects the site-wide default publication', async ( { + page, + } ) => { + setSettings( { publication_id: PUB_B.id, post_template_id: 'tpl_b1' } ); + await page.goto( '/wp-admin/post-new.php' ); + await page.waitForSelector( 'iframe[name="editor-canvas"]', { + timeout: 20000, + } ); + const close = page + .locator( '.components-modal__header button' ) + .first(); + if ( + await close + .waitFor( { state: 'visible', timeout: 4000 } ) + .then( () => true ) + .catch( () => false ) + ) { + await close.click(); + } + await openSidebar( page ); + await page.getByLabel( 'Send to newsletter' ).check(); + + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + expect( await selectedText( pub ) ).toBe( PUB_B.name ); + await expect( pub ).toHaveValue( PUB_B.id ); + } ); + + test( 'AC-004: with no site default the selector has nothing preselected and the post cannot send; the settings notice appears only when no publication is connected', async ( { + page, + } ) => { + setSettings( { publication_id: '', post_template_id: '' } ); + const id = createPost( { + title: 'QA AC-004', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + await expect( pub ).toHaveValue( '' ); + expect( await optionTexts( pub ) ).toEqual( + expect.arrayContaining( [ PUB_A.name, PUB_B.name ] ) + ); + await expect( + page.getByText( 'Choose a publication for this post.' ) + ).toBeVisible(); + await expect( + page.getByText( 'Choose a publication in', { exact: false } ) + ).toHaveCount( 0 ); + + // Publishing without choosing must not create a beehiiv post anywhere. + resetLog(); + await editorSave( page, { status: 'publish' } ); + expect( postApiCalls().filter( ( c ) => c.method === 'POST' ) ).toEqual( + [] + ); + expect( readMeta( id )._beehiiv_post_id ).toBe( '' ); + + // No publication connected at all -> the settings notice. + seedWorld( { + publications: [], + settings: { publication_id: '', post_template_id: '' }, + } ); + const id2 = createPost( { + title: 'QA AC-004 none', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id2 ); + await expect( + page + .locator( '.beehiiv-post-settings-content' ) + .getByText( 'Choose a publication in', { exact: false } ) + ).toBeVisible(); + await expect( page.locator( PUB_SELECT ) ).toHaveCount( 0 ); + } ); + + test( 'AC-005: a post with no stored publication is treated as using the site default', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-005 legacy', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + php( `delete_post_meta( ${ id }, "_beehiiv_publication_id" );` ); + await openEditorWithSidebar( page, id ); + + const pub = page.locator( PUB_SELECT ); + expect( await selectedText( pub ) ).toBe( PUB_A.name ); + await waitForTemplateOptions( page, 'Alpha Standard (default)' ); + + resetLog(); + await editorSave( page, { status: 'publish' } ); + const creates = postApiCalls().filter( ( c ) => c.method === 'POST' ); + expect( creates.map( ( c ) => c.publication ) ).toEqual( [ PUB_A.id ] ); + expect( readMeta( id )._beehiiv_linked_publication_id ).toBe( + PUB_A.id + ); + } ); + + test( "AC-006: only users with publish rights can change a post's publication", async ( { + page, + browser, + } ) => { + const pass = 'qa-pps-' + Date.now(); + php( ` + $u = get_user_by( "login", "qa_pps_contrib" ); + if ( $u ) { wp_set_password( "${ pass }", $u->ID ); } else { wp_insert_user( [ "user_login" => "qa_pps_contrib", "user_pass" => "${ pass }", "user_email" => "qa_pps_contrib@example.test", "role" => "contributor" ] ); } + ` ); + const contribId = Number( + /ID=(\d+)/.exec( + php( + 'echo "ID=" . get_user_by( "login", "qa_pps_contrib" )->ID;' + ) + )[ 1 ] + ); + const id = createPost( { + title: 'QA AC-006', + meta: { _beehiiv_publication_id: PUB_A.id }, + } ); + php( + `wp_update_post( [ "ID" => ${ id }, "post_author" => ${ contribId } ] );` + ); + + // Contributor: no beehiiv sidebar, and a direct REST meta write is refused. + const ctx = await browser.newContext(); + const cpage = await ctx.newPage(); + await loginAs( cpage, 'qa_pps_contrib', pass ); + await openPostEditor( cpage, id ); + await expect( + cpage.getByRole( 'button', { name: 'beehiiv', exact: true } ) + ).toHaveCount( 0 ); + const contribResult = await cpage.evaluate( async ( postId ) => { + try { + await window.wp.apiFetch( { + path: `/wp/v2/posts/${ postId }`, + method: 'POST', + data: { meta: { _beehiiv_publication_id: 'pub_qa_beta' } }, + } ); + return 'ok'; + } catch ( e ) { + return e.code || 'error'; + } + }, id ); + await ctx.close(); + expect( contribResult ).not.toBe( 'ok' ); + expect( readMeta( id )._beehiiv_publication_id ).toBe( PUB_A.id ); + + // Admin (publish rights) can change it through the same REST path. + await openPostEditor( page, id ); + const adminResult = await page.evaluate( async ( postId ) => { + await window.wp.apiFetch( { + path: `/wp/v2/posts/${ postId }`, + method: 'POST', + data: { meta: { _beehiiv_publication_id: 'pub_qa_beta' } }, + } ); + return 'ok'; + }, id ); + expect( adminResult ).toBe( 'ok' ); + expect( readMeta( id )._beehiiv_publication_id ).toBe( PUB_B.id ); + } ); + + // US-002 --------------------------------------------------------------- + + test( "AC-007: Post template list shows only the chosen publication's templates", async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-007', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_B.id, + }, + } ); + await openEditorWithSidebar( page, id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + const texts = await optionTexts( page.locator( TPL_SELECT ) ); + expect( texts ).toEqual( + expect.arrayContaining( [ 'Beta Classic', 'Beta Digest' ] ) + ); + expect( texts.some( ( t ) => t.startsWith( 'Alpha' ) ) ).toBe( false ); + } ); + + test( 'AC-008: Refresh templates refreshes only the chosen publication', async ( { + page, + } ) => { + php( + `beehiiv_e2e_mock_http( "/publications/${ PUB_B.id }/post_templates", [ "body" => [ "data" => [ [ "id" => "tpl_b1", "name" => "Beta Classic" ], [ "id" => "tpl_b3", "name" => "Beta Fresh" ] ] ] ] );` + ); + const id = createPost( { + title: 'QA AC-008', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_B.id, + }, + } ); + await openEditorWithSidebar( page, id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + resetLog(); + + const refreshReq = page.waitForRequest( + ( r ) => + r.url().includes( 'post-templates' ) && + /refresh=1/.test( r.url() ) + ); + await page.getByRole( 'button', { name: 'Refresh templates' } ).click(); + const req = await refreshReq; + expect( decodeURIComponent( req.url() ) ).toContain( + `publication_id=${ PUB_B.id }` + ); + await waitForTemplateOptions( page, 'Beta Fresh' ); + + const outbound = JSON.parse( + ( ( o ) => o.slice( o.indexOf( 'JSON=' ) + 5 ) )( + php( + 'echo "JSON=" . wp_json_encode( beehiiv_e2e_get_http_log() );' + ) + ) + ); + const templateFetches = outbound.filter( ( e ) => + /post_templates/.test( e.url ) + ); + expect( templateFetches.length ).toBeGreaterThan( 0 ); + expect( + templateFetches.every( ( e ) => + e.url.includes( `/publications/${ PUB_B.id }/` ) + ) + ).toBe( true ); + // The other publication's cache was not touched. + const aCache = php( + `echo "JSON=" . wp_json_encode( \\Beehiiv\\API\\Cache::get_post_templates( "${ PUB_A.id }" ) );` + ); + expect( + JSON.parse( aCache.slice( aCache.indexOf( 'JSON=' ) + 5 ) ) + ).toEqual( TEMPLATES[ PUB_A.id ] ); + } ); + + test( "AC-009: changing the publication clears the template and loads the new publication's list", async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-009', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_A.id, + _beehiiv_post_template_id: 'tpl_a2', + }, + } ); + await openEditorWithSidebar( page, id ); + await waitForTemplateOptions( page, 'Alpha Promo' ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( 'tpl_a2' ); + + await page.locator( PUB_SELECT ).selectOption( PUB_B.id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( '' ); + const texts = await optionTexts( page.locator( TPL_SELECT ) ); + expect( texts.some( ( t ) => t.startsWith( 'Alpha' ) ) ).toBe( false ); + const editedTemplate = await page.evaluate( + () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'meta' )._beehiiv_post_template_id + ); + expect( editedTemplate ).toBe( '' ); + } ); + + test( 'AC-010: on the default publication the settings default template appears once as "Name (default)" and is preselected', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-010', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + await waitForTemplateOptions( page, 'Alpha Standard (default)' ); + const tpl = page.locator( TPL_SELECT ); + const texts = await optionTexts( tpl ); + expect( texts.filter( ( t ) => t.includes( '(default)' ) ) ).toEqual( [ + 'Alpha Standard (default)', + ] ); + expect( + texts.some( ( t ) => /Default \(from beehiiv settings\)/.test( t ) ) + ).toBe( false ); + expect( await selectedText( tpl ) ).toBe( 'Alpha Standard (default)' ); + } ); + + test( 'AC-011: on a non-default publication templates have plain names, none is preselected, and a pick is required', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-011', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_B.id, + }, + } ); + await openEditorWithSidebar( page, id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + const tpl = page.locator( TPL_SELECT ); + const texts = await optionTexts( tpl ); + expect( texts.some( ( t ) => t.includes( '(default)' ) ) ).toBe( + false + ); + await expect( tpl ).toHaveValue( '' ); + await expect( + page.getByText( 'Pick a post template for this post.' ) + ).toBeVisible(); + + // Publishing without a pick does not create a beehiiv post. + resetLog(); + await editorSave( page, { status: 'publish' } ); + expect( postApiCalls().filter( ( c ) => c.method === 'POST' ) ).toEqual( + [] + ); + expect( readMeta( id )._beehiiv_post_id ).toBe( '' ); + } ); + + // US-003 --------------------------------------------------------------- + + test( "AC-012: the newsletter is created in the post's publication and that publication is recorded", async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-012', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + await page.locator( PUB_SELECT ).selectOption( PUB_B.id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + await page.locator( TPL_SELECT ).selectOption( 'tpl_b1' ); + + resetLog(); + await editorSave( page, { status: 'publish' } ); + const creates = postApiCalls().filter( ( c ) => c.method === 'POST' ); + expect( creates.map( ( c ) => c.publication ) ).toEqual( [ PUB_B.id ] ); + const meta = readMeta( id ); + expect( meta._beehiiv_post_id ).toBe( CREATED_ID[ PUB_B.id ] ); + expect( meta._beehiiv_linked_publication_id ).toBe( PUB_B.id ); + expect( meta._beehiiv_publication_id ).toBe( PUB_B.id ); + expect( meta._beehiiv_linked_post_template_id ).toBe( 'tpl_b1' ); + } ); + + test( 'AC-013: later sync and cancel use the recorded publication even after the site default changes', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-013', + status: 'publish', + meta: linkedMeta( { + pub: PUB_A.id, + template: 'tpl_a1', + beehiivPostId: 'bh_alpha_old', + } ), + } ); + // Admin switches the site default after the post was scheduled. + setSettings( { publication_id: PUB_B.id, post_template_id: 'tpl_b1' } ); + resetLog(); + + await openEditorWithSidebar( page, id ); + await editorSave( page, { title: 'QA AC-013 edited' } ); + let calls = postApiCalls(); + expect( calls.length ).toBeGreaterThan( 0 ); + expect( calls.every( ( c ) => c.publication === PUB_A.id ) ).toBe( + true + ); + + resetLog(); + await editorSave( page, { status: 'draft' } ); + calls = postApiCalls(); + expect( calls.some( ( c ) => c.method === 'DELETE' ) ).toBe( true ); + expect( calls.every( ( c ) => c.publication === PUB_A.id ) ).toBe( + true + ); + } ); + + test( 'AC-014: a post linked to a scheduled, unsent newsletter keeps its publication read-only', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-014', + status: 'publish', + meta: linkedMeta( { + pub: PUB_A.id, + template: 'tpl_a1', + beehiivPostId: 'bh_alpha_old', + } ), + } ); + await openEditorWithSidebar( page, id ); + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + await expect( pub ).toBeDisabled(); + expect( await selectedText( pub ) ).toBe( PUB_A.name ); + + // Saving leaves the newsletter where it is: nothing is removed or recreated. + resetLog(); + await editorSave( page ); + expect( postApiCalls().filter( ( c ) => c.method !== 'GET' ) ).toEqual( + expect.not.arrayContaining( [ + expect.objectContaining( { method: 'DELETE' } ), + expect.objectContaining( { + method: 'POST', + beehiivPostId: '', + } ), + ] ) + ); + const meta = readMeta( id ); + expect( meta._beehiiv_post_id ).toBe( 'bh_alpha_old' ); + expect( meta._beehiiv_linked_publication_id ).toBe( PUB_A.id ); + } ); + + test( 'AC-015: after unpublishing cancels the beehiiv post, the selector is editable and a re-send uses the newly selected publication', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-015', + status: 'publish', + meta: linkedMeta( { + pub: PUB_A.id, + template: 'tpl_a1', + beehiivPostId: 'bh_alpha_old', + } ), + } ); + await openEditorWithSidebar( page, id ); + resetLog(); + await editorSave( page, { status: 'draft' } ); + expect( postApiCalls() ).toEqual( + expect.arrayContaining( [ + { + method: 'DELETE', + publication: PUB_A.id, + beehiivPostId: 'bh_alpha_old', + }, + ] ) + ); + expect( readMeta( id )._beehiiv_post_id ).toBe( '' ); + php( + `delete_post_meta( ${ id }, "_beehiiv_send_to_newsletter_date" );` + ); + + await openEditorWithSidebar( page, id ); + await expect( page.locator( PUB_SELECT ) ).toBeEnabled(); + await page.locator( PUB_SELECT ).selectOption( PUB_B.id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + await page.locator( TPL_SELECT ).selectOption( 'tpl_b1' ); + resetLog(); + await editorSave( page, { status: 'publish' } ); + const creates = postApiCalls().filter( ( c ) => c.method === 'POST' ); + expect( creates.map( ( c ) => c.publication ) ).toEqual( [ PUB_B.id ] ); + expect( readMeta( id )._beehiiv_linked_publication_id ).toBe( + PUB_B.id + ); + } ); + + // US-004 --------------------------------------------------------------- + + test( 'AC-016: once beehiiv has sent the newsletter, the selector is read-only and shows the publication name', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-016', + status: 'publish', + meta: linkedMeta( { + pub: PUB_B.id, + template: 'tpl_b1', + beehiivPostId: 'bh_beta_sent', + scheduledDays: 0, + } ), + } ); + await openEditorWithSidebar( page, id ); + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + await expect( pub ).toBeDisabled(); + expect( await selectedText( pub ) ).toBe( PUB_B.name ); + + // Server-side lock: a direct REST write of the publication is ignored. + await page.evaluate( async ( postId ) => { + await window.wp + .apiFetch( { + path: `/wp/v2/posts/${ postId }`, + method: 'POST', + data: { meta: { _beehiiv_publication_id: 'pub_qa_alpha' } }, + } ) + .catch( () => null ); + }, id ); + expect( readMeta( id )._beehiiv_publication_id ).toBe( PUB_B.id ); + } ); + + test( 'AC-017: the template dropdown locks together with the publication once the post is linked', async ( { + page, + } ) => { + const scheduled = createPost( { + title: 'QA AC-017 scheduled', + status: 'publish', + meta: linkedMeta( { + pub: PUB_A.id, + template: 'tpl_a1', + beehiivPostId: 'bh_alpha_sched', + } ), + } ); + await openEditorWithSidebar( page, scheduled ); + await expect( page.locator( TPL_SELECT ) ).toBeVisible( { + timeout: 15000, + } ); + await expect( page.locator( PUB_SELECT ) ).toBeDisabled(); + await expect( page.locator( TPL_SELECT ) ).toBeDisabled(); + + const sent = createPost( { + title: 'QA AC-017 sent', + status: 'publish', + meta: linkedMeta( { + pub: PUB_A.id, + template: 'tpl_a1', + beehiivPostId: 'bh_alpha_sent', + scheduledDays: 0, + } ), + } ); + await openEditorWithSidebar( page, sent ); + await expect( page.locator( TPL_SELECT ) ).toBeVisible( { + timeout: 15000, + } ); + await expect( page.locator( PUB_SELECT ) ).toBeDisabled(); + await expect( page.locator( TPL_SELECT ) ).toBeDisabled(); + } ); + + // US-005 --------------------------------------------------------------- + + test( 'AC-018: a post with a publication and its own template is ready even without a site default template', async ( { + page, + } ) => { + setSettings( { publication_id: PUB_A.id, post_template_id: '' } ); + + // Default publication, own template pick, no settings default template. + const id = createPost( { + title: 'QA AC-018 default', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + await expect( + page.getByText( 'Pick a post template for this post.' ) + ).toBeVisible(); + await waitForTemplateOptions( page, 'Alpha Promo' ); + await page.locator( TPL_SELECT ).selectOption( 'tpl_a2' ); + await expect( + page.getByText( 'Pick a post template for this post.' ) + ).toHaveCount( 0 ); + await expect( page.getByLabel( 'Send to newsletter' ) ).toBeEnabled(); + resetLog(); + await editorSave( page, { status: 'publish' } ); + expect( + postApiCalls() + .filter( ( c ) => c.method === 'POST' ) + .map( ( c ) => c.publication ) + ).toEqual( [ PUB_A.id ] ); + expect( readMeta( id )._beehiiv_linked_post_template_id ).toBe( + 'tpl_a2' + ); + + // Non-default publication with its own template. + const id2 = createPost( { + title: 'QA AC-018 other', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_B.id, + _beehiiv_post_template_id: 'tpl_b2', + }, + } ); + await openEditorWithSidebar( page, id2 ); + await waitForTemplateOptions( page, 'Beta Digest' ); + await expect( + page.getByText( 'Pick a post template for this post.' ) + ).toHaveCount( 0 ); + resetLog(); + await editorSave( page, { status: 'publish' } ); + expect( + postApiCalls() + .filter( ( c ) => c.method === 'POST' ) + .map( ( c ) => c.publication ) + ).toEqual( [ PUB_B.id ] ); + } ); + + test( 'AC-019: a disconnected stored publication falls back to the default publication with an error notice', async ( { + page, + } ) => { + const id = createPost( { + title: 'QA AC-019', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_publication_id: PUB_GONE, + }, + } ); + await openEditorWithSidebar( page, id ); + await expect( + page.getByText( + /no longer connected.*default publication "Alpha Daily"/ + ) + ).toBeVisible(); + + resetLog(); + await editorSave( page, { status: 'publish' } ); + expect( + postApiCalls() + .filter( ( c ) => c.method === 'POST' ) + .map( ( c ) => c.publication ) + ).toEqual( [ PUB_A.id ] ); + const meta = readMeta( id ); + expect( meta._beehiiv_linked_publication_id ).toBe( PUB_A.id ); + expect( meta._beehiiv_newsletter_error_type ).toBe( + 'publication_fallback' + ); + + await openEditorWithSidebar( page, id ); + const notice = page + .locator( '.beehiiv-post-settings-content' ) + .getByText( + /no longer connected, so this newsletter was sent to the default publication "Alpha Daily"/ + ); + await expect( notice ).toBeVisible(); + } ); + + // US-006 --------------------------------------------------------------- + + test( 'AC-020: settings publication field reads "Default publication" with preselect help text; stored settings shape unchanged', async ( { + page, + } ) => { + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + const select = page.locator( '#beehiiv_publication_id' ); + await expect( select ).toBeVisible(); + await expect( + page.locator( 'label[for="beehiiv_publication_id"]' ) + ).toHaveText( 'Default publication' ); + await expect( + select.locator( + 'xpath=following-sibling::p[contains(@class,"description")]' + ) + ).toContainText( 'Preselected on new posts' ); + await expect( select ).toHaveAttribute( + 'name', + 'beehiiv_settings[publication_id]' + ); + await expect( select ).toHaveValue( PUB_A.id ); + // Saving through the relabelled field still stores the same keys. + await page + .locator( '#submit, input[type="submit"][name="submit"]' ) + .first() + .click(); + await page.waitForLoadState( 'load' ); + const raw = php( + 'echo "JSON=" . wp_json_encode( get_option( "beehiiv_settings" ) );' + ); + const stored = JSON.parse( raw.slice( raw.indexOf( 'JSON=' ) + 5 ) ); + expect( stored.publication_id ).toBe( PUB_A.id ); + expect( stored.post_template_id ).toBe( 'tpl_a1' ); + expect( + Object.keys( stored ).some( ( k ) => /default/i.test( k ) ) + ).toBe( false ); + } ); + + test( 'AC-021: Refresh publications reloads the list from beehiiv bypassing the cache, keeps the selection, confirms, and the editor offers the refreshed list', async ( { + page, + } ) => { + const PUB_C = { id: 'pub_qa_gamma', name: 'Gamma Monthly' }; + // The cache is freshly seeded with A + B; beehiiv itself now returns A + B + C. + // Only the list call ("/publications?limit=...") is mocked, so per-publication post mocks are untouched. + php( + `beehiiv_e2e_mock_http( "/publications?", [ "body" => [ "data" => ${ phpJson( + [ PUB_A, PUB_B, PUB_C ] + ) } ] ] );` + ); + + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + const select = page.locator( '#beehiiv_publication_id' ); + await expect( select ).toBeVisible(); + await expect( + select.locator( 'option', { hasText: PUB_C.name } ) + ).toHaveCount( 0 ); + + // Change the (unsaved) selection to B so "keeps the current selection" is a real check, not the saved default. + await select.selectOption( PUB_B.id ); + resetLog(); + + const button = page.getByRole( 'button', { + name: 'Refresh publications', + } ); + await expect( button ).toBeVisible(); + const restResp = page.waitForResponse( + ( r ) => + /publications/.test( decodeURIComponent( r.url() ) ) && + /refresh=1/.test( r.url() ) && + r.request().method() === 'GET' + ); + await button.click(); + expect( ( await restResp ).status() ).toBe( 200 ); + + await expect( + page.getByText( 'Publications updated from beehiiv.' ) + ).toBeVisible(); + await expect( + select.locator( 'option', { hasText: PUB_C.name } ) + ).toHaveCount( 1 ); + await expect( select ).toHaveValue( PUB_B.id ); + await expect( select ).toBeEnabled(); + await expect( button ).toBeEnabled(); + + // The cache was bypassed: a real outbound list call to beehiiv happened despite a fresh cached list. + const out = php( + 'echo "JSON=" . wp_json_encode( beehiiv_e2e_get_http_log() );' + ); + const log = JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ); + expect( + log.some( + ( e ) => e.method === 'GET' && /\/publications\?/.test( e.url ) + ) + ).toBe( true ); + + // The post editor's Publication selector offers the refreshed list. + const id = createPost( { + title: 'QA AC-021', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + const pubSelect = page.locator( PUB_SELECT ); + await expect( pubSelect ).toBeVisible( { timeout: 15000 } ); + const texts = await optionTexts( pubSelect ); + expect( texts ).toEqual( + expect.arrayContaining( [ PUB_A.name, PUB_B.name, PUB_C.name ] ) + ); + } ); + + test( "AC-022: editor Refresh publications reloads the list from beehiiv bypassing the cache, keeps the post's choice, confirms, and is unavailable once sent", async ( { + page, + } ) => { + const PUB_C = { id: 'pub_qa_gamma', name: 'Gamma Monthly' }; + // Cache is freshly seeded with A + B; beehiiv itself now returns A + B + C (list call only). + php( + `beehiiv_e2e_mock_http( "/publications?", [ "body" => [ "data" => ${ phpJson( + [ PUB_A, PUB_B, PUB_C ] + ) } ] ] );` + ); + + const id = createPost( { + title: 'QA AC-022', + meta: { _beehiiv_send_to_newsletter: '1' }, + } ); + await openEditorWithSidebar( page, id ); + const box = page.locator( '.beehiiv-newsletter-publication' ); + const pub = page.locator( PUB_SELECT ); + await expect( pub ).toBeVisible(); + await expect( + pub.locator( 'option', { hasText: PUB_C.name } ) + ).toHaveCount( 0 ); + + // Make the post's current (unsaved) choice B, not the site default A. + await pub.selectOption( PUB_B.id ); + await waitForTemplateOptions( page, 'Beta Classic' ); + resetLog(); + + const button = box.getByRole( 'button', { + name: 'Refresh publications', + } ); + await expect( button ).toBeVisible(); + await expect( button ).toBeEnabled(); + const restResp = page.waitForResponse( + ( r ) => + /beehiiv\/v1\/publications/.test( + decodeURIComponent( r.url() ) + ) && + /refresh=1/.test( r.url() ) && + r.request().method() === 'GET' + ); + await button.click(); + expect( ( await restResp ).status() ).toBe( 200 ); + + await expect( + box.getByText( 'Publications updated from beehiiv.' ) + ).toBeVisible(); + await expect( + pub.locator( 'option', { hasText: PUB_C.name } ) + ).toHaveCount( 1 ); + await expect( pub ).toHaveValue( PUB_B.id ); + await expect( pub ).toBeEnabled(); + await expect( button ).toBeEnabled(); + const edited = await page.evaluate( + () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'meta' )._beehiiv_publication_id + ); + expect( edited ).toBe( PUB_B.id ); + + // The cache was bypassed: a real outbound beehiiv list call happened despite a fresh cached list. + const out = php( + 'echo "JSON=" . wp_json_encode( beehiiv_e2e_get_http_log() );' + ); + const log = JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ); + expect( + log.some( + ( e ) => e.method === 'GET' && /\/publications\?/.test( e.url ) + ) + ).toBe( true ); + + // Once beehiiv has sent the newsletter, the button is unavailable. + const sent = createPost( { + title: 'QA AC-022 sent', + status: 'publish', + meta: linkedMeta( { + pub: PUB_B.id, + template: 'tpl_b1', + beehiivPostId: 'bh_beta_sent22', + scheduledDays: 0, + } ), + } ); + await openEditorWithSidebar( page, sent ); + await expect( pub ).toBeDisabled(); + const sentButton = page + .locator( '.beehiiv-newsletter-publication' ) + .getByRole( 'button', { name: 'Refresh publications' } ); + await expect( sentButton ).toBeVisible(); + await expect( sentButton ).toBeDisabled(); + } ); +} ); diff --git a/tests/e2e/specs/publication-template-config.spec.js b/tests/e2e/specs/publication-template-config.spec.js new file mode 100644 index 0000000..65a7989 --- /dev/null +++ b/tests/e2e/specs/publication-template-config.spec.js @@ -0,0 +1,581 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/08-admin-interface/1-settings-page/publication-template-config.prd.md + * + * RE-RUN NOTE: a prior run of this spec found all 13 ACs browser-untestable. + * Every AC in this PRD describes behavior of the publication/template + * settings form (two `` fields' server-rendered markup. + * - `src/js/admin/settings/index.js` -- publication-change and + * refresh-button handlers, template dropdown population, notices. + * - `includes/REST/PostTemplatesController.php`, + * `includes/API/Resources/PostTemplates.php`, `includes/API/Cache.php` -- + * the REST route the client JS calls, and its cache-hit/refresh contract. + * - `includes/Admin/Options.php` `Options::sanitize()` -- save path + * (composes with the sibling `settings-validation-cache` PRD's own AC + * coverage of the same method; this file drives it through a real + * "Save settings" click rather than a raw `options.php` POST, since + * AC-012's wording is specifically about that click and every value this + * file saves is a normal seeded option, not a payload a ` is + // never server-rendered with a `disabled` attribute at all -- unlike + // the refresh button a few lines below it, which does use + // `disabled( '' === $publication_id )`. src/js/admin/settings/index.js + // only ever sets `templateSelect.disabled = true` transiently during + // an in-flight fetch (inside loadTemplates()), never on initial page + // load. This assertion follows the AC's literal wording and is + // expected to FAIL against the real DOM -- that failure is the + // evidence for a genuine PRD/implementation mismatch, not a flaky + // test. + await expect( select ).toBeDisabled(); + } ); + + test( 'AC-004: post template dropdown populates automatically when publication selection changes', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac004'; + seedPublications( [ { id: pubId, name: 'QA Publication AC004' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac004', name: 'QA Template AC004' }, + ] ); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + const pubSelect = page.locator( PUB_SELECT ); + const tplSelect = page.locator( TPL_SELECT ); + + await expect( tplSelect.locator( 'option' ) ).toHaveCount( 1 ); + + const [ response ] = await Promise.all( [ + page.waitForResponse( ( resp ) => + isPostTemplatesResponse( resp, pubId ) + ), + pubSelect.selectOption( pubId ), + ] ); + expect( response.status() ).toBe( 200 ); + + await expect( + tplSelect.locator( 'option', { hasText: 'QA Template AC004' } ) + ).toHaveCount( 1 ); + } ); + + test( 'AC-005: post template selection is optional; a "No default template" option is always available', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac005'; + seedPublications( [ { id: pubId, name: 'QA Publication AC005' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac005', name: 'QA Template AC005' }, + ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + + const tplSelect = page.locator( TPL_SELECT ); + expect( await tplSelect.getAttribute( 'required' ) ).toBeNull(); + + const firstOption = tplSelect.locator( 'option' ).first(); + await expect( firstOption ).toHaveAttribute( 'value', '' ); + await expect( firstOption ).toHaveText( /No default template/ ); + } ); + + test( 'AC-006: "Refresh templates" button is visible and enabled when a publication is selected', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac006'; + seedPublications( [ { id: pubId, name: 'QA Publication AC006' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac006', name: 'QA Template AC006' }, + ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + + const button = page.locator( REFRESH_BTN ); + await expect( button ).toBeVisible(); + await expect( button ).toBeEnabled(); + } ); + + test( 'AC-007: "Refresh templates" button is disabled when no publication is selected', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + const button = page.locator( REFRESH_BTN ); + await expect( button ).toBeVisible(); + await expect( button ).toBeDisabled(); + } ); + + test( 'AC-008: clicking "Refresh templates" fetches the latest template list from beehiiv without submitting the form', async ( { + page, + } ) => { + // Refresh always bypasses the cache (Cache::delete_post_templates() + // runs before re-fetching), so it reaches beehiiv's list endpoint. + // That call is mocked with a *different* list than the seeded cache, + // so the test never depends on live network latency. + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac008'; + seedPublications( [ { id: pubId, name: 'QA Publication AC008' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac008-old', name: 'QA Template AC008 Old' }, + ] ); + mockLiveTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac008-new', name: 'QA Template AC008 New' }, + ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + expect( readCachedTemplates( pubId ) ).not.toBeNull(); + + const urlBefore = page.url(); + + const [ response ] = await Promise.all( [ + page.waitForResponse( + ( resp ) => isPostTemplatesResponse( resp, 'refresh=1' ), + { timeout: 30000 } + ), + page.locator( REFRESH_BTN ).click(), + ] ); + expect( response.status() ).toBe( 200 ); + + // No form submission occurred -- still the same admin.php?page=beehiiv + // URL, no options.php redirect. + expect( page.url() ).toBe( urlBefore ); + + // PostTemplatesController::get_items() calls + // Cache::delete_post_templates() before re-fetching when `refresh=1` + // is sent. The cache now holds beehiiv's (mocked) new list instead of + // the old seeded one -- proving the refresh really executed a + // bypass-cache round trip rather than serving the cached list. + const cached = readCachedTemplates( pubId ); + expect( cached.map( ( item ) => item.id ) ).toEqual( [ + 'qa-e2e-tpl-ac008-new', + ] ); + await expect( + page.locator( '#beehiiv_post_template_id' ) + ).toContainText( 'QA Template AC008 New' ); + } ); + + test( 'AC-009: a "Templates updated" notice appears briefly after manual refresh', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac009'; + seedPublications( [ { id: pubId, name: 'QA Publication AC009' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac009', name: 'QA Template AC009' }, + ] ); + mockLiveTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac009', name: 'QA Template AC009' }, + ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + + // showRefreshNotice() runs on the success branch of loadTemplates()'s + // try block, reached once the REST call resolves; beehiiv's list call + // is mocked so the round trip is fast and deterministic. + await Promise.all( [ + page.waitForResponse( + ( resp ) => isPostTemplatesResponse( resp, 'refresh=1' ), + { timeout: 30000 } + ), + page.locator( REFRESH_BTN ).click(), + ] ); + + const notice = page.locator( '.beehiiv-refresh-notice' ); + await expect( notice ).toBeVisible(); + await expect( notice ).toHaveText( /Templates updated/ ); + } ); + + test( 'AC-010: "No templates available" notice appears and refresh button is shown when the selected publication has no templates', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac010'; + seedPublications( [ { id: pubId, name: 'QA Publication AC010' } ] ); + // Cached empty list -- a genuine cache hit (Cache::get_post_templates() + // returns [] rather than null), so no live call is made and the "no + // templates" branch is reached deterministically. + seedTemplates( pubId, [] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + + const notice = page.locator( '.beehiiv-no-templates-notice' ); + await expect( notice ).toBeVisible(); + await expect( notice ).toHaveText( /no post templates/i ); + + await expect( page.locator( REFRESH_BTN ) ).toBeVisible(); + } ); + + test( 'AC-011: previously selected publication and template values persist across a reload', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac011'; + const tplId = 'qa-e2e-tpl-ac011'; + seedPublications( [ { id: pubId, name: 'QA Publication AC011' } ] ); + seedTemplates( pubId, [ { id: tplId, name: 'QA Template AC011' } ] ); + setSettingsOption( pubId, tplId ); + + await page.goto( SETTINGS_PATH ); + await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId ); + + // A fresh navigation -- same as the admin returning in a later session. + await page.reload(); + await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId ); + } ); + + test( 'AC-012: form saves selections when the user clicks "Save settings"', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac012'; + const tplId = 'qa-e2e-tpl-ac012'; + seedPublications( [ { id: pubId, name: 'QA Publication AC012' } ] ); + seedTemplates( pubId, [ { id: tplId, name: 'QA Template AC012' } ] ); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + const [ templatesResponse ] = await Promise.all( [ + page.waitForResponse( ( resp ) => + isPostTemplatesResponse( resp, pubId ) + ), + page.locator( PUB_SELECT ).selectOption( pubId ), + ] ); + expect( templatesResponse.status() ).toBe( 200 ); + + await page.locator( TPL_SELECT ).selectOption( tplId ); + + // A real click on the Settings API's own submit_button() output -- + // exactly the interaction the AC describes -- not a raw + // options.php POST. + await page.locator( '#submit' ).click(); + await page.waitForURL( /page=beehiiv/ ); + + expect( readSettingsOption() ).toEqual( { + publication_id: pubId, + post_template_id: tplId, + } ); + } ); + + test( 'AC-013: new templates created in beehiiv appear when the user next loads the page after saving settings', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac013'; + seedPublications( [ { id: pubId, name: 'QA Publication AC013' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac013-old', name: 'QA Template AC013 Old' }, + ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + await expect( + page + .locator( TPL_SELECT ) + .locator( 'option', { hasText: 'QA Template AC013 Old' } ) + ).toHaveCount( 1 ); + + // beehiiv now has a new template: mock its list call so the page that + // loads after the save fetches it without a live, slow network call. + mockLiveTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac013-new', name: 'QA Template AC013 New' }, + ] ); + + // Save the form via a real click, same publication selected -- + // Options::sanitize() clears the per-publication template cache on + // every save where publication_id is non-empty (BR-004), regardless + // of whether the publication itself changed. The settings page that + // loads after the save then fetches the list from beehiiv again. + await page.locator( '#submit' ).click(); + await page.waitForURL( /page=beehiiv/ ); + + await expect( + page + .locator( TPL_SELECT ) + .locator( 'option', { hasText: 'QA Template AC013 New' } ) + ).toHaveCount( 1 ); + await expect( + page + .locator( TPL_SELECT ) + .locator( 'option', { hasText: 'QA Template AC013 Old' } ) + ).toHaveCount( 0 ); + + // The old cached list was cleared and replaced by beehiiv's current one. + const cached = readCachedTemplates( pubId ); + expect( cached.map( ( item ) => item.id ) ).toEqual( [ + 'qa-e2e-tpl-ac013-new', + ] ); + } ); +} ); diff --git a/tests/e2e/specs/secure-token-persistence.spec.js b/tests/e2e/specs/secure-token-persistence.spec.js new file mode 100644 index 0000000..e946b95 --- /dev/null +++ b/tests/e2e/specs/secure-token-persistence.spec.js @@ -0,0 +1,193 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/03-authentication/2-token-management/secure-token-persistence.prd.md + * (brownfield-mapped -- no PLAN/EXECUTION artifacts exist for this feature; + * the real files below were located by direct code search, not a plan/ + * execution trail. Verified by reading: includes/OAuth/TokenStore.php (unit + * under test), includes/Security/DataEncryption.php (AES-256-CTR using + * WordPress's own LOGGED_IN_KEY/LOGGED_IN_SALT), includes/Connection/Manager.php + * (get_connected_user_label(), consumed by AC-002's UI-display half), + * includes/Admin/Views/connection.php. + * + * TokenStore is a pure persistence/encryption class with no UI of its own + * beyond the connected-user display it feeds into the settings page's + * connection status card. Its real consumers (Authorization, CallbackHandler, + * AdminActions) all call it directly, so this spec exercises it the same + * way -- directly via wp-cli against the real running site -- plus one real + * page load for AC-002's UI-display half. + */ + +const OAUTH_OPTION = 'beehiiv_oauth'; +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; + +test.beforeAll( () => { + ensurePluginActive(); + + // Known clean baseline for every test in this file. This is wp-env's + // dedicated *tests* environment (never dev/production), so there is no + // real state worth preserving here. + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); +} ); + +test.afterAll( () => { + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); +} ); + +test.describe( 'Secure Token Persistence', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'AC-001: all credential fields are stored encrypted, not in plaintext', async () => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac1-client", [ "access_token" => "qa-e2e-ac1-plaintext-access", "refresh_token" => "qa-e2e-ac1-plaintext-refresh", "expires_in" => 3600 ] );\'' + ); + + const raw = wpCli( `option get ${ OAUTH_OPTION } --format=json` ); + expect( raw ).not.toContain( 'qa-e2e-ac1-plaintext-access' ); + expect( raw ).not.toContain( 'qa-e2e-ac1-plaintext-refresh' ); + expect( raw ).not.toContain( 'qa-e2e-ac1-client' ); + + // Sanity: the encrypted blob still round-trips to the real value via + // the class's own decrypt path -- proves this is genuine encryption, + // not just garbled/corrupted data that happens not to contain the + // substring. + const accessToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + expect( accessToken.trim() ).toBe( 'qa-e2e-ac1-plaintext-access' ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-002: connected user information is stored and retrievable for display in the admin UI', async ( { + page, + } ) => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac2-client", [ "access_token" => "qa-e2e-ac2-access", "refresh_token" => "qa-e2e-ac2-refresh", "expires_in" => 3600 ], [ "first_name" => "QA", "last_name" => "Tester", "email" => "qa-e2e-ac2@example.test" ] );\'' + ); + + const label = wpCli( + "eval 'echo \\Beehiiv\\Connection\\Manager::get_connected_user_label();'" + ); + expect( label.trim() ).toBe( 'QA Tester (qa-e2e-ac2@example.test)' ); + + await page.goto( SETTINGS_PATH ); + await expect( + page.locator( '.beehiiv-connection-status__account' ) + ).toContainText( 'QA Tester (qa-e2e-ac2@example.test)' ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-003: methods to retrieve tokens return decrypted values transparently to callers', async () => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac3-client", [ "access_token" => "qa-e2e-ac3-access", "refresh_token" => "qa-e2e-ac3-refresh", "expires_in" => 3600 ] );\'' + ); + + const clientId = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_client_id();'" + ); + const accessToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + const refreshToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_refresh_token();'" + ); + + expect( clientId.trim() ).toBe( 'qa-e2e-ac3-client' ); + expect( accessToken.trim() ).toBe( 'qa-e2e-ac3-access' ); + expect( refreshToken.trim() ).toBe( 'qa-e2e-ac3-refresh' ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-004: expiry metadata is stored and accessible to refresh logic for staleness checks', async () => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac4-client", [ "access_token" => "qa-e2e-ac4-access", "refresh_token" => "qa-e2e-ac4-refresh", "expires_in" => 1800 ] );\'' + ); + + const expiresAt = parseInt( + wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_expires_at();'" + ).trim(), + 10 + ); + const expectedExpiry = Math.floor( Date.now() / 1000 ) + 1800; + + expect( expiresAt ).toBeGreaterThan( expectedExpiry - 10 ); + expect( expiresAt ).toBeLessThanOrEqual( expectedExpiry ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-005: the system supports partial credential updates (e.g. refreshing only the access token) without requiring re-authorization', async () => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac5-client", [ "access_token" => "qa-e2e-ac5-original-access", "refresh_token" => "qa-e2e-ac5-original-refresh", "expires_in" => 3600 ] );\'' + ); + + // BR-002: a token response with NO refresh_token key must preserve + // the existing stored refresh token, not blank it out. + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac5-client", [ "access_token" => "qa-e2e-ac5-new-access", "expires_in" => 3600 ] );\'' + ); + + const accessToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_access_token();'" + ); + const refreshToken = wpCli( + "eval 'echo \\Beehiiv\\OAuth\\TokenStore::get_refresh_token();'" + ); + + expect( accessToken.trim() ).toBe( 'qa-e2e-ac5-new-access' ); + expect( refreshToken.trim() ).toBe( 'qa-e2e-ac5-original-refresh' ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-006: the system provides a way to verify whether valid credentials are available before attempting API calls', async () => { + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + const beforeAny = wpCli( + 'eval \'echo \\Beehiiv\\OAuth\\TokenStore::has_credentials() ? "yes" : "no";\'' + ); + expect( beforeAny.trim() ).toBe( 'no' ); + + // BR-001: client_id alone (no access_token) is still incomplete. + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_client_id( "qa-e2e-ac6-client" );\'' + ); + const clientOnly = wpCli( + 'eval \'echo \\Beehiiv\\OAuth\\TokenStore::has_credentials() ? "yes" : "no";\'' + ); + expect( clientOnly.trim() ).toBe( 'no' ); + + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac6-client", [ "access_token" => "qa-e2e-ac6-access", "refresh_token" => "qa-e2e-ac6-refresh", "expires_in" => 3600 ] );\'' + ); + const full = wpCli( + 'eval \'echo \\Beehiiv\\OAuth\\TokenStore::has_credentials() ? "yes" : "no";\'' + ); + expect( full.trim() ).toBe( 'yes' ); + + wpCliSafe( `option delete ${ OAUTH_OPTION }` ); + } ); + + test( 'AC-007: all credentials can be deleted in a single operation when the site disconnects', async () => { + wpCli( + 'eval \'\\Beehiiv\\OAuth\\TokenStore::save_tokens( "qa-e2e-ac7-client", [ "access_token" => "qa-e2e-ac7-access", "refresh_token" => "qa-e2e-ac7-refresh", "expires_in" => 3600 ], [ "first_name" => "QA" ] );\'' + ); + + wpCli( "eval '\\Beehiiv\\OAuth\\TokenStore::delete_all();'" ); + + const optionValue = wpCliSafe( `option get ${ OAUTH_OPTION }` ); + expect( optionValue ).toBeNull(); + + const hasCredentials = wpCli( + 'eval \'echo \\Beehiiv\\OAuth\\TokenStore::has_credentials() ? "yes" : "no";\'' + ); + expect( hasCredentials.trim() ).toBe( 'no' ); + } ); +} ); diff --git a/tests/e2e/specs/settings-page-assets.spec.js b/tests/e2e/specs/settings-page-assets.spec.js new file mode 100644 index 0000000..c29e323 --- /dev/null +++ b/tests/e2e/specs/settings-page-assets.spec.js @@ -0,0 +1,291 @@ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/08-admin-interface/4-admin-assets/settings-page-assets.prd.md + * + * Brownfield-mapped PRD (no PLAN/EXECUTION trail -- provenance: + * brownfield-mapped, so `execute-prd` never ran for this feature). File + * discovery was done by direct code search, not a PLAN/EXECUTION file list: + * - includes/Admin/Assets.php (enqueue logic -- `enqueue_admin_scripts()` + * unconditionally enqueues the global 'admin' style handle, then returns + * early unless `$hook_suffix === 'toplevel_page_' . Config::PLUGIN_SLUG` + * ('toplevel_page_beehiiv', confirmed by reading includes/Admin/Menu.php's + * add_menu_page() call and independently by curling the logged-in page + * source). Only when that hook matches does it enqueue BOTH + * HANDLE_ADMIN_SETTINGS ('beehiiv-admin-settings', build/admin-settings.css) + * via wp_enqueue_style() and HANDLE_ADMIN_SETTINGS_SCRIPT + * ('beehiiv-admin-settings', build/admin-settings.js) via + * wp_enqueue_script() + wp_set_script_translations($handle, 'beehiiv'). + * The style and script each independently guard on file_exists() for + * their own *.asset.php + built file before enqueueing -- there is no + * joint "both must exist" check despite the PRD's BR-002 phrasing; each + * asset degrades gracefully on its own. + * - build/admin-settings.asset.php (dependencies: wp-api-fetch, wp-i18n; + * version hash used as the wp_enqueue_style/script() cache-busting $ver) + * - src/js/admin/settings/index.js (the actual JS entry -- imports + * `@wordpress/api-fetch` and `@wordpress/i18n`'s `__`; looks up + * #beehiiv_publication_id / #beehiiv_post_template_id / the refresh + * button by ID and no-ops via `if (publicationSelect && templateSelect)` + * guards when they're absent, rather than throwing) + * - includes/Admin/Views/settings-page.php (the interactive form -- + * do_settings_sections(), which is where those IDs would render -- is + * gated behind `$beehiiv_can_write_posts`, itself gated behind + * `Manager::is_connected()` i.e. TokenStore::has_credentials(). This + * wp-env tests environment has no `beehiiv_oauth` option at all + * (confirmed via `wp option get beehiiv_oauth` erroring "does it + * exist?"), so is_connected() is false, Workspace::can_write_posts()'s + * live HTTP call is never even reached, and the form never renders here.) + * - languages/ (contains only beehiiv.pot -- no compiled .mo/.json + * translation catalog ships in this repo or this environment) + * + * AC-001/AC-004's file-presence edge cases are exercised by temporarily + * renaming build/admin-settings.css and build/admin-settings.js out of the + * way, same technique the admin-styling sibling spec used for the global + * bundle. Both handles are scoped to hook_suffix 'toplevel_page_beehiiv' + * only, so the blast radius is limited to the beehiiv settings page -- + * every other wp-admin page (including ones sibling QA agents' specs may + * exercise) is unaffected. Renames are wrapped in try/finally so files are + * restored even if an assertion throws, plus a defensive restore in + * beforeAll/afterAll in case a previous run crashed mid-test. + * + * AC-003 (translatable strings) is documented as browser-untestable below + * rather than given a hollow always-passing test -- see the reasoning next + * to its entry in the coverage report. + */ + +const PLUGIN_ROOT = path.resolve( __dirname, '../../..' ); +const SETTINGS_CSS = path.join( PLUGIN_ROOT, 'build/admin-settings.css' ); +const SETTINGS_CSS_BAK = path.join( + PLUGIN_ROOT, + 'build/admin-settings.css.qa-bak' +); +const SETTINGS_JS = path.join( PLUGIN_ROOT, 'build/admin-settings.js' ); +const SETTINGS_JS_BAK = path.join( + PLUGIN_ROOT, + 'build/admin-settings.js.qa-bak' +); +const SETTINGS_ASSET_PHP = path.join( + PLUGIN_ROOT, + 'build/admin-settings.asset.php' +); + +/** + * Reads the `version` value out of a webpack-generated `*.asset.php` file + * (a literal ` array(...), 'version' => + * '');`) without needing a PHP runtime -- the format is static/generated, + * never hand-authored. + * + * @param {string} assetPhpPath + */ +function readAssetVersion( assetPhpPath ) { + const contents = fs.readFileSync( assetPhpPath, 'utf8' ); + const match = contents.match( /'version'\s*=>\s*'([^']+)'/ ); + if ( ! match ) { + throw new Error( + `Could not find a version string in ${ assetPhpPath }` + ); + } + return match[ 1 ]; +} + +/** Restore both build files from their .qa-bak counterparts if a prior crashed run left them renamed. */ +function restoreIfBackedUp() { + if ( + fs.existsSync( SETTINGS_CSS_BAK ) && + ! fs.existsSync( SETTINGS_CSS ) + ) { + fs.renameSync( SETTINGS_CSS_BAK, SETTINGS_CSS ); + } + if ( fs.existsSync( SETTINGS_JS_BAK ) && ! fs.existsSync( SETTINGS_JS ) ) { + fs.renameSync( SETTINGS_JS_BAK, SETTINGS_JS ); + } +} + +test.beforeAll( () => { + ensurePluginActive(); + restoreIfBackedUp(); +} ); + +test.afterAll( () => { + restoreIfBackedUp(); +} ); + +test.describe( 'Settings Page Assets', () => { + test( "AC-001: settings page CSS and JS load only when viewing the plugin's main settings page", async ( { + page, + } ) => { + const expectedVersion = readAssetVersion( SETTINGS_ASSET_PHP ); + + await loginAsAdmin( page ); + + // The plugin's own settings page -- both assets present, cache-busted + // with the real build hash. + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + const cssHref = await page + .locator( 'link#beehiiv-admin-settings-css' ) + .getAttribute( 'href' ); + expect( cssHref ).toContain( 'build/admin-settings.css' ); + expect( cssHref ).toContain( `ver=${ expectedVersion }` ); + + const jsSrc = await page + .locator( 'script#beehiiv-admin-settings-js' ) + .getAttribute( 'src' ); + expect( jsSrc ).toContain( 'build/admin-settings.js' ); + expect( jsSrc ).toContain( `ver=${ expectedVersion }` ); + + // A core wp-admin screen with no relation to beehiiv at all. + await page.goto( '/wp-admin/edit.php' ); + await expect( + page.locator( 'link#beehiiv-admin-settings-css' ) + ).toHaveCount( 0 ); + await expect( + page.locator( 'script#beehiiv-admin-settings-js' ) + ).toHaveCount( 0 ); + + // The WP dashboard. + await page.goto( '/wp-admin/' ); + await expect( + page.locator( 'link#beehiiv-admin-settings-css' ) + ).toHaveCount( 0 ); + await expect( + page.locator( 'script#beehiiv-admin-settings-js' ) + ).toHaveCount( 0 ); + } ); + + test( 'AC-002: the settings script loads with its declared dependencies resolved and executes without errors', async ( { + page, + } ) => { + const consoleIssues = []; + page.on( 'console', ( msg ) => { + if ( msg.type() === 'error' || msg.type() === 'warning' ) { + consoleIssues.push( `[${ msg.type() }] ${ msg.text() }` ); + } + } ); + const pageErrors = []; + page.on( 'pageerror', ( err ) => pageErrors.push( err.message ) ); + + await loginAsAdmin( page ); + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + + // build/admin-settings.asset.php declares dependencies: wp-api-fetch, + // wp-i18n -- WordPress's dependency resolver must therefore also + // print those handles' own script tags on this page for + // admin-settings.js's `import apiFetch from '@wordpress/api-fetch'` + // and `import { __ } from '@wordpress/i18n'` to resolve at runtime. + await expect( page.locator( 'script#wp-api-fetch-js' ) ).toHaveCount( + 1 + ); + await expect( page.locator( 'script#wp-i18n-js' ) ).toHaveCount( 1 ); + await expect( + page.locator( 'script#beehiiv-admin-settings-js' ) + ).toHaveCount( 1 ); + + // The interactive publication/template dropdowns this script wires up + // are gated behind a live beehiiv connection (Workspace::can_write_posts() + // -- see file-header note) and are not present in this environment; + // the script's own top-level guards (`if (publicationSelect && + // templateSelect)`) must no-op cleanly rather than throwing when those + // elements are absent -- proving dependency resolution actually + // succeeded end to end (a missing/misordered dependency would surface + // as a ReferenceError/TypeError here, not silence). + await page.waitForLoadState( 'networkidle' ); + + expect( + consoleIssues, + `Console issues: ${ consoleIssues.join( '; ' ) }` + ).toHaveLength( 0 ); + expect( + pageErrors, + `Page errors: ${ pageErrors.join( '; ' ) }` + ).toHaveLength( 0 ); + } ); + + test( 'AC-004: missing settings CSS and JS build files are skipped silently -- no console errors and the page still renders', async ( { + page, + } ) => { + expect( fs.existsSync( SETTINGS_CSS ) ).toBe( true ); + expect( fs.existsSync( SETTINGS_JS ) ).toBe( true ); + + const consoleIssues = []; + page.on( 'console', ( msg ) => { + if ( msg.type() === 'error' || msg.type() === 'warning' ) { + consoleIssues.push( `[${ msg.type() }] ${ msg.text() }` ); + } + } ); + const pageErrors = []; + page.on( 'pageerror', ( err ) => pageErrors.push( err.message ) ); + + fs.renameSync( SETTINGS_CSS, SETTINGS_CSS_BAK ); + fs.renameSync( SETTINGS_JS, SETTINGS_JS_BAK ); + + try { + await loginAsAdmin( page ); + const response = await page.goto( + '/wp-admin/admin.php?page=beehiiv' + ); + + expect( response.status() ).toBe( 200 ); + // Page still renders normally -- the h1 is unconditional in + // settings-page.php regardless of connection state. + await expect( page.locator( 'h1' ) ).toHaveText( + 'beehiiv Settings' + ); + + // The tags for the missing bundles were never emitted -- proves + // the PHP-side file_exists() guards in enqueue_build_style() / + // enqueue_build_script() actually skipped enqueueing rather than + // emitting links/scripts to 404ing assets. + await expect( + page.locator( 'link#beehiiv-admin-settings-css' ) + ).toHaveCount( 0 ); + await expect( + page.locator( 'script#beehiiv-admin-settings-js' ) + ).toHaveCount( 0 ); + + expect( + consoleIssues, + `Console issues: ${ consoleIssues.join( '; ' ) }` + ).toHaveLength( 0 ); + expect( + pageErrors, + `Page errors: ${ pageErrors.join( '; ' ) }` + ).toHaveLength( 0 ); + } finally { + fs.renameSync( SETTINGS_CSS_BAK, SETTINGS_CSS ); + fs.renameSync( SETTINGS_JS_BAK, SETTINGS_JS ); + } + } ); + + // AC-003: User-facing text in settings page JavaScript is translatable. + // + // Deliberately not given a Playwright test() -- see + // includes/Admin/Assets.php's enqueue_build_script(), which calls + // wp_set_script_translations('beehiiv-admin-settings', 'beehiiv'). Reading + // wp-includes/l10n.php's print_translations() / _load_script_textdomain_ + // from_src() in the actual WP core running this environment confirms that + // call only produces an *observable* difference (a + // `wp.i18n.setLocaleData(...)` inline qa-e2e-pub-xss'; + + const response = await submitSettingsForm( page, nonceFields, { + 'beehiiv_settings[publication_id]': rawPublicationId, + 'beehiiv_settings[post_template_id]': '', + } ); + expect( response.ok() ).toBe( true ); + + const saved = readSettingsOption(); + expect( saved.publication_id ).not.toContain( '' ); + expect( saved.publication_id ).toContain( 'qa-e2e-pub-xss' ); + } ); + + test( 'AC-002: post template ID is sanitized as a text field before being saved', async ( { + page, + } ) => { + const nonceFields = await readSettingsFormNonceFields( page ); + const rawTemplateId = 'boldqa-e2e-tmpl-xss'; + + const response = await submitSettingsForm( page, nonceFields, { + 'beehiiv_settings[publication_id]': '', + 'beehiiv_settings[post_template_id]': rawTemplateId, + } ); + expect( response.ok() ).toBe( true ); + + const saved = readSettingsOption(); + expect( saved.post_template_id ).not.toContain( '' ); + expect( saved.post_template_id ).not.toContain( '' ); + expect( saved.post_template_id ).toContain( 'qa-e2e-tmpl-xss' ); + } ); + + test( 'AC-003: if input is not an array, the save is aborted and current settings are retained', async ( { + page, + } ) => { + // Establish a known-good baseline through a real, valid save first. + let nonceFields = await readSettingsFormNonceFields( page ); + await submitSettingsForm( page, nonceFields, { + 'beehiiv_settings[publication_id]': 'qa-e2e-pub-baseline', + 'beehiiv_settings[post_template_id]': 'qa-e2e-tmpl-baseline', + } ); + const baseline = readSettingsOption(); + expect( baseline ).toEqual( { + publication_id: 'qa-e2e-pub-baseline', + post_template_id: 'qa-e2e-tmpl-baseline', + } ); + + // Submit `beehiiv_settings` as a flat (non-bracketed) field, so PHP + // parses $_POST['beehiiv_settings'] as a plain string rather than an + // array -- Options::sanitize()'s `is_array( $input )` guard should + // reject it and return the current settings unchanged, instead of the + // `