diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..7898fda --- /dev/null +++ b/.eslintignore @@ -0,0 +1,30 @@ +# Build output +dist/ + +# Distributable archives +builds/ + +# Strauss-prefixed runtime deps +dependencies/ + +# Node modules +node_modules/ + +# WordPress environment +.wp-env/ + +# Composer vendor +vendor/ + +# Test artifacts (minified vendor JS / traces — not lintable) +playwright-report/ +test-results/ + +# tests/ holds Node lane-runner scripts (.mjs) + Playwright specs, not block +# source. lint:js is scoped to src/, so tests/ is never a release-gate target; +# ignoring it stops `lint-js --fix` from over-scanning and rewriting these files. +tests/ + +# Root build/tooling config — airo-wp-owned, not DSG block source. `lint-js +# --fix src/` otherwise silently reformats it on every dsg-sync. +webpack.config.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..039664c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,53 @@ +module.exports = { + extends: ['plugin:@wordpress/eslint-plugin/recommended'], + rules: { + 'import/no-extraneous-dependencies': 'off', + 'import/no-unresolved': 'off', + 'jsdoc/require-param-description': 'off', + // src/ is 100% DSG-owned and read-only (see CLAUDE.md): airo-wp never + // authors JS here, so lint must accept DSG's own conventions rather than + // error on source it cannot modify. The parent (DesignSetGo) lints this + // same source clean on an older, unpinned @wordpress/eslint-plugin where + // these rules were warnings; newer versions promote them to errors. + '@wordpress/no-unsafe-wp-apis': 'off', + '@wordpress/no-unused-vars-before-return': 'off', + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-description': 'off', + 'jsdoc/check-line-alignment': 'off', + 'no-nested-ternary': 'off', + 'jsdoc/no-undefined-types': [ + 'error', + { + definedTypes: [ + 'JSX', + 'Element', + 'HTMLElement', + 'HTMLImageElement', + 'IntersectionObserver', + 'NodeList', + 'KeyboardEvent', + 'Document', + ], + }, + ], + }, + overrides: [ + { + files: ['tests/**/*.js', '**/*.test.js', '**/*.spec.js'], + env: { + jest: true, + }, + rules: { + 'no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + 'jsx-a11y/label-has-associated-control': 'off', + }, + }, + ], +}; diff --git a/.github/scripts/stage-svn-payload.sh b/.github/scripts/stage-svn-payload.sh new file mode 100755 index 0000000..fa7abed --- /dev/null +++ b/.github/scripts/stage-svn-payload.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Stages the distributable plugin zip as a directory tree for SVN, and verifies +# the staged tree matches the archive exactly. +# +# The zip is the canonical artifact: it is built from package.json "files" by +# tests/e2e/setup/build-zip.mjs, which is the same artifact the pre-release +# workflow runs Plugin Check and the e2e matrix against. Staging by unzipping +# that archive — rather than assembling a second file list — is what makes the +# bytes published to WordPress.org identical to the bytes CI tested. +# +# Usage: stage-svn-payload.sh +# +# Writes payload= to $GITHUB_OUTPUT when set. Always prints a summary. + +set -euo pipefail + +ZIP="${1:?usage: stage-svn-payload.sh }" +STAGING="${2:?usage: stage-svn-payload.sh }" + +if [ ! -f "$ZIP" ]; then + echo "::error::Archive not found: ${ZIP}" >&2 + exit 1 +fi + +# The archive wraps everything in a single directory named after the plugin. +# Derive it rather than assume, so a change in archive layout fails loudly here +# instead of publishing a wrongly-nested tree to WordPress.org. +ROOTS=$(unzip -Z1 "$ZIP" | awk -F/ 'NF > 1 { print $1 }' | sort -u) +ROOT_COUNT=$(printf '%s\n' "$ROOTS" | grep -c . || true) + +if [ "$ROOT_COUNT" -ne 1 ]; then + echo "::error::Expected exactly one top-level directory in ${ZIP}, found ${ROOT_COUNT}: $(printf '%s ' $ROOTS)" >&2 + exit 1 +fi + +ROOT="$ROOTS" + +rm -rf "$STAGING" +mkdir -p "$STAGING" +unzip -q "$ZIP" -d "$STAGING" + +PAYLOAD="${STAGING}/${ROOT}" + +if [ ! -d "$PAYLOAD" ]; then + echo "::error::Staging did not produce ${PAYLOAD}" >&2 + exit 1 +fi + +# Compare the archive's file list against what landed on disk. unzip guarantees +# per-file byte fidelity, so the thing worth asserting is that the staged tree +# has neither gained nor lost entries. +archive_manifest=$(unzip -Z1 "$ZIP" | grep -v '/$' | sort) +staged_manifest=$(cd "$STAGING" && find . -type f | sed 's|^\./||' | sort) + +if [ "$archive_manifest" != "$staged_manifest" ]; then + echo "::error::Staged tree does not match ${ZIP}" >&2 + diff <(printf '%s\n' "$archive_manifest") <(printf '%s\n' "$staged_manifest") >&2 || true + exit 1 +fi + +file_count=$(printf '%s\n' "$staged_manifest" | wc -l | tr -d ' ') +byte_size=$(du -sk "$PAYLOAD" | awk '{print $1}') + +echo "Payload staged from $(basename "$ZIP")" +echo " root: ${PAYLOAD}" +echo " files: ${file_count}" +echo " size: ${byte_size} KiB" +echo " top-level entries:" +( cd "$PAYLOAD" && ls -A1 | sed 's/^/ /' ) + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "payload=${PAYLOAD}" >> "$GITHUB_OUTPUT" +fi diff --git a/.github/scripts/svn-import-tag.sh b/.github/scripts/svn-import-tag.sh new file mode 100755 index 0000000..62ffec3 --- /dev/null +++ b/.github/scripts/svn-import-tag.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Publishes a pre-release to WordPress.org as a tag only, leaving trunk alone. +# +# trunk/readme.txt's "Stable tag" is what WordPress.org feeds to the auto-update +# channel. A pre-release must not move it, so this never checks out or writes +# trunk: it imports the payload straight into tags/ in a single commit. +# That is why the stable lane's deploy action cannot be reused here — it always +# rsyncs into trunk first. +# +# Usage: svn-import-tag.sh +# dry-run: literal "true" or "false" +# +# Env: SVN_USERNAME, SVN_PASSWORD (required only when dry-run is false) + +set -euo pipefail + +PAYLOAD="${1:?usage: svn-import-tag.sh }" +SVN_ROOT="${2:?missing svn-root}" +SLUG="${3:?missing slug}" +VERSION="${4:?missing version}" +DRY_RUN="${5:?missing dry-run flag}" + +TAG_URL="${SVN_ROOT}/${SLUG}/tags/${VERSION}" +TRUNK_URL="${SVN_ROOT}/${SLUG}/trunk" + +if [ ! -d "$PAYLOAD" ]; then + echo "::error::Payload directory not found: ${PAYLOAD}" >&2 + exit 1 +fi + +file_count=$(find "$PAYLOAD" -type f | wc -l | tr -d ' ') + +if [ "$file_count" -eq 0 ]; then + echo "::error::Payload directory is empty: ${PAYLOAD}" >&2 + exit 1 +fi + +if ! command -v svn >/dev/null 2>&1; then + echo "::error::svn is not installed. Subversion is not preinstalled on GitHub-hosted runners; install it before calling this script." >&2 + exit 1 +fi + +# Fingerprint trunk so we can prove afterwards that it was untouched. Uses +# `svn cat` rather than an HTTP fetch so the same code path works against a +# file:// repository in tests. An absent readme is a legitimate state before the +# first stable release, and must fingerprint stably rather than error. +# sha256sum is coreutils (Linux runners); shasum is Perl (macOS). Pick whichever +# exists so this is runnable both on the runner and locally. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 | awk '{print $1}'; } +else + echo "::error::Neither sha256sum nor shasum is available; cannot fingerprint trunk." >&2 + exit 1 +fi + +fingerprint_trunk() { + if out=$(svn cat "${TRUNK_URL}/readme.txt" --non-interactive 2>/dev/null); then + printf '%s' "$out" | sha256 + else + printf 'absent' + fi +} + +echo "Payload: ${PAYLOAD} (${file_count} files)" +echo "Tag URL: ${TAG_URL}" +echo "Trunk: ${TRUNK_URL} (never written by this lane)" + +before=$(fingerprint_trunk) +echo "Trunk fingerprint before: ${before}" + +if [ "$DRY_RUN" != "false" ]; then + echo "Dry run: no import performed. Would run:" + echo " svn import '${PAYLOAD}' '${TAG_URL}' -m 'Release ${VERSION} (pre-release)'" + exit 0 +fi + +: "${SVN_USERNAME:?SVN_USERNAME must be set for a real import}" +: "${SVN_PASSWORD:?SVN_PASSWORD must be set for a real import}" + +echo "Importing ${file_count} files into ${TAG_URL}" + +svn import "$PAYLOAD" "$TAG_URL" \ + -m "Release ${VERSION} (pre-release)" \ + --no-auth-cache \ + --non-interactive \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" + +# Post-conditions: the tag exists, and trunk is byte-identical to before. +if ! svn ls "$TAG_URL" --non-interactive >/dev/null 2>&1; then + echo "::error::Import reported success but ${TAG_URL} is not readable." >&2 + exit 1 +fi + +after=$(fingerprint_trunk) +echo "Trunk fingerprint after: ${after}" + +if [ "$before" != "$after" ]; then + echo "::error::trunk changed during a pre-release publish (${before} -> ${after}). A pre-release must never modify trunk, because trunk's Stable tag drives the auto-update channel." >&2 + exit 1 +fi + +echo "Imported ${VERSION}; trunk unchanged." diff --git a/.github/scripts/svn-sync-assets.sh b/.github/scripts/svn-sync-assets.sh new file mode 100755 index 0000000..2d8fb8a --- /dev/null +++ b/.github/scripts/svn-sync-assets.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# Syncs plugin-directory artwork (banner, icon, screenshots) to WordPress.org's +# SVN assets/ directory. Nothing else is touched. +# +# 10up/action-wordpress-plugin-asset-update is deliberately not used: it has no +# dry-run mode, and it always writes trunk. Even its most conservative setting +# (IGNORE_OTHER_FILES=true) copies readme.txt into trunk, which can move +# "Stable tag" and therefore what the auto-update channel serves; by default it +# rsyncs the whole working tree into trunk, which for this plugin would publish +# unbuilt source. The image mime-type handling below is borrowed from it. +# +# trunk is never fetched: the working copy is checked out at --depth empty and +# only assets/ is populated, so "trunk untouched" is structural rather than +# merely asserted. It is still fingerprinted before and after as a backstop. +# +# Usage: svn-sync-assets.sh +# dry-run: literal "true" or "false" +# +# Env: SVN_USERNAME, SVN_PASSWORD (required only when dry-run is false) + +set -euo pipefail + +ASSETS_SRC="${1:?usage: svn-sync-assets.sh }" +SVN_ROOT="${2:?missing svn-root}" +SLUG="${3:?missing slug}" +DRY_RUN="${4:?missing dry-run flag}" + +PLUGIN_URL="${SVN_ROOT}/${SLUG}" +TRUNK_URL="${PLUGIN_URL}/trunk" + +if [ ! -d "$ASSETS_SRC" ]; then + echo "::error::Assets directory not found: ${ASSETS_SRC}" >&2 + exit 1 +fi + +asset_count=$(find "$ASSETS_SRC" -type f | wc -l | tr -d ' ') + +if [ "$asset_count" -eq 0 ]; then + echo "::error::Assets directory is empty: ${ASSETS_SRC}" >&2 + exit 1 +fi + +if ! command -v svn >/dev/null 2>&1; then + echo "::error::svn is not installed. Subversion is not preinstalled on GitHub-hosted runners." >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 | awk '{print $1}'; } +else + echo "::error::Neither sha256sum nor shasum is available." >&2 + exit 1 +fi + +fingerprint_trunk() { + if out=$(svn cat "${TRUNK_URL}/readme.txt" --non-interactive 2>/dev/null); then + printf '%s' "$out" | sha256 + else + printf 'absent' + fi +} + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +echo "Assets: ${ASSETS_SRC} (${asset_count} files)" +echo "Target: ${PLUGIN_URL}/assets" + +before=$(fingerprint_trunk) +echo "Trunk fingerprint before: ${before}" + +# --depth empty then populating only assets/ means trunk never enters the +# working copy, so it cannot be modified even by accident. +svn checkout --depth empty --non-interactive "$PLUGIN_URL" "$WORK/svn" >/dev/null +svn update --set-depth infinity --non-interactive "$WORK/svn/assets" >/dev/null + +if [ -d "$WORK/svn/trunk" ]; then + echo "::error::trunk was fetched into the working copy; refusing to continue." >&2 + exit 1 +fi + +rsync -rc --delete "${ASSETS_SRC}/" "$WORK/svn/assets/" + +cd "$WORK/svn" + +svn add "assets" --force --non-interactive > /dev/null +# Stage deletions for anything rsync removed. +svn status assets | awk '/^!/ {print $2}' | while read -r gone; do + svn delete --force --non-interactive "$gone" > /dev/null +done + +# Screenshots otherwise force-download instead of displaying in the browser. +for ext in png:image/png jpg:image/jpeg gif:image/gif svg:image/svg+xml; do + glob="${ext%%:*}" + mime="${ext##*:}" + if find assets -maxdepth 1 -name "*.${glob}" -print -quit | grep -q .; then + svn propset svn:mime-type "$mime" assets/*."${glob}" >/dev/null || true + fi +done + +echo "Pending changes:" +svn status assets | sed 's/^/ /' + +if [ -z "$(svn status assets)" ]; then + echo "No asset changes to publish." + exit 0 +fi + +if [ "$DRY_RUN" != "false" ]; then + echo "Dry run: nothing committed." + exit 0 +fi + +: "${SVN_USERNAME:?SVN_USERNAME must be set for a real sync}" +: "${SVN_PASSWORD:?SVN_PASSWORD must be set for a real sync}" + +svn commit assets \ + -m "Update plugin directory assets" \ + --no-auth-cache \ + --non-interactive \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" + +after=$(fingerprint_trunk) +echo "Trunk fingerprint after: ${after}" + +if [ "$before" != "$after" ]; then + echo "::error::trunk changed during an assets-only sync (${before} -> ${after})." >&2 + exit 1 +fi + +echo "Assets synced; trunk unchanged." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ac47b0..e8c2074 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,7 @@ jobs: node-version: '20' cache: npm - run: npm ci - - name: Start WordPress environment - run: npm run wp-env:start - - name: Run PHPCS - run: npm run lint - - name: Stop WordPress environment - if: always() - run: npm run wp-env:stop + - run: npm run lint:php unit: name: PHPUnit @@ -42,13 +36,7 @@ jobs: node-version: '20' cache: npm - run: npm ci - - name: Start WordPress environment - run: npm run wp-env:start - - name: Run PHPUnit - run: npm run test:unit - - name: Stop WordPress environment - if: always() - run: npm run wp-env:stop + - run: npm run test:unit:php check-plugin: name: Plugin Check (PCP) diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index c1606b8..dd6d614 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -1,35 +1,42 @@ name: Pre-release -# Full WP version matrix sweep. PHP fixed at 8.3 (set in .wp-env.json). -# Triggers automatically on release/* PRs to main; also runnable manually. +# Full compatibility sweep. Run manually before cutting a release, or automatically +# on release/* PRs to main. +# +# Job graph: +# lint (PHP 8.3 × WP 7.1) ─┐ +# lint-js ├─ (parallel, independent) +# unit (PHP 8.3 × WP 7.1) ┘ +# build ──► test-e2e matrix (8 PHP × WP combinations) +# └──► check-plugin (PHP 8.3 × WP 7.1) +# +# E2E matrix: +# Current WP 7.1 × PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 +# Previous WP 7.0 × PHP 7.4 +# Minimum WP 6.9 × PHP 7.4 (matches "Requires at least" in readme.txt) +# Nightly WP × PHP 8.4 on: workflow_dispatch: - inputs: - wp_version: - description: 'WP version to test, or "all"' - default: 'all' pull_request: branches: [main] jobs: - matrix-setup: - name: Build matrix + lint: + name: PHPCS (PHP 8.3 × WP 7.1) runs-on: ubuntu-24.04 if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' - outputs: - wp: ${{ steps.set.outputs.wp }} steps: - - id: set - run: | - if [ -z "${{ inputs.wp_version }}" ] || [ "${{ inputs.wp_version }}" = "all" ]; then - echo 'wp=["6.8","6.9","7.0"]' >> $GITHUB_OUTPUT - else - echo 'wp=["${{ inputs.wp_version }}"]' >> $GITHUB_OUTPUT - fi + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run lint:php - lint: - name: PHPCS + lint-js: + name: JS/SCSS lint runs-on: ubuntu-24.04 if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: @@ -39,14 +46,11 @@ jobs: node-version: '20' cache: npm - run: npm ci - - run: npm run wp-env:start - - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress - - run: npm run lint - - if: always() - run: npm run wp-env:stop + - run: npm run lint:js + - run: npm run lint:style unit: - name: PHPUnit + name: PHPUnit (PHP 8.3 × WP 7.1) runs-on: ubuntu-24.04 if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: @@ -56,20 +60,45 @@ jobs: node-version: '20' cache: npm - run: npm ci - - run: npm run wp-env:start - - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress - - run: npm run test:unit - - if: always() - run: npm run wp-env:stop + - run: npm run test:unit:php + + build: + name: Build test zip + runs-on: ubuntu-24.04 + if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Build distributable zip + run: npm run build:zip + - name: Upload test zip artifact + uses: actions/upload-artifact@v4 + with: + name: airo-wp-zip + path: builds/airo-wp.zip + retention-days: 1 test-e2e: - name: E2E (WP ${{ matrix.wp }}) - needs: matrix-setup + name: E2E (PHP ${{ matrix.php }} × WP ${{ matrix.wp }}) + needs: build runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - wp: ${{ fromJson(needs.matrix-setup.outputs.wp) }} + include: + - { php: "7.4", wp: "7.1" } + - { php: "8.0", wp: "7.1" } + - { php: "8.1", wp: "7.1" } + - { php: "8.2", wp: "7.1" } + - { php: "8.3", wp: "7.1" } + - { php: "8.4", wp: "7.1" } + - { php: "7.4", wp: "7.0" } + - { php: "7.4", wp: "6.9" } + - { php: "8.4", wp: "nightly" } steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -77,37 +106,40 @@ jobs: node-version: '20' cache: npm - run: npm ci - - name: Pin WP version in .wp-env.json + - name: Download test zip artifact + uses: actions/download-artifact@v4 + with: + name: airo-wp-zip + path: builds/ + - name: Configure .wp-env.json (PHP, WP version, built zip) + env: + WP_CORE: ${{ matrix.wp == 'nightly' && 'https://wordpress.org/nightly-builds/wordpress-latest.zip' || format('https://wordpress.org/wordpress-{0}.zip', matrix.wp) }} run: | node -e " const fs = require('fs'); - const cfg = JSON.parse(fs.readFileSync('.wp-env.json','utf8')); - cfg.core = 'https://wordpress.org/wordpress-${{ matrix.wp }}.zip'; + const cfg = JSON.parse(fs.readFileSync('.wp-env.json', 'utf8')); + cfg.phpVersion = '${{ matrix.php }}'; + cfg.core = process.env.WP_CORE; + cfg.plugins[0] = './builds/airo-wp.zip'; fs.writeFileSync('.wp-env.json', JSON.stringify(cfg, null, 2)); " - - run: npm run build - run: npm run wp-env:start - - run: npx playwright install --with-deps chromium - run: npm run test:e2e - if: always() run: npm run wp-env:stop - if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-wp${{ matrix.wp }} + name: playwright-report-php${{ matrix.php }}-wp${{ matrix.wp }} path: | playwright-report/ test-results/ retention-days: 7 check-plugin: - name: Plugin Check (WP ${{ matrix.wp }}) - needs: matrix-setup + name: Plugin Check (PHP 8.3 × WP 7.1) + needs: build runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - wp: ${{ fromJson(needs.matrix-setup.outputs.wp) }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -115,23 +147,15 @@ jobs: node-version: '20' cache: npm - run: npm ci - - name: Pin WP version in .wp-env.json - run: | - node -e " - const fs = require('fs'); - const cfg = JSON.parse(fs.readFileSync('.wp-env.json','utf8')); - cfg.core = 'https://wordpress.org/wordpress-${{ matrix.wp }}.zip'; - fs.writeFileSync('.wp-env.json', JSON.stringify(cfg, null, 2)); - " - - run: npm run wp-env:start - - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-dev --no-interaction --no-progress - - run: npm run build:zip + - name: Download test zip artifact + uses: actions/download-artifact@v4 + with: + name: airo-wp-zip + path: builds/ - run: npm run plugin-check - - if: always() - run: npm run wp-env:stop - if: failure() uses: actions/upload-artifact@v4 with: - name: plugin-check-wp${{ matrix.wp }} + name: plugin-check-results path: builds/plugin-check-results.txt retention-days: 7 diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml index cca9abb..415b980 100644 --- a/.github/workflows/publish-plugin.yml +++ b/.github/workflows/publish-plugin.yml @@ -1,14 +1,394 @@ name: Publish Plugin +# Ships a reviewed release/* branch to the plugin's WordPress.org SVN repository. +# +# This workflow is authored in the private repo at .github/public/workflows/ and +# mirrored here by release-mirror.yml. It is not dispatchable from the private +# repo: GitHub only recognises workflows under .github/workflows/. +# +# Scope: shared scaffolding only — guards, payload staging and dry-run +# reporting. It performs NO SVN write. The stable lane (trunk + tag) and the +# pre-release lane (tag only) land separately, as does asset sync. + on: workflow_dispatch: - push: - tags: ['v[0-9]+.[0-9]+.[0-9]+'] + inputs: + version: + description: 'Version to publish — defaults to package.json. x.y.z, or a pre-release x.y.z-beta1 / x.y.z-rc1' + required: false + type: string + dry_run: + description: 'Report what would be published and make no SVN change' + required: false + default: true + type: boolean + sync_assets: + description: 'Also sync .wordpress-org/ to SVN assets/ (not implemented yet)' + required: false + default: false + type: boolean + +concurrency: + group: publish-plugin + cancel-in-progress: false + +permissions: + contents: read + +env: + # Must match the approved WordPress.org plugin slug. + SLUG: airo-wp + SVN_ROOT: https://plugins.svn.wordpress.org jobs: + # sync_assets means "artwork only, no code release", so the two jobs are + # mutually exclusive by construction. publish: name: Publish to WordPress.org - runs-on: ubuntu-latest + if: inputs.sync_assets != true + runs-on: ubuntu-24.04 + outputs: + channel: ${{ steps.version.outputs.channel }} + version: ${{ steps.version.outputs.version }} + + steps: + # Publishing is only ever done from a reviewed release branch. Fail before + # doing any work, rather than after building an artifact nobody will use. + - name: Guard - dispatch ref must be a release branch + run: | + if [[ "${GITHUB_REF}" != refs/heads/release/* ]]; then + echo "::error::Refusing to publish from '${GITHUB_REF}'. Dispatch this workflow with a release/* branch as the ref." + exit 1 + fi + echo "Ref: ${GITHUB_REF}" + + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve and validate version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + PKG_VERSION=$(node -p "require('./package.json').version") + + if [ -n "${INPUT_VERSION}" ]; then + VERSION="${INPUT_VERSION}" + else + VERSION="${PKG_VERSION}" + fi + + # Accept a stable x.y.z or a pre-release x.y.z- (beta1, rc1). + if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Version '${VERSION}' is not x.y.z or x.y.z-." + exit 1 + fi + + # An explicit input that disagrees with package.json means the branch and + # the request are out of step; publishing either one would be a guess. + if [ "${VERSION}" != "${PKG_VERSION}" ]; then + echo "::error::Requested version '${VERSION}' does not match package.json ('${PKG_VERSION}')." + exit 1 + fi + + if printf '%s' "${VERSION}" | grep -q -- '-'; then + CHANNEL=pre-release + else + CHANNEL=stable + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" + echo "Publishing ${VERSION} (${CHANNEL})" + + # SVN existence checks over HTTP, so this job needs no svn client and has + # no means of writing to WordPress.org. + - name: Guard - slug provisioned and tag not already published + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + PLUGIN_URL="${SVN_ROOT}/${SLUG}/" + TAG_URL="${SVN_ROOT}/${SLUG}/tags/${VERSION}/" + + plugin_status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${PLUGIN_URL}") + if [ "${plugin_status}" != "200" ]; then + echo "::error::${PLUGIN_URL} returned ${plugin_status}. The '${SLUG}' slug does not look provisioned on WordPress.org." + exit 1 + fi + + tag_status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${TAG_URL}") + if [ "${tag_status}" = "200" ]; then + echo "::error::${TAG_URL} already exists. WordPress.org tags are effectively permanent - bump the version instead of republishing." + exit 1 + fi + if [ "${tag_status}" != "404" ]; then + echo "::error::Unexpected status ${tag_status} for ${TAG_URL}. Refusing to continue on an ambiguous answer." + exit 1 + fi + + echo "Slug provisioned; tags/${VERSION} is free." + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - run: npm ci + + # The zip is the canonical artifact: built from package.json "files", and + # the same one the pre-release workflow runs Plugin Check and the e2e + # matrix against. + - name: Build distributable zip + run: npm run build:zip + + - name: Stage SVN payload + id: payload + run: bash .github/scripts/stage-svn-payload.sh builds/airo-wp.zip builds/svn-payload + + # deploy.sh evaluates its dry-run input as a shell command (`if $INPUT_DRY_RUN`), + # so it must receive exactly `true` or `false`. Normalise here: anything that + # is not an unambiguous `false` becomes `true`, i.e. the safe direction. This + # is what keeps the destructive path fail-closed now that the deploy step + # runs on dry runs too. + - name: Normalise dry-run flag + id: flags + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if [ "${DRY_RUN}" = "false" ]; then + echo "dry_run=false" >> "$GITHUB_OUTPUT" + echo "Dry run: false - this run WILL commit to WordPress.org." + else + echo "dry_run=true" >> "$GITHUB_OUTPUT" + echo "Dry run: true - nothing will be committed." + fi + + # A stable release must declare itself stable. The deploy rsyncs the payload + # over trunk, so it is the PAYLOAD's readme that ends up as trunk/readme.txt + # and therefore decides what WordPress.org serves — checking the remote + # trunk's current value would inspect state we are about to overwrite. + # + # Stable only: a pre-release deliberately leaves Stable tag pointing at the + # previous stable release, so this must not run on that channel. + - name: Guard - stable release must declare itself stable + if: steps.version.outputs.channel == 'stable' + env: + VERSION: ${{ steps.version.outputs.version }} + PAYLOAD: ${{ steps.payload.outputs.payload }} + run: | + set -euo pipefail + + readme="${PAYLOAD}/readme.txt" + + if [ ! -f "${readme}" ]; then + echo "::error::${readme} is missing from the staged payload." + exit 1 + fi + + stable_tag=$(sed -n 's/^Stable tag:[[:space:]]*\([^[:space:]]*\).*/\1/p' "${readme}" | head -1) + + if [ -z "${stable_tag}" ]; then + echo "::error::No 'Stable tag' header found in the payload's readme.txt." + exit 1 + fi + + if [ "${stable_tag}" != "${VERSION}" ]; then + echo "::error::Payload readme.txt declares 'Stable tag: ${stable_tag}' but this run publishes ${VERSION}. WordPress.org serves whatever Stable tag names, so this would publish ${VERSION} and then keep serving ${stable_tag}." + exit 1 + fi + + echo "Payload declares Stable tag: ${stable_tag}" + + - name: Report what would be published + env: + VERSION: ${{ steps.version.outputs.version }} + CHANNEL: ${{ steps.version.outputs.channel }} + PAYLOAD: ${{ steps.payload.outputs.payload }} + DRY_RUN: ${{ inputs.dry_run }} + SYNC_ASSETS: ${{ inputs.sync_assets }} + run: | + set -euo pipefail + + { + echo "### Publish plan" + echo + echo "| | |" + echo "|---|---|" + echo "| Version | \`${VERSION}\` |" + echo "| Channel | ${CHANNEL} |" + echo "| Slug | \`${SLUG}\` |" + echo "| SVN tag | \`${SVN_ROOT}/${SLUG}/tags/${VERSION}\` |" + if [ "${CHANNEL}" = "stable" ]; then + echo "| SVN trunk | \`${SVN_ROOT}/${SLUG}/trunk\` (would be updated) |" + else + echo "| SVN trunk | not touched (pre-release) |" + fi + echo "| Dry run | ${DRY_RUN} |" + echo "| Sync assets | after a successful stable deploy |" + echo + echo "
Payload manifest" + echo + echo '```' + ( cd "${PAYLOAD}" && find . -type f | sed 's|^\./||' | sort ) + echo '```' + echo + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + + # Informational only. An empty trunk (404) is the expected state before + # the first release, so this must not gate anything. + trunk_readme=$(curl -s --max-time 30 "${SVN_ROOT}/${SLUG}/trunk/readme.txt" || true) + current=$(printf '%s' "${trunk_readme}" | sed -n 's/^Stable tag:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1) + echo "Current trunk Stable tag: ${current:-none (trunk is empty)}" + + echo "Target tag: ${SVN_ROOT}/${SLUG}/tags/${VERSION}" + echo "Payload: ${PAYLOAD}" + echo "Manifest written to the job summary." + + # On a dry run this archive is the thing worth inspecting: it is exactly + # what would be committed to WordPress.org. + - name: Upload distributable zip + uses: actions/upload-artifact@v4 + with: + name: airo-wp-${{ steps.version.outputs.version }} + path: builds/airo-wp.zip + retention-days: 7 + + # Stable lane: rsync the payload into trunk and copy trunk to tags/X.Y.Z. + # + # Runs on dry runs too, deliberately: with dry-run the action still performs + # the SVN checkout and rsync and prints `svn status`, so a dry run shows the + # exact remote diff. Only the single `svn commit` is skipped, so nothing is + # written. The flag is normalised above rather than passed through raw. + # + # ASSETS_DIR is pointed at a path that does not exist on purpose. It defaults + # to `.wordpress-org`, which this repo now has, so leaving it unset would make + # every stable deploy publish plugin-directory artwork as a side effect — + # bypassing the sync_assets input entirely. Asset syncing is owned by its own + # lane; deploy.sh logs "No assets directory found" and leaves SVN assets/ + # untouched. + - name: Publish stable release to WordPress.org + if: steps.version.outputs.channel == 'stable' + uses: 10up/action-wordpress-plugin-deploy@stable + with: + dry-run: ${{ steps.flags.outputs.dry_run }} + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + SLUG: ${{ env.SLUG }} + VERSION: ${{ steps.version.outputs.version }} + BUILD_DIR: ${{ steps.payload.outputs.payload }} + ASSETS_DIR: .no-assets-managed-by-asset-lane + + # Subversion is NOT preinstalled on GitHub-hosted runners (it is absent from + # the ubuntu-24.04 image manifest). The stable lane gets it for free because + # 10up's deploy.sh installs it; this lane shells out to svn directly, so it + # has to provision it itself. + - name: Install Subversion + if: steps.version.outputs.channel != 'stable' + run: | + set -euo pipefail + if ! command -v svn >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends subversion + fi + svn --version --quiet + + # Pre-release lane: import straight into tags/, never touching + # trunk. The script asserts trunk is byte-identical afterwards and honours + # the normalised dry-run flag itself, since `svn import` has no dry-run mode. + - name: Publish pre-release tag to WordPress.org + if: steps.version.outputs.channel != 'stable' + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + PAYLOAD: ${{ steps.payload.outputs.payload }} + VERSION: ${{ steps.version.outputs.version }} + DRY_RUN: ${{ steps.flags.outputs.dry_run }} + run: | + bash .github/scripts/svn-import-tag.sh \ + "${PAYLOAD}" "${SVN_ROOT}" "${SLUG}" "${VERSION}" "${DRY_RUN}" + + # Runs either standalone (sync_assets, no code release) or after a successful + # stable deploy. Never for a pre-release: SVN assets/ is version-independent and + # goes live immediately, so a beta must not change what the plugin page shows + # while the released version is still the previous one. + # + # `needs` plus `always()` is what allows both entry points: on a standalone run + # the publish job is skipped, and this still proceeds. + sync-assets: + name: Sync plugin-directory assets + needs: publish + if: | + always() && ( + inputs.sync_assets == true || + (needs.publish.result == 'success' && needs.publish.outputs.channel == 'stable') + ) + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - name: Guard - dispatch ref must be a release branch + run: | + if [[ "${GITHUB_REF}" != refs/heads/release/* ]]; then + echo "::error::Refusing to sync assets from '${GITHUB_REF}'. Dispatch this workflow with a release/* branch as the ref." + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v4 + + # Job-scoped: a standalone run skips the publish job, so its normalised flag + # is not available here. Same fail-closed rule. + - name: Normalise dry-run flag + id: flags + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if [ "${DRY_RUN}" = "false" ]; then + echo "dry_run=false" >> "$GITHUB_OUTPUT" + else + echo "dry_run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Refuse to publish placeholder artwork + env: + DRY_RUN: ${{ steps.flags.outputs.dry_run }} + run: | + set -euo pipefail + marker=.github/ASSETS_ARE_PLACEHOLDERS + + if [ ! -f "${marker}" ]; then + echo "No placeholder marker present; artwork is treated as real." + exit 0 + fi + + if [ "${DRY_RUN}" = "false" ]; then + echo "::error::${marker} exists, so .wordpress-org/ still holds placeholder images. Publishing them would put them on the live plugin page immediately. Replace the artwork and delete that file first." + exit 1 + fi + + echo "::warning::Placeholder artwork detected (${marker}); continuing because this is a dry run." + + - name: Install Subversion + run: | + set -euo pipefail + if ! command -v svn >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends subversion + fi + svn --version --quiet + + - name: Sync assets to WordPress.org + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + DRY_RUN: ${{ steps.flags.outputs.dry_run }} + run: | + bash .github/scripts/svn-sync-assets.sh \ + .wordpress-org "${SVN_ROOT}" "${SLUG}" "${DRY_RUN}" diff --git a/.gitignore b/.gitignore index 1598317..51fa90d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,8 @@ /playwright-report/ /plugin-check-results/ -# DSG-owned — generated by make dsg-sync, never edited directly and not committed. -# Regenerated by make dsg-sync; shipped to the zip via the package.json "files" -# allowlist (build-zip.sh runs sync + build), so they don't need to be tracked. +# DesignSetGo-owned — generated by the DesignSetGo sync, never edited directly and +# not committed. Shipped to the distributable zip via the package.json "files" +# allowlist, so they do not need to be tracked here. /.env.local /artifacts/storage-states/*.json diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..b05ca3b --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,20 @@ +# Build output +dist/ +build/ + +# Distributable archives + ephemeral wp-env/plugin-check instances +builds/ + +# Strauss-prefixed runtime deps +dependencies/ + +# Node modules / Composer vendor +node_modules/ +vendor/ + +# WordPress environment +.wp-env/ + +# Test artifacts +playwright-report/ +test-results/ diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..076924a --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,11 @@ +{ + "extends": "@wordpress/stylelint-config/scss", + "rules": { + "selector-class-pattern": null, + "no-descending-specificity": null, + "no-duplicate-selectors": null, + "color-named": [ "never", { "ignore": [ "inside-function" ] } ], + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true + } +} diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 0000000..dac97f2 Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 0000000..07572ff Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 0000000..e331898 Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png new file mode 100644 index 0000000..93aa710 Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ diff --git a/.wp-env.json b/.wp-env.json index 27f5836..84f57ac 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,12 +1,9 @@ { + "core": "https://wordpress.org/wordpress-7.1.zip", "phpVersion": "8.3", "port": 9173, "testsPort": 9190, "plugins": [ - ".", - "https://downloads.wordpress.org/plugin/plugin-check.2.0.0.zip" - ], - "lifecycleScripts": { - "afterStart": "npx wp-env run tests-cli -- sh -c 'command -v composer || curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer' && npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install && npx wp-env run tests-cli -- wp rewrite structure '/%postname%/' && npx wp-env run tests-cli -- wp rewrite flush --hard && npx wp-env run tests-cli -- wp plugin install hello-dolly --version=1.7.2 --force" - } + "." + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ab984..db0f0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 + +- Added block editor extensions (animations, hover effects, sticky headers, dynamic tags, and more) to the bundled block source +- Confirmed compatibility with WordPress 7.1 +- Added plugin-directory icon and banner artwork + ## 0.2.5 - Hardened permission checks on four MCP tools diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e530514..7bf7700 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -136,6 +136,97 @@ CI runs automatically on pull requests and pushes to `main`: All four must pass before merge. +## Releasing + +Releases are published to WordPress.org by the **Publish Plugin** workflow. It is +dispatched manually, always from a `release/` branch, and refuses to run +from any other ref. + +### Version numbers + +| Kind | Form | Example | +|------|------|---------| +| Stable | `x.y.z` | `0.3.0` | +| Beta | `x.y.z-betaN` | `0.3.0-beta1` | +| Release candidate | `x.y.z-rcN` | `0.3.0-rc1` | + +The `-betaN` / `-rcN` spelling is WordPress's own convention, and the counter +starts at 1. `version_compare()` ranks them below the release they precede — +`0.3.0-beta1 < 0.3.0-rc1 < 0.3.0` — and the counter compares numerically, so +`beta9 < beta10`. Note that ordering is not what keeps a beta away from existing +installs; `Stable tag` is, as described below. + +### Publishing + +Dry run first — `dry_run` defaults to `true`, and a dry run reports the exact +Subversion diff without committing anything: + +```bash +gh workflow run publish-plugin.yml \ + --repo godaddy-wordpress/airo-wp \ + --ref release/0.3.0 \ + -f dry_run=true +``` + +Read the job summary: it lists the target Subversion tag, the full payload +manifest, and the current `Stable tag` in `trunk`. When it looks right, re-dispatch +with `-f dry_run=false`. + +What the workflow does depends on the version: + +| Version | `trunk` | Tag | Plugin-directory assets | +|---------|---------|-----|------------------------| +| Stable | updated | `tags/x.y.z` | synced after a successful deploy | +| Pre-release | **untouched** | `tags/x.y.z-betaN` | never | + +A pre-release is published as a tag only. `trunk/readme.txt`'s `Stable tag` is what +WordPress.org feeds to the auto-update channel, so leaving `trunk` alone is what +keeps a beta off every existing install. Testers install it from the plugin page's +advanced view. + +For a stable release, the payload's `readme.txt` must declare `Stable tag` equal to +the version being published. The workflow refuses to publish otherwise, because +WordPress.org would then serve a different version from the one just released. + +### Artwork only + +Banner, icon and screenshots live in `.wordpress-org/` and are published to +Subversion's `assets/` directory. They are version-independent and go live the +moment they are committed, so they can be updated without releasing any code: + +```bash +gh workflow run publish-plugin.yml \ + --repo godaddy-wordpress/airo-wp \ + --ref release/0.3.0 \ + -f sync_assets=true -f dry_run=false +``` + +`sync_assets` means *artwork only* — no code is built, no tag is created, and +`trunk` is not touched. + +If `.github/ASSETS_ARE_PLACEHOLDERS` is present, a real artwork sync is refused and +only dry runs are allowed. It exists to stop unfinished artwork reaching the live +plugin page; remove it once the artwork is real. + +### Rollback: read this before publishing + +**WordPress.org tags are effectively permanent.** No workflow path unpublishes a +version. Removing a tag requires manual Subversion work, and by then the release +may already have been downloaded and installed. + +What you can do: + +- **Stable release is bad** — commit `trunk/readme.txt` with `Stable tag` pointing + back at the previous good version. WordPress.org serves whatever `Stable tag` + names, so this is the fastest way to stop distributing a bad release. The bad tag + itself stays published. +- **Pre-release is bad** — much lower stakes: `trunk` was never touched and + `Stable tag` never pointed at it, so no existing install was ever offered it. + Publish a higher pre-release (`-beta2`) and move on. + +In both cases the real remedy is releasing forward, not retracting. Treat the dry +run as the last point at which a mistake is cheap. + ## Architecture | Path | Role | diff --git a/README.md b/README.md index f15e021..b8e0ce5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Airo WP AI Builder -[![PHPUnit](https://github.com/gdcorp-wordpress/airo-wp/actions/workflows/phpunit.yml/badge.svg)](https://github.com/gdcorp-wordpress/airo-wp/actions/workflows/phpunit.yml) -[![PHPCS](https://github.com/gdcorp-wordpress/airo-wp/actions/workflows/phpcs.yml/badge.svg)](https://github.com/gdcorp-wordpress/airo-wp/actions/workflows/phpcs.yml) +[![CI](https://github.com/godaddy-wordpress/airo-wp/actions/workflows/ci.yml/badge.svg)](https://github.com/godaddy-wordpress/airo-wp/actions/workflows/ci.yml) **Airo WP AI Builder** is a WordPress plugin that exposes your site to AI assistants via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). It also bundles a curated set of Gutenberg block patterns built on the Twenty Twenty-Five (tt5) theme, giving any connected AI a ready-made vocabulary for assembling pages. @@ -23,12 +22,12 @@ |---|---| | **Plugin name** | Airo WP AI Builder | | **Text domain** | `airo-wp` | -| **Version** | 0.2.5 | +| **Version** | 0.3.0 | | **Requires WordPress** | 6.8+ | | **Requires PHP** | 7.4+ | | **License** | [GPLv2 or later](https://www.gnu.org/licenses/gpl-2.0.html) | | **Author** | [GoDaddy](https://www.godaddy.com) | -| **Repository** | [gdcorp-wordpress/airo-wp](https://github.com/gdcorp-wordpress/airo-wp) | +| **Repository** | [godaddy-wordpress/airo-wp](https://github.com/godaddy-wordpress/airo-wp) | ## Installation diff --git a/airo-wp.php b/airo-wp.php index fbd656c..f6d42fd 100644 --- a/airo-wp.php +++ b/airo-wp.php @@ -3,7 +3,7 @@ * Plugin Name: Airo WP AI Builder * Plugin URI: https://github.com/godaddy-wordpress/airo-wp * Description: MCP server and block pattern library for AI-powered site building. - * Version: 0.2.5 + * Version: 0.3.0 * Requires at least: 6.9 * Requires PHP: 7.4 * Author: GoDaddy @@ -20,7 +20,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'AIRO_WP_VERSION' ) ) { - define( 'AIRO_WP_VERSION', '0.2.5' ); + define( 'AIRO_WP_VERSION', '0.3.0' ); } if ( ! defined( 'AIRO_WP_PLUGIN_FILE' ) ) { define( 'AIRO_WP_PLUGIN_FILE', __FILE__ ); diff --git a/composer.json b/composer.json index d70b6eb..cb1c505 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "gdcorp-wordpress/airo-wp", + "name": "godaddy-wordpress/airo-wp", "description": "Airo WordPress plugin", "type": "wordpress-plugin", "license": "proprietary", diff --git a/composer.lock b/composer.lock index 9649390..1748d0a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "23d8279a68202aa0b462da83eab25aff", + "content-hash": "e63628024993fae0c9a5b7a31995ade3", "packages": [ { "name": "psr/container", diff --git a/data/block-animation-attributes.php b/data/block-animation-attributes.php index 15e00a9..6560f34 100644 --- a/data/block-animation-attributes.php +++ b/data/block-animation-attributes.php @@ -14,93 +14,149 @@ } /** - * Get animation data attributes from block attributes + * Get animation classes/attributes as structured arrays. * - * Extracts animation-related attributes and returns them as - * an array of data attributes suitable for adding to HTML elements. + * Raw (unescaped) values — callers are responsible for escaping. Mirrors + * addAnimationSaveProps() in src/extensions/block-animations/editor.js: the + * two must emit identical markup, because a block is served by whichever of + * them applies (save filter for static blocks, render filter for dynamic + * ones). Returns only the SVG-draw attribute unless dsgoAnimationEnabled is + * truthy — that one effect is independent of the entrance/exit system. * * @param array $attributes Block attributes array. - * @return array Array of data attributes for animations. + * @return array{classes: string[], attrs: array} */ -function airowp_get_animation_attributes( $attributes ) { - $animation_attrs = array(); - $animation_classes = array(); - - // Check if animations are enabled. - $animation_enabled = isset( $attributes['dsgoAnimationEnabled'] ) ? $attributes['dsgoAnimationEnabled'] : false; +function airowp_get_animation_parts( $attributes ) { + $classes = array(); + $attrs = array(); + + // SVG drawing targets descendant strokes rather than this block's own + // opacity, so it is independent of the entrance/exit system and survives + // the animations-disabled return below. + if ( ! empty( $attributes['dsgoSvgDraw'] ) ) { + $attrs['data-airo-wp-svg-draw'] = 'true'; + } - if ( ! $animation_enabled ) { + $enabled = isset( $attributes['dsgoAnimationEnabled'] ) ? $attributes['dsgoAnimationEnabled'] : false; + if ( ! $enabled ) { return array( - 'classes' => '', - 'attrs' => '', + 'classes' => $classes, + 'attrs' => $attrs, ); } - // Add animation classes. - $animation_classes[] = 'has-airo-wp-animation'; + $classes[] = 'has-airo-wp-animation'; + $attrs['data-airo-wp-animation-enabled'] = 'true'; - // Add entrance animation class. - $entrance_animation = isset( $attributes['dsgoEntranceAnimation'] ) ? $attributes['dsgoEntranceAnimation'] : ''; - if ( $entrance_animation ) { - $animation_classes[] = 'airo-wp-animation-' . esc_attr( $entrance_animation ); + $entrance = isset( $attributes['dsgoEntranceAnimation'] ) ? (string) $attributes['dsgoEntranceAnimation'] : ''; + if ( '' !== $entrance ) { + $classes[] = 'airo-wp-animation-' . $entrance; + $attrs['data-airo-wp-entrance-animation'] = $entrance; } - // Add exit animation class. - $exit_animation = isset( $attributes['dsgoExitAnimation'] ) ? $attributes['dsgoExitAnimation'] : ''; - if ( $exit_animation ) { - $animation_classes[] = 'airo-wp-animation-exit-' . esc_attr( $exit_animation ); - } - - // Always include the enabled flag and animation type(s) — required by frontend JS. - $animation_attrs['data-airo-wp-animation-enabled'] = 'true'; - - if ( $entrance_animation ) { - $animation_attrs['data-airo-wp-entrance-animation'] = esc_attr( $entrance_animation ); + $trigger = isset( $attributes['dsgoAnimationTrigger'] ) ? (string) $attributes['dsgoAnimationTrigger'] : 'scroll'; + if ( 'scroll' !== $trigger ) { + $attrs['data-airo-wp-animation-trigger'] = $trigger; } - if ( $exit_animation ) { - $animation_attrs['data-airo-wp-exit-animation'] = esc_attr( $exit_animation ); + // Scrubbing hands the entrance to the scroll timeline, so it needs an + // entrance animation and it only means anything on the scroll trigger: + // frontend.js skips scroll-linked elements entirely, so emitting it on a + // click- or hover-triggered block would swallow that trigger - and, for + // click, the tabindex/role=button keyboard affordance with it. Existing + // content can still carry the combination, which is why the trigger is + // checked here and not only in the panel. + $scroll_linked = ! empty( $attributes['dsgoScrollLinked'] ) + && '' !== $entrance + && 'scroll' === $trigger; + + // frontend.js never wires up the exit trigger for a scrubbed element, so + // exit markup alongside it would advertise an animation that can never + // fire. Dropped here exactly as the save path drops it. + $exit = isset( $attributes['dsgoExitAnimation'] ) ? (string) $attributes['dsgoExitAnimation'] : ''; + if ( $scroll_linked ) { + $exit = ''; } - - // Only output settings that differ from defaults to keep markup lean. - // Defaults: trigger=scroll, duration=600, delay=0, easing=ease-out, offset=100, once=true. - $trigger = isset( $attributes['dsgoAnimationTrigger'] ) ? $attributes['dsgoAnimationTrigger'] : 'scroll'; - if ( 'scroll' !== $trigger ) { - $animation_attrs['data-airo-wp-animation-trigger'] = esc_attr( $trigger ); + if ( '' !== $exit ) { + $classes[] = 'airo-wp-animation-exit-' . $exit; + $attrs['data-airo-wp-exit-animation'] = $exit; } $duration = isset( $attributes['dsgoAnimationDuration'] ) ? (int) $attributes['dsgoAnimationDuration'] : 600; if ( 600 !== $duration ) { - $animation_attrs['data-airo-wp-animation-duration'] = esc_attr( (string) $duration ); + $attrs['data-airo-wp-animation-duration'] = (string) $duration; } $delay = isset( $attributes['dsgoAnimationDelay'] ) ? (int) $attributes['dsgoAnimationDelay'] : 0; if ( 0 !== $delay ) { - $animation_attrs['data-airo-wp-animation-delay'] = esc_attr( (string) $delay ); + $attrs['data-airo-wp-animation-delay'] = (string) $delay; } - $easing = isset( $attributes['dsgoAnimationEasing'] ) ? $attributes['dsgoAnimationEasing'] : 'ease-out'; + $easing = isset( $attributes['dsgoAnimationEasing'] ) ? (string) $attributes['dsgoAnimationEasing'] : 'ease-out'; if ( 'ease-out' !== $easing ) { - $animation_attrs['data-airo-wp-animation-easing'] = esc_attr( $easing ); + $attrs['data-airo-wp-animation-easing'] = $easing; } $offset = isset( $attributes['dsgoAnimationOffset'] ) ? (int) $attributes['dsgoAnimationOffset'] : 100; if ( 100 !== $offset ) { - $animation_attrs['data-airo-wp-animation-offset'] = esc_attr( (string) $offset ); + $attrs['data-airo-wp-animation-offset'] = (string) $offset; } $once = isset( $attributes['dsgoAnimationOnce'] ) ? (bool) $attributes['dsgoAnimationOnce'] : true; if ( ! $once ) { - $animation_attrs['data-airo-wp-animation-once'] = 'false'; + $attrs['data-airo-wp-animation-once'] = 'false'; } - // Convert classes array to string. - $classes_string = implode( ' ', $animation_classes ); + if ( $scroll_linked ) { + $attrs['data-airo-wp-scroll-linked'] = 'true'; + } + + // Stagger moves the motion onto the block's children, so it needs an + // animation to move and it rules scrubbing out - the two want the + // keyframes on different elements. + $stagger = ! empty( $attributes['dsgoStaggerEnabled'] ) + && ! $scroll_linked + && ( '' !== $entrance || '' !== $exit ); + + if ( $stagger ) { + $attrs['data-airo-wp-stagger'] = 'true'; + + $step = isset( $attributes['dsgoStaggerStep'] ) ? (int) $attributes['dsgoStaggerStep'] : 80; + if ( 80 !== $step ) { + $attrs['data-airo-wp-stagger-step'] = (string) $step; + } + } + + return array( + 'classes' => $classes, + 'attrs' => $attrs, + ); +} + +/** + * Get animation data attributes from block attributes + * + * Extracts animation-related attributes and returns them as + * an array of data attributes suitable for adding to HTML elements. + * + * @param array $attributes Block attributes array. + * @return array Array of data attributes for animations. + */ +function airowp_get_animation_attributes( $attributes ) { + $parts = airowp_get_animation_parts( $attributes ); + + if ( empty( $parts['classes'] ) && empty( $parts['attrs'] ) ) { + return array( + 'classes' => '', + 'attrs' => '', + ); + } + + $classes_string = implode( ' ', array_map( 'esc_attr', $parts['classes'] ) ); - // Convert data attributes array to string. $attrs_string = ''; - foreach ( $animation_attrs as $key => $value ) { - $attrs_string .= ' ' . $key . '="' . $value . '"'; + foreach ( $parts['attrs'] as $key => $value ) { + $attrs_string .= ' ' . $key . '="' . esc_attr( $value ) . '"'; } return array( diff --git a/functions/blocks/helpers.php b/functions/blocks/helpers.php index ff1e68c..233f427 100644 --- a/functions/blocks/helpers.php +++ b/functions/blocks/helpers.php @@ -319,3 +319,62 @@ function airowp_resolve_preset_color( $color, $fallback = null ) { // Preset reference that could not be resolved to a palette color. return null === $fallback ? $color : $fallback; } + +/** + * Builds a keyless Google Maps embed URL for the Map block. + * + * Uses the long-standing `output=embed` share URL rather than the official + * Maps Embed API (`google.com/maps/embed/v1/*`), because the latter still + * requires a Cloud API key even though it is free and unmetered. This form + * needs no key at all, which is the whole point of the `googlemaps-embed` + * provider. It is undocumented, so the keyed Maps JavaScript API path stays + * in place as the supported option. + * + * Google geocodes the `q` parameter itself, so an address is passed through + * verbatim and the block's Nominatim lookup is skipped entirely. + * + * @since 2.6.0 + * + * @param string $address Street address. Preferred over coordinates when set. + * @param float $latitude Latitude, used when $address is empty. + * @param float $longitude Longitude, used when $address is empty. + * @param int $zoom Zoom level; clamped to Google's 1–20 range. + * @return string Fully-formed embed URL (not escaped — escape at output). + */ +function airowp_map_embed_url( $address, $latitude, $longitude, $zoom ) { + // Flatten multi-line addresses the way the block's geocoder does, so the + // two providers resolve the same author input to the same place. + $address = preg_replace( '/[\r\n]+/', ', ', (string) $address ); + $address = trim( preg_replace( '/\s+/', ' ', $address ) ); + + if ( '' !== $address ) { + $query = $address; + } else { + $query = airowp_format_coordinate( $latitude ) . ',' . airowp_format_coordinate( $longitude ); + } + + $args = array( + 'q' => $query, + 'z' => (string) max( 1, min( 20, (int) $zoom ) ), + 'output' => 'embed', + ); + + return 'https://maps.google.com/maps?' . http_build_query( $args ); +} + +/** + * Formats a coordinate for a map URL without exponent or trailing-zero noise. + * + * @since 2.6.0 + * + * @param float $value Coordinate value. + * @return string Plain decimal representation. + */ +function airowp_format_coordinate( $value ) { + $formatted = number_format( (float) $value, 6, '.', '' ); + + // Trim trailing zeros, then a bare trailing separator ("0.000000" → "0"). + $formatted = rtrim( $formatted, '0' ); + + return rtrim( $formatted, '.' ); +} diff --git a/includes/Blocks/Common/DynamicTags/Bootstrap.php b/includes/Blocks/Common/DynamicTags/Bootstrap.php index bd79647..acded89 100644 --- a/includes/Blocks/Common/DynamicTags/Bootstrap.php +++ b/includes/Blocks/Common/DynamicTags/Bootstrap.php @@ -50,7 +50,9 @@ public function get_rest_controller() { } /** - * Registers the four source families and the custom-field metadata. + * Registers the source families and the custom-field metadata. + * + * WooSources no-ops unless WooCommerce is active. */ public function register_sources() { $registry = Registry::instance(); @@ -59,6 +61,7 @@ public function register_sources() { SiteSources::register( $registry ); ArchiveSources::register( $registry ); UserSources::register( $registry ); + WooSources::register( $registry ); $this->register_custom_field_metadata( $registry ); diff --git a/includes/Blocks/Common/DynamicTags/WooSources.php b/includes/Blocks/Common/DynamicTags/WooSources.php new file mode 100644 index 0000000..6afeb49 --- /dev/null +++ b/includes/Blocks/Common/DynamicTags/WooSources.php @@ -0,0 +1,254 @@ +`, which no Woo block can provide. + * That gap is this file's entire reason to exist. + * + * `woo-price-html` is the one formatted source kept, for the narrower case of + * driving a *airo-wp* block (a price inside a `pill` or `advanced-heading` + * with DSGo typography) rather than accepting Woo's own markup. + * + * @package airo-wp + * @since 2.7.0 + */ + +declare(strict_types=1); + +namespace GoDaddy\WordPress\Plugins\AiroWp\Blocks\Common\DynamicTags; + +defined( 'ABSPATH' ) || exit; +/** + * Registers the `airo-wp/woo-*` binding sources. + */ +class WooSources { + + /** + * Registers all WooCommerce sources and their registry metadata. + * + * No-ops entirely when WooCommerce is absent, so the picker never offers a + * source that cannot resolve. + * + * @param Registry $registry Metadata registry. + */ + public static function register( Registry $registry ) { + if ( ! function_exists( 'airowp_register_bindings_source' ) ) { + return; + } + + if ( ! self::is_woocommerce_active() ) { + return; + } + + $registry->register_group( 'woocommerce', __( 'WooCommerce', 'airo-wp' ), 60 ); + + self::register_one( + $registry, + 'airo-wp/woo-price-html', + __( 'Price (formatted)', 'airo-wp' ), + array( 'html' ), + static function ( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + $html = $product->get_price_html(); + + return '' === $html ? null : $html; + } + ); + + self::register_one( + $registry, + 'airo-wp/woo-price', + __( 'Price (raw number)', 'airo-wp' ), + array( 'number', 'text' ), + static function ( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + return self::numeric_or_null( $product->get_price() ); + } + ); + + self::register_one( + $registry, + 'airo-wp/woo-regular-price', + __( 'Regular price (raw number)', 'airo-wp' ), + array( 'number', 'text' ), + static function ( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + return self::numeric_or_null( $product->get_regular_price() ); + } + ); + + self::register_one( + $registry, + 'airo-wp/woo-discount-percent', + __( 'Discount percent', 'airo-wp' ), + array( 'number', 'text' ), + array( self::class, 'get_discount_percent' ) + ); + + self::register_one( + $registry, + 'airo-wp/woo-stock-quantity', + __( 'Stock quantity', 'airo-wp' ), + array( 'number', 'text' ), + static function ( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + $quantity = $product->get_stock_quantity(); + + // Null when the product does not manage stock — an unmanaged + // product has no quantity, which is not the same as zero. + return null === $quantity ? null : (string) (int) $quantity; + } + ); + + self::register_one( + $registry, + 'airo-wp/woo-average-rating', + __( 'Average rating', 'airo-wp' ), + array( 'number', 'text' ), + static function ( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + // Read once: the cast is only for the comparison, while the + // returned value keeps Woo's own formatting (e.g. '4.00'). + $rating = $product->get_average_rating(); + + // A product with no reviews rates 0.0; report nothing rather than + // a misleading zero, matching how Woo's own rating block hides. + return (float) $rating > 0 ? (string) $rating : null; + } + ); + } + + /** + * Percentage off, as a whole number, or null when not discounted. + * + * Public because it is registered as a callable rather than a closure, to + * keep register() inside the file-length budget. + * + * @param array $args Binding args, carrying the resolved `__airowp_post_id`. + * @return string|null + */ + public static function get_discount_percent( $args ) { + $product = self::resolve_product( $args ); + if ( ! $product ) { + return null; + } + + $regular = (float) $product->get_regular_price(); + $current = (float) $product->get_price(); + + if ( $regular <= 0 || $current >= $regular ) { + return null; + } + + return (string) (int) round( ( 1 - ( $current / $regular ) ) * 100 ); + } + + /** + * Whether WooCommerce is loaded far enough for product reads. + * + * @return bool + */ + private static function is_woocommerce_active() { + return class_exists( 'WooCommerce' ) && function_exists( 'wc_get_product' ); + } + + /** + * Resolves the bound post to a WooCommerce product. + * + * Returns null when the post is not a product, so a Woo source dropped onto a + * regular post degrades to "no value" rather than erroring. + * + * @param array $args Binding args, carrying the resolved `__airowp_post_id`. + * @return \WC_Product|null + */ + private static function resolve_product( $args ) { + $post_id = isset( $args['__airowp_post_id'] ) ? (int) $args['__airowp_post_id'] : 0; + + if ( ! $post_id || ! self::is_woocommerce_active() ) { + return null; + } + + $product = wc_get_product( $post_id ); + + return $product instanceof \WC_Product ? $product : null; + } + + /** + * Normalises a Woo price string to a value, or null when it is empty. + * + * Woo returns '' (not 0) for an unset price, and '' would render as an empty + * bound attribute rather than falling back to the block's own content. + * + * NOTE for variable products: `get_price()` returns the *minimum* variation + * price, not a range. Authors who need the range must use + * `airo-wp/woo-price-html` or WooCommerce's own product-price block. + * + * @param mixed $value Raw price value from the product object. + * @return string|null + */ + private static function numeric_or_null( $value ) { + if ( '' === $value || null === $value ) { + return null; + } + + return (string) $value; + } + + /** + * Registers one source with both core Bindings and the metadata registry. + * + * Mirrors ArchiveSources::register_one(). + * + * @param Registry $registry Metadata registry. + * @param string $slug Binding source slug. + * @param string $label Display label. + * @param string[] $returns Return types. + * @param callable $callback Value callback. + */ + private static function register_one( Registry $registry, $slug, $label, array $returns, callable $callback ) { + airowp_register_bindings_source( + $slug, + $callback, + array( 'label' => $label ) + ); + + $registry->register_source( + $slug, + array( + 'label' => $label, + 'group' => 'woocommerce', + 'returns' => $returns, + ) + ); + } +} diff --git a/includes/Blocks/Common/Forms/FormHandler.php b/includes/Blocks/Common/Forms/FormHandler.php index f5b6674..817db88 100644 --- a/includes/Blocks/Common/Forms/FormHandler.php +++ b/includes/Blocks/Common/Forms/FormHandler.php @@ -705,14 +705,20 @@ private function get_user_agent() { * Localize script with nonce and REST URL. */ public function localize_form_script() { - // Only enqueue if form block is present on the page. - if ( ! has_block( 'airo-wp/form-builder' ) ) { - return; - } - - // Get the form-builder view script handle. - $asset_file = include AIRO_WP_PLUGIN_DIR . 'build/blocks/form-builder/view.asset.php'; - $handle = 'airo-wp-form-builder-view-script'; + // Deliberately unconditional. This used to be guarded by + // has_block( 'airo-wp/form-builder' ), which only inspects the + // current post's content — so a form living in a template part + // (a newsletter signup in the footer), a synced pattern, a block + // widget, or anything rendered via do_blocks() never matched. The + // block still rendered and WordPress still enqueued its viewScript, + // so view.js ran with airowpForm undefined and every submission + // died on a ReferenceError. + // + // No guard is needed: wp_localize_script() only attaches data to a + // registered handle. WordPress enqueues the block's viewScript at + // render time, and the data is printed only if that happens — on a + // page with no form, this outputs nothing. + $handle = 'airo-wp-form-builder-view-script'; // Localize with nonce and REST URL. wp_localize_script( diff --git a/includes/Blocks/Common/Forms/FormSecurity.php b/includes/Blocks/Common/Forms/FormSecurity.php index ba7d9d5..7835ef1 100644 --- a/includes/Blocks/Common/Forms/FormSecurity.php +++ b/includes/Blocks/Common/Forms/FormSecurity.php @@ -82,7 +82,7 @@ public function check_submission_timing( string $timestamp, string $form_id ) { */ public function check_rate_limit( string $form_id, int $block_max = 3 ) { $ip_address = $this->get_client_ip(); - $key = 'form_submit_' . $form_id . '_' . md5( $ip_address ); + $key = 'airowp_form_submit_' . md5( $form_id ) . '_' . md5( $ip_address ); $count = get_transient( $key ); $max_submissions = apply_filters( 'airowp_form_rate_limit_count', $block_max, $form_id ); @@ -108,7 +108,7 @@ public function check_rate_limit( string $form_id, int $block_max = 3 ) { */ public function increment_rate_limit( string $form_id, int $block_window = 60 ): void { $ip_address = $this->get_client_ip(); - $key = 'form_submit_' . $form_id . '_' . md5( $ip_address ); + $key = 'airowp_form_submit_' . md5( $form_id ) . '_' . md5( $ip_address ); $count = get_transient( $key ); $time_window = apply_filters( 'airowp_form_rate_limit_window', $block_window, $form_id ); @@ -143,6 +143,7 @@ public function verify_turnstile( string $token ) { // experience. On timeout, wp_remote_post() returns a WP_Error and we degrade // gracefully (let the submission through) rather than punish the user. $response = wp_remote_post( + // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Server-side Turnstile verification API endpoint, not an offloaded asset. The sniff matches any `cloudflare.com` host in any string; no image, script, style or other content is loaded from it. 'https://challenges.cloudflare.com/turnstile/v0/siteverify', array( 'timeout' => 3, diff --git a/includes/Blocks/Common/Query/FilterIndexRebuilder.php b/includes/Blocks/Common/Query/FilterIndexRebuilder.php index 45642d3..33a4173 100644 --- a/includes/Blocks/Common/Query/FilterIndexRebuilder.php +++ b/includes/Blocks/Common/Query/FilterIndexRebuilder.php @@ -348,7 +348,7 @@ private static function do_rebuild_filter( string $key, array $args ): array { } while ( $ids_count === $batch_size ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $total_rows = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE filter_key = %s", $key ) ); + $total_rows = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE filter_key = %s', $table, $key ) ); self::write_status( array( diff --git a/includes/Blocks/Common/TextPath/Controller.php b/includes/Blocks/Common/TextPath/Controller.php new file mode 100644 index 0000000..e09a314 --- /dev/null +++ b/includes/Blocks/Common/TextPath/Controller.php @@ -0,0 +1,318 @@ + 7, + 'C' => 6, + 'H' => 1, + 'L' => 2, + 'M' => 2, + 'Q' => 4, + 'S' => 4, + 'T' => 2, + 'V' => 1, + 'Z' => 0, + ); + + /** + * Registers the safe SVG extraction route. + * + * @return void + */ + public static function register_routes() { + register_rest_route( + 'airo-wp/v1', + '/text-path/extract', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'extract' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + 'args' => array( + 'svg' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + } + + /** + * Limits extraction to users permitted to upload media. + * + * @param \WP_REST_Request $request REST request context. + * @return true|\WP_Error Whether the request is permitted. + */ + public static function permissions_check( \WP_REST_Request $request ) { + unset( $request ); + + if ( current_user_can( 'upload_files' ) ) { + return true; + } + + return new \WP_Error( 'airowp_text_path_forbidden', __( 'You do not have permission to extract SVG paths.', 'airo-wp' ), array( 'status' => 403 ) ); + } + + /** + * Extracts the first valid path from an SVG payload. + * + * @param \WP_REST_Request $request REST request containing SVG markup. + * @return \WP_REST_Response|\WP_Error Extraction result. + */ + public static function extract( \WP_REST_Request $request ) { + $data = self::parse_svg_path( $request->get_param( 'svg' ) ); + if ( null === $data ) { + return new \WP_Error( 'airowp_text_path_invalid_svg', __( 'The SVG does not contain a safe path.', 'airo-wp' ), array( 'status' => 400 ) ); + } + + return rest_ensure_response( $data ); + } + + /** + * Parses a safe SVG document into a normalised path payload. + * + * @param mixed $svg SVG markup to validate. + * @return array{viewBox: string, d: string}|null Safe path data, if found. + */ + public static function parse_svg_path( $svg ) { + if ( + ! is_string( $svg ) || + '' === $svg || + strlen( $svg ) > self::MAX_LENGTH || + preg_match( '/loadXML( $svg, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMDocument exposes this native property. + $document_element = $document->documentElement; + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMElement exposes this native property. + $root_name = $document_element ? $document_element->localName : ''; + if ( + ! $loaded || + ! $document_element || + 'svg' !== strtolower( $root_name ) || + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMElement exposes this native property. + self::SVG_NAMESPACE !== $document_element->namespaceURI + ) { + return null; + } + + $root = $document_element; + foreach ( $root->getElementsByTagName( '*' ) as $element ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMElement exposes this native property. + $element_name = $element->localName; + if ( in_array( strtolower( $element_name ), array( 'script', 'foreignobject' ), true ) ) { + return null; + } + } + + $view_box = self::normalise_view_box( $root->getAttribute( 'viewBox' ) ); + if ( ! $view_box ) { + return null; + } + foreach ( $root->getElementsByTagNameNS( self::SVG_NAMESPACE, 'path' ) as $path ) { + $d = trim( $path->getAttribute( 'd' ) ); + if ( self::is_safe_path( $d ) ) { + return array( + 'viewBox' => $view_box, + 'd' => $d, + ); + } + } + + return null; + } + + /** + * Normalises a positive four-value SVG viewBox. + * + * @param mixed $view_box Candidate SVG viewBox. + * @return string|null Normalised viewBox or null when invalid. + */ + private static function normalise_view_box( $view_box ) { + $parts = preg_split( '/[\s,]+/', trim( (string) $view_box ) ); + if ( + 4 !== count( $parts ) || + ! self::is_safe_number( $parts[0] ) || + ! self::is_safe_number( $parts[1] ) || + ! self::is_safe_number( $parts[2] ) || + ! self::is_safe_number( $parts[3] ) || + (float) $parts[2] <= 0 || + (float) $parts[3] <= 0 + ) { + return null; + } + return implode( ' ', $parts ); + } + + /** + * Determines whether path data is within the block's conservative allowlist. + * + * @param string $path Candidate SVG path data. + * @return bool Whether the path data is safe to store. + */ + private static function is_safe_path( $path ) { + if ( '' === $path || self::MAX_LENGTH < strlen( $path ) ) { + return false; + } + + $matches = array(); + if ( ! preg_match_all( '/[AaCcHhLlMmQqSsTtVvZz]|' . self::NUMBER_PATTERN . '_', $path, $matches, PREG_OFFSET_CAPTURE ) ) { + return false; + } + + $tokens = array(); + $cursor = 0; + foreach ( $matches[0] as $match ) { + $token = $match[0]; + $offset = $match[1]; + if ( ! self::is_legal_path_separator( substr( $path, $cursor, $offset - $cursor ), end( $tokens ), $token ) ) { + return false; + } + $tokens[] = $token; + $cursor = $offset + strlen( $token ); + } + + if ( ! $tokens || ! preg_match( '/^\s*$/', substr( $path, $cursor ) ) ) { + return false; + } + + $command = null; + $argument_index = 0; + $has_move = false; + $has_drawable_segment = false; + $ends_with_close = false; + + foreach ( $tokens as $token ) { + if ( self::is_path_command( $token ) ) { + if ( $command && ! self::is_complete_argument_run( $command, $argument_index ) ) { + return false; + } + + $command = strtoupper( $token ); + $argument_index = 0; + if ( 'Z' === $command ) { + $command = null; + $ends_with_close = true; + continue; + } + + $ends_with_close = false; + if ( ! $has_move && 'M' !== $command ) { + return false; + } + if ( 'M' === $command ) { + $has_move = true; + } + continue; + } + + if ( ! $command || ! self::is_safe_number( $token ) ) { + return false; + } + + if ( + 'A' === $command && + ( 3 === $argument_index % self::PATH_ARGUMENT_COUNTS['A'] || 4 === $argument_index % self::PATH_ARGUMENT_COUNTS['A'] ) && + '0' !== $token && + '1' !== $token + ) { + return false; + } + + if ( 'M' !== $command || $argument_index >= self::PATH_ARGUMENT_COUNTS['M'] ) { + $has_drawable_segment = true; + } + ++$argument_index; + $ends_with_close = false; + } + + return $has_move && + $has_drawable_segment && + ( $ends_with_close || ( $command && self::is_complete_argument_run( $command, $argument_index ) ) ); + } + + /** + * Determines whether a command's arguments form one or more complete runs. + * + * @param string $command Uppercase path command. + * @param int $argument_index Number of arguments consumed for the command. + * @return bool Whether the argument count is a positive multiple of the command's arity. + */ + private static function is_complete_argument_run( $command, $argument_index ) { + $count = isset( self::PATH_ARGUMENT_COUNTS[ $command ] ) ? self::PATH_ARGUMENT_COUNTS[ $command ] : 0; + if ( $count < 1 ) { + return false; + } + + return $argument_index > 0 && 0 === $argument_index % $count; + } + + /** + * Determines whether a token is a finite SVG number accepted by the editor. + * + * @param string $value Candidate numeric token. + * @return bool Whether the token is a finite SVG number. + */ + private static function is_safe_number( $value ) { + return 1 === preg_match( '/^' . self::NUMBER_PATTERN . '$/', $value ) && is_finite( (float) $value ); + } + + /** + * Determines whether a token is an allowed SVG path command. + * + * @param string $token Candidate command token. + * @return bool Whether the token is a supported path command. + */ + private static function is_path_command( $token ) { + return 1 === preg_match( '/^[AaCcHhLlMmQqSsTtVvZz]$/', $token ); + } + + /** + * Matches the editor's separator rules between path data tokens. + * + * @param string $separator Text between two path tokens. + * @param string|false $previous Previous token, if any. + * @param string $next Next token. + * @return bool Whether the separator is allowed. + */ + private static function is_legal_path_separator( $separator, $previous, $next ) { + if ( '' === $separator || preg_match( '/^\s+$/', $separator ) ) { + return true; + } + + // Compare against '' rather than relying on truthiness: the token '0' is + // falsy in PHP, which would reject ordinary path data such as 'M0,0'. + return 1 === preg_match( '/^\s*,\s*$/', $separator ) && + is_string( $previous ) && '' !== $previous && + is_string( $next ) && '' !== $next && + ! self::is_path_command( $previous ) && + ! self::is_path_command( $next ); + } +} diff --git a/package-lock.json b/package-lock.json index 52e2bf5..dd398ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,24 @@ { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.0", "devDependencies": { "@playwright/test": "^1.52.0", "@wordpress/e2e-test-utils-playwright": "^1.50.0", "@wordpress/env": "^10.0.0", "@wordpress/icons": "^10.0.0", - "@wordpress/scripts": "^30.0.0", + "@wordpress/scripts": "^31.8.0", "adm-zip": "^0.5.18", "classnames": "^2.3.2", "countup.js": "^2.9.0", "glob": "^11.0.0", "leaflet": "^1.9.4", + "prettier": "^3.9.5", "typescript": "^5.4.0" } }, @@ -35,6 +36,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2071,6 +2093,78 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -2324,9 +2418,9 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2514,9 +2608,9 @@ } }, "node_modules/@humanwhocodes/config-array/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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3070,6 +3164,230 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -3131,6 +3449,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -5980,16 +6322,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -6230,9 +6562,9 @@ } }, "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { @@ -6809,9 +7141,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -7367,9 +7699,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.50.0.tgz", - "integrity": "sha512-nXF+cu0NA9lk4GPO+/iv3mMt1TV9zzjMalfaNPfafWz5VDGW68h82Le8wqclTewex/99kYWl12kHwDIx66hw6Q==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.51.0.tgz", + "integrity": "sha512-blv2dA2gH9XzD71jiX5rI68Xjioais+n4UC8+wSVcGmHzcVuOHta/serOD8nYzQL0+HOv59O29uzXGONKDWzNg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7379,9 +7711,9 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^6.50.0", - "@wordpress/warning": "^3.50.0", - "browserslist": "^4.21.10", + "@wordpress/browserslist-config": "^6.51.0", + "@wordpress/warning": "^3.51.0", + "browserslist": "^4.28.4", "core-js": "^3.31.0", "react": "^18.3.1" }, @@ -7402,9 +7734,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.50.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.50.0.tgz", - "integrity": "sha512-/5Zn/uIvVw37iLJRa/HhqHQ/DJq4KWjEXTkFQ/NpCNFQm6ll54uDgYZ9f0tWQM7ORCzCU32z/IJ8ujZaMLA9nQ==", + "version": "6.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.51.0.tgz", + "integrity": "sha512-/siYL1d2O/evfWkXIDuhIVfHHBYE0T8hiD4JD8xm6JGe9Z2zHikveVT/AvJZrIzUBJknEIADyFE+cXimk97GkA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7819,17 +8151,18 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "22.22.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-22.22.0.tgz", - "integrity": "sha512-DLGm5i8Gn0vjkZGKF49U2pYME5Jl9AvmoMJB2G508d+sB/oTSkPmM0baUP7G5zxbd1aqfNTaD0KjdyGyWFFKOA==", + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-24.5.0.tgz", + "integrity": "sha512-Kd6DReqgLib710txDLFhhktNOFBYzR3Tv4hgeNJ4S3JGpflrq6Cvoku7SL3wBbugddF/1u6dF1B5+0utX0nwdQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "7.25.7", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", - "@wordpress/babel-preset-default": "^8.36.0", - "@wordpress/prettier-config": "^4.36.0", + "@wordpress/babel-preset-default": "^8.43.0", + "@wordpress/prettier-config": "^4.43.0", + "@wordpress/theme": "^0.10.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^8.3.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -7863,6 +8196,33 @@ } } }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/theme": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-0.10.0.tgz", + "integrity": "sha512-U8CaRvGzeQtFfGQFsKarcbzPEH+jfXJmpOlIpt4bq2goW9CgeWFlDC29p0oyzoMn1Ga9hX+c8ay3nUgSbhmSSA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/element": "^6.43.0", + "@wordpress/private-apis": "^1.43.0", + "colorjs.io": "^0.6.0", + "memize": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "stylelint": "^16.8.2" + }, + "peerDependenciesMeta": { + "stylelint": { + "optional": true + } + } + }, "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -8033,9 +8393,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.50.0.tgz", - "integrity": "sha512-Q48oFBORY0TiVWIebPOG0ynjUAq1Hi9gXKJ69xw2i/UwOwghLOXXFLdAQhS4bJXOw0cdV5xiBaXEQYU+82r5hA==", + "version": "4.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.51.0.tgz", + "integrity": "sha512-V6bsx/WImZmxaiMG7DOA4z8G76eFxlmJ/ZqZRN7tAja7jHPfliuRtCZI2CJBx0c0fT3zGsq8xkC4bmDlMra65A==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8117,25 +8477,25 @@ } }, "node_modules/@wordpress/scripts": { - "version": "30.27.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.27.0.tgz", - "integrity": "sha512-gXGptazCxAaR7g8kcN5joj7B5fCm0VeBHOmnDBs2dbQ4W4F3tfzdg6CTEj8LonF9bWQXlSy3ku8EqWCdkSG9Xw==", + "version": "31.8.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-31.8.0.tgz", + "integrity": "sha512-cV/P5YDB6HZaY2JxdXu5pT1mwH4QG47WA7N91b+fTwOM6o4Jmk2///70bDkYaDOpd9hUlaesdisjP/c4DhriIw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.34.0", - "@wordpress/browserslist-config": "^6.34.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.34.0", - "@wordpress/e2e-test-utils-playwright": "^1.34.0", - "@wordpress/eslint-plugin": "^22.20.0", - "@wordpress/jest-preset-default": "^12.34.0", - "@wordpress/npm-package-json-lint-config": "^5.34.0", - "@wordpress/postcss-plugins-preset": "^5.34.0", - "@wordpress/prettier-config": "^4.34.0", - "@wordpress/stylelint-config": "^23.26.0", + "@wordpress/babel-preset-default": "^8.43.0", + "@wordpress/browserslist-config": "^6.43.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.43.0", + "@wordpress/e2e-test-utils-playwright": "^1.43.0", + "@wordpress/eslint-plugin": "^24.5.0", + "@wordpress/jest-preset-default": "^12.43.0", + "@wordpress/npm-package-json-lint-config": "^5.43.0", + "@wordpress/postcss-plugins-preset": "^5.43.0", + "@wordpress/prettier-config": "^4.43.0", + "@wordpress/stylelint-config": "^23.35.0", "adm-zip": "^0.5.9", "babel-jest": "29.7.0", "babel-loader": "9.2.1", @@ -8148,13 +8508,13 @@ "cssnano": "^6.0.1", "cwd": "^0.10.0", "dir-glob": "^3.0.1", - "eslint": "^8.3.0", + "eslint": "^8.57.1", "expect-puppeteer": "^4.4.0", "fast-glob": "^3.2.7", "filenamify": "^4.2.0", "jest": "^29.6.2", "jest-dev-server": "^10.1.4", - "jest-environment-jsdom": "^29.6.2", + "jest-environment-jsdom": "^30.2.0", "jest-environment-node": "^29.6.2", "json2php": "^0.0.9", "markdownlint-cli": "^0.31.1", @@ -8191,8 +8551,8 @@ "npm": ">=8.19.2" }, "peerDependencies": { - "@playwright/test": "^1.56.1", - "@wordpress/env": "^10.0.0", + "@playwright/test": "^1.58.2", + "@wordpress/env": ">=10.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" }, @@ -8202,6 +8562,23 @@ } } }, + "node_modules/@wordpress/scripts/node_modules/prettier": { + "name": "wp-prettier", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/wp-prettier/-/wp-prettier-3.0.3.tgz", + "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@wordpress/style-runtime": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.5.0.tgz", @@ -8316,9 +8693,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.50.0.tgz", - "integrity": "sha512-6X3ioKOGfmRuZFngmG2QZZW9CBVMTBr5FXqs6Q3li5ryhnAqn3u/ocqsx556YnB5dYdVxK14TiH9o7DDElt8gw==", + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.51.0.tgz", + "integrity": "sha512-wWeM6pjAWbMhdNfgCaxi5yhLzomj6/trcIjGPi2Q4kaIuxUula8Ybq0ZPn5lYuc19ICkcGYcAnWNYz/4mYCHdA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8513,17 +8890,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-import-attributes": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", @@ -11093,33 +11459,20 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -11159,18 +11512,17 @@ } }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -11637,20 +11989,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -12041,9 +12379,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", - "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "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": { @@ -12440,9 +12778,9 @@ } }, "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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -12711,9 +13049,9 @@ } }, "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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -12828,9 +13166,9 @@ } }, "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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -12947,9 +13285,9 @@ "license": "Python-2.0" }, "node_modules/eslint/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.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -14694,16 +15032,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-entities": { @@ -14811,18 +15149,27 @@ } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/http-proxy-middleware": { @@ -16262,26 +16609,21 @@ } }, "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -16289,6 +16631,202 @@ } } }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -16786,44 +17324,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -16831,6 +17363,30 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -19293,20 +19849,6 @@ "node": ">= 14" } }, - "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -20589,10 +21131,9 @@ } }, "node_modules/prettier": { - "name": "wp-prettier", - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/wp-prettier/-/wp-prettier-3.0.3.tgz", - "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", "bin": { @@ -20750,20 +21291,6 @@ "node": ">= 14" } }, - "node_modules/proxy-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/proxy-agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -20795,19 +21322,6 @@ "dev": true, "license": "MIT" }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -20908,13 +21422,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -21016,6 +21523,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "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/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -21580,6 +22103,13 @@ "node": ">=10.0.0" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -24065,6 +24595,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, "node_modules/tldts-core": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", @@ -24082,6 +24625,13 @@ "tldts-core": "^7.4.6" } }, + "node_modules/tldts/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -24158,42 +24708,29 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tree-kill": { @@ -24805,17 +25342,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-memo-one": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", @@ -24912,16 +25438,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/wait-on": { @@ -25436,9 +25962,9 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", @@ -25446,7 +25972,7 @@ "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-encoding/node_modules/iconv-lite": { @@ -25463,27 +25989,27 @@ } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/when-exit": { @@ -25716,13 +26242,13 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xml-naming": { diff --git a/package.json b/package.json index 3f2a56d..5bd9e72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.0", "private": true, "files": [ "airo-wp.php", @@ -20,26 +20,34 @@ "build": "wp-scripts build", "build:zip": "npm run build && node tests/e2e/setup/build-zip.mjs", "start": "wp-scripts start", - "test:unit": "node tests/scripts/wp-env-exec.mjs test", - "lint": "node tests/scripts/wp-env-exec.mjs lint", - "format": "wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer format || true", + "lint": "npm run lint:php && npm run lint:js && npm run lint:style", + "lint:php": "node tests/scripts/wp-env-exec.mjs composer lint", + "lint:js": "wp-scripts lint-js src/", + "lint:style": "wp-scripts lint-style 'src/**/*.scss'", + "format": "npm run format:js && npm run format:css && npm run format:php", + "format:php": "node tests/scripts/wp-env-exec.mjs composer format", + "format:js": "wp-scripts lint-js --fix src/ || true", + "format:css": "wp-scripts lint-style --fix 'src/**/*.scss' || true", + "test:unit:php": "node tests/scripts/wp-env-exec.mjs composer test", "test:e2e": "node tests/scripts/e2e.mjs", "test:e2e:debug": "node tests/scripts/e2e.mjs --debug", "plugin-check": "node tests/scripts/plugin-check.mjs", + "task:pre-release": "node tests/scripts/pre-release.mjs", "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop" }, "devDependencies": { "@playwright/test": "^1.52.0", - "adm-zip": "^0.5.18", "@wordpress/e2e-test-utils-playwright": "^1.50.0", "@wordpress/env": "^10.0.0", "@wordpress/icons": "^10.0.0", - "@wordpress/scripts": "^30.0.0", + "@wordpress/scripts": "^31.8.0", + "adm-zip": "^0.5.18", "classnames": "^2.3.2", "countup.js": "^2.9.0", "glob": "^11.0.0", "leaflet": "^1.9.4", + "prettier": "^3.9.5", "typescript": "^5.4.0" } } diff --git a/patterns/contact/contact-consultation-form.php b/patterns/contact/contact-consultation-form.php index 4b50d77..540e942 100644 --- a/patterns/contact/contact-consultation-form.php +++ b/patterns/contact/contact-consultation-form.php @@ -60,8 +60,8 @@

Schedule a Consultation

- -
+ +
diff --git a/patterns/contact/contact-consultation.php b/patterns/contact/contact-consultation.php index c312ba9..47c60d3 100644 --- a/patterns/contact/contact-consultation.php +++ b/patterns/contact/contact-consultation.php @@ -65,8 +65,8 @@

Request a Consultation

- -
+ +
diff --git a/patterns/contact/contact-form.php b/patterns/contact/contact-form.php index 389a8d3..d7e9522 100644 --- a/patterns/contact/contact-form.php +++ b/patterns/contact/contact-form.php @@ -26,8 +26,8 @@
-
-
+
+
diff --git a/patterns/contact/contact-map-split.php b/patterns/contact/contact-map-split.php index f0632cc..7da36a0 100644 --- a/patterns/contact/contact-map-split.php +++ b/patterns/contact/contact-map-split.php @@ -34,8 +34,8 @@

Send Us a Message

- -
+ +
diff --git a/patterns/contact/contact-split.php b/patterns/contact/contact-split.php index 2b6585b..892bd9c 100644 --- a/patterns/contact/contact-split.php +++ b/patterns/contact/contact-split.php @@ -66,8 +66,8 @@

Send a Message

- -
+ +
diff --git a/patterns/cta/cta-banner.php b/patterns/cta/cta-banner.php index eecb90d..1b709e0 100644 --- a/patterns/cta/cta-banner.php +++ b/patterns/cta/cta-banner.php @@ -26,8 +26,8 @@ -
-
00
Days
00
Hours
00
Min
00
Sec
Offer has ended!
+
+
00
Days
00
Hours
00
Min
00
Sec
Offer has ended!
diff --git a/patterns/cta/cta-countdown.php b/patterns/cta/cta-countdown.php index b42c0e1..a99de19 100644 --- a/patterns/cta/cta-countdown.php +++ b/patterns/cta/cta-countdown.php @@ -25,7 +25,7 @@ -
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
+
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
diff --git a/patterns/cta/cta-newsletter.php b/patterns/cta/cta-newsletter.php index df942fb..de8022a 100644 --- a/patterns/cta/cta-newsletter.php +++ b/patterns/cta/cta-newsletter.php @@ -48,8 +48,8 @@ -
-
+
+
diff --git a/patterns/cta/cta-volunteer-signup.php b/patterns/cta/cta-volunteer-signup.php index c958649..f57414c 100644 --- a/patterns/cta/cta-volunteer-signup.php +++ b/patterns/cta/cta-volunteer-signup.php @@ -60,8 +60,8 @@

Sign Up to Volunteer

- -
+ +
diff --git a/patterns/features/features-grid.php b/patterns/features/features-grid.php index c0b0919..2ae1b2b 100644 --- a/patterns/features/features-grid.php +++ b/patterns/features/features-grid.php @@ -25,27 +25,27 @@ -
+

Lightning Fast

Optimized for performance with lazy loading, minimal CSS, and efficient JavaScript.

- +

Fully Responsive

Every block adapts beautifully to any screen size, from mobile to desktop.

- +

Accessible

Built with WCAG guidelines in mind, ensuring your site is usable by everyone.

- +

Easy to Customize

Adjust colors, spacing, typography, and more using the familiar WordPress interface.

- +

Theme Compatible

Works seamlessly with any WordPress theme that supports the block editor.

- +

Regular Updates

Constantly improved with new features, bug fixes, and WordPress compatibility.

diff --git a/patterns/hero/hero-event-conference.php b/patterns/hero/hero-event-conference.php index 35f8182..d47653f 100644 --- a/patterns/hero/hero-event-conference.php +++ b/patterns/hero/hero-event-conference.php @@ -26,7 +26,7 @@ -
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
+
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
diff --git a/patterns/hero/hero-event-countdown.php b/patterns/hero/hero-event-countdown.php index c43806c..71e5192 100644 --- a/patterns/hero/hero-event-countdown.php +++ b/patterns/hero/hero-event-countdown.php @@ -28,7 +28,7 @@
-
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
+
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
diff --git a/patterns/hero/hero-video-modal.php b/patterns/hero/hero-video-modal.php index 2fd47c4..a1d1d51 100644 --- a/patterns/hero/hero-video-modal.php +++ b/patterns/hero/hero-video-modal.php @@ -28,7 +28,7 @@ -