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..94fa9a2 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,32 @@ 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 build + - 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 tests/scripts/configure-wp-env.mjs --php '${{ matrix.php }}' --core "$WP_CORE" - 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 +139,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..4ef836a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.3.1 + +- Maintenance release: release-pipeline and test-lane fixes only, no user-facing changes + +## 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..5fd337e 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.1 | | **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..8f7aacb 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.1 * 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.1' ); } 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/FilterIndexHooks.php b/includes/Blocks/Common/Query/FilterIndexHooks.php index 0d918a6..bd63092 100644 --- a/includes/Blocks/Common/Query/FilterIndexHooks.php +++ b/includes/Blocks/Common/Query/FilterIndexHooks.php @@ -20,6 +20,16 @@ */ class FilterIndexHooks { + /** + * The airo-wp/query-filter kinds that are driven by a taxonomy. + * + * `search`, `sort`, `active` and `reset` carry the `taxonomy` attribute + * too — it has a default and every block gets one — but never read it. + * + * @var string[] + */ + private const TAXONOMY_FILTER_KINDS = array( 'checkbox', 'select' ); + /** * Tracks whether the hook set has been registered in this request. * A single flag (not per-hook has_action checks) so a caller that removes @@ -142,16 +152,27 @@ private static function register_filters_from_post_blocks( \WP_Post $post ): voi private static function walk_blocks_for_filters( array $blocks ): void { foreach ( $blocks as $block ) { if ( 'airo-wp/query-filter' === ( $block['blockName'] ?? '' ) ) { - $attrs = $block['attrs'] ?? array(); - $taxonomy = sanitize_key( (string) ( $attrs['taxonomy'] ?? '' ) ); - if ( '' !== $taxonomy ) { - FilterRegistry::register( - $taxonomy, - array( - 'type' => 'taxonomy', - 'source' => $taxonomy, - ) - ); + $attrs = self::with_block_defaults( + 'airo-wp/query-filter', + isset( $block['attrs'] ) ? (array) $block['attrs'] : array() + ); + + // Only the taxonomy-driven kinds mean anything here. Reading + // defaults (above) makes `taxonomy` always present, so without + // this gate a search or sort filter would register a category + // filter it never uses. + $kind = isset( $attrs['filterKind'] ) ? (string) $attrs['filterKind'] : ''; + if ( in_array( $kind, self::TAXONOMY_FILTER_KINDS, true ) ) { + $taxonomy = sanitize_key( (string) ( $attrs['taxonomy'] ?? '' ) ); + if ( '' !== $taxonomy ) { + FilterRegistry::register( + $taxonomy, + array( + 'type' => 'taxonomy', + 'source' => $taxonomy, + ) + ); + } } } if ( ! empty( $block['innerBlocks'] ) ) { @@ -160,6 +181,44 @@ private static function walk_blocks_for_filters( array $blocks ): void { } } + /** + * Merge a parsed block's attributes over its registered defaults. + * + * `parse_blocks()` returns only what the block comment carries, and + * WordPress never serializes an attribute whose value equals its + * `block.json` default. Reading `$attrs['taxonomy']` directly therefore + * misses every filter left on the default taxonomy — which is `category`, + * the overwhelmingly common case. The symptom was silent: the filter never + * registered, so `wp dsgo query index rebuild` reported "Indexed N objects + * (0 rows)" and counts never rendered, with nothing anywhere saying why. + * + * Falls back to the raw attributes when the block type is not registered + * yet, which is no worse than the behaviour this replaces. + * + * @param string $block_name Block type name. + * @param array $attrs Attributes from parse_blocks(). + * @return array Attributes with registered defaults filled in. + */ + private static function with_block_defaults( string $block_name, array $attrs ): array { + if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { + return $attrs; + } + + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); + if ( ! $block_type || empty( $block_type->attributes ) ) { + return $attrs; + } + + $defaults = array(); + foreach ( (array) $block_type->attributes as $key => $schema ) { + if ( is_array( $schema ) && array_key_exists( 'default', $schema ) ) { + $defaults[ $key ] = $schema['default']; + } + } + + return array_merge( $defaults, $attrs ); + } + /** * Removes a post's index rows when the post is force-deleted. * 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..3dfa3d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,24 @@ { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.1", "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..ea7283b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "airo-wp", - "version": "0.2.5", + "version": "0.3.1", "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/content/content-about-blobs.php b/patterns/content/content-about-blobs.php index ccb68f7..4d57471 100644 --- a/patterns/content/content-about-blobs.php +++ b/patterns/content/content-about-blobs.php @@ -94,7 +94,7 @@
- +
diff --git a/patterns/content/content-campaign-progress.php b/patterns/content/content-campaign-progress.php index 3f9af75..4f9c114 100644 --- a/patterns/content/content-campaign-progress.php +++ b/patterns/content/content-campaign-progress.php @@ -34,19 +34,19 @@ - diff --git a/patterns/content/content-coaching-split.php b/patterns/content/content-coaching-split.php index 5cea079..2e3cfe9 100644 --- a/patterns/content/content-coaching-split.php +++ b/patterns/content/content-coaching-split.php @@ -68,7 +68,7 @@
- +
diff --git a/patterns/content/content-course-cards.php b/patterns/content/content-course-cards.php index 3e7658c..1dd3e61 100644 --- a/patterns/content/content-course-cards.php +++ b/patterns/content/content-course-cards.php @@ -26,7 +26,7 @@
- +
diff --git a/patterns/content/content-image-overlay.php b/patterns/content/content-image-overlay.php index 16e4d78..35dcd8d 100644 --- a/patterns/content/content-image-overlay.php +++ b/patterns/content/content-image-overlay.php @@ -29,11 +29,11 @@ -
+
- +
diff --git a/patterns/content/content-location-hours.php b/patterns/content/content-location-hours.php index 441dd5d..3f482c3 100644 --- a/patterns/content/content-location-hours.php +++ b/patterns/content/content-location-hours.php @@ -68,7 +68,7 @@
- +
diff --git a/patterns/content/content-menu-dishes.php b/patterns/content/content-menu-dishes.php index 91e0e93..4e4e83f 100644 --- a/patterns/content/content-menu-dishes.php +++ b/patterns/content/content-menu-dishes.php @@ -91,7 +91,7 @@ -
diff --git a/patterns/content/content-mission-statement.php b/patterns/content/content-mission-statement.php index 4b68600..6b3ed7e 100644 --- a/patterns/content/content-mission-statement.php +++ b/patterns/content/content-mission-statement.php @@ -32,7 +32,7 @@

Our approach focuses on long-term solutions that empower communities to thrive independently. We invest in local leaders, provide training and resources, and measure our impact to ensure every dollar makes a difference.

- +
diff --git a/patterns/content/content-product-cards.php b/patterns/content/content-product-cards.php index e61114d..a072d89 100644 --- a/patterns/content/content-product-cards.php +++ b/patterns/content/content-product-cards.php @@ -26,7 +26,7 @@
- + diff --git a/patterns/content/content-progress-skills.php b/patterns/content/content-progress-skills.php index cfd0437..7fac657 100644 --- a/patterns/content/content-progress-skills.php +++ b/patterns/content/content-progress-skills.php @@ -24,7 +24,7 @@

We have honed our skills over years of experience to deliver exceptional results in every project we undertake.

- + diff --git a/patterns/content/content-slider-cards.php b/patterns/content/content-slider-cards.php index e2c3d08..53a3a7b 100644 --- a/patterns/content/content-slider-cards.php +++ b/patterns/content/content-slider-cards.php @@ -37,7 +37,7 @@

Our optimized infrastructure ensures sub-second load times for all your pages and assets.

- +
@@ -55,7 +55,7 @@

Bank-level encryption and security protocols protect your data around the clock.

- +
@@ -73,7 +73,7 @@

Work together seamlessly with real-time editing and commenting features.

- +
@@ -91,7 +91,7 @@

Track performance metrics and gain actionable insights with our powerful dashboard.

- +
@@ -109,7 +109,7 @@

Content delivered from edge locations worldwide for minimal latency everywhere.

- +
diff --git a/patterns/content/content-split-image.php b/patterns/content/content-split-image.php index db6a2b4..0e13024 100644 --- a/patterns/content/content-split-image.php +++ b/patterns/content/content-split-image.php @@ -52,7 +52,7 @@ - +
diff --git a/patterns/content/progress-skills.php b/patterns/content/progress-skills.php index 4603f1d..486cbc3 100644 --- a/patterns/content/progress-skills.php +++ b/patterns/content/progress-skills.php @@ -24,7 +24,7 @@

We have honed our skills over years of experience to deliver exceptional results in every project we undertake.

- + diff --git a/patterns/cta/cta-banner-gradient.php b/patterns/cta/cta-banner-gradient.php index 7a0b9ca..665b498 100644 --- a/patterns/cta/cta-banner-gradient.php +++ b/patterns/cta/cta-banner-gradient.php @@ -25,11 +25,11 @@ -
+
- +
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-contact-split.php b/patterns/cta/cta-contact-split.php index cdce631..60990e1 100644 --- a/patterns/cta/cta-contact-split.php +++ b/patterns/cta/cta-contact-split.php @@ -28,7 +28,7 @@

Let us discuss your project and explore how we can help you achieve your goals.

- + diff --git a/patterns/cta/cta-countdown.php b/patterns/cta/cta-countdown.php index b42c0e1..95450c7 100644 --- a/patterns/cta/cta-countdown.php +++ b/patterns/cta/cta-countdown.php @@ -25,15 +25,15 @@ -
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-donation.php b/patterns/cta/cta-donation.php index af332a6..7e61658 100644 --- a/patterns/cta/cta-donation.php +++ b/patterns/cta/cta-donation.php @@ -24,7 +24,7 @@ - diff --git a/patterns/cta/cta-enrollment.php b/patterns/cta/cta-enrollment.php index 04f8c47..00fe153 100644 --- a/patterns/cta/cta-enrollment.php +++ b/patterns/cta/cta-enrollment.php @@ -24,11 +24,11 @@ - diff --git a/patterns/cta/cta-event-register.php b/patterns/cta/cta-event-register.php index 153e990..c9a6ee3 100644 --- a/patterns/cta/cta-event-register.php +++ b/patterns/cta/cta-event-register.php @@ -24,7 +24,7 @@ - diff --git a/patterns/cta/cta-get-started.php b/patterns/cta/cta-get-started.php index 18794cf..c14d384 100644 --- a/patterns/cta/cta-get-started.php +++ b/patterns/cta/cta-get-started.php @@ -23,11 +23,11 @@ -
+ diff --git a/patterns/cta/cta-gradient.php b/patterns/cta/cta-gradient.php index dcf3851..5c554cb 100644 --- a/patterns/cta/cta-gradient.php +++ b/patterns/cta/cta-gradient.php @@ -24,11 +24,11 @@ - diff --git a/patterns/cta/cta-newsletter-subscribe.php b/patterns/cta/cta-newsletter-subscribe.php index 90aa2b1..bf964ab 100644 --- a/patterns/cta/cta-newsletter-subscribe.php +++ b/patterns/cta/cta-newsletter-subscribe.php @@ -24,7 +24,7 @@ -
+ 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-reservation.php b/patterns/cta/cta-reservation.php index cfc1d63..ddac409 100644 --- a/patterns/cta/cta-reservation.php +++ b/patterns/cta/cta-reservation.php @@ -24,7 +24,7 @@ - diff --git a/patterns/cta/cta-saas-transform.php b/patterns/cta/cta-saas-transform.php index b51046e..386ff4c 100644 --- a/patterns/cta/cta-saas-transform.php +++ b/patterns/cta/cta-saas-transform.php @@ -23,11 +23,11 @@ - diff --git a/patterns/cta/cta-split.php b/patterns/cta/cta-split.php index e969698..97623b3 100644 --- a/patterns/cta/cta-split.php +++ b/patterns/cta/cta-split.php @@ -29,11 +29,11 @@ -
+ diff --git a/patterns/cta/cta-transformation.php b/patterns/cta/cta-transformation.php index ed88771..db52d7a 100644 --- a/patterns/cta/cta-transformation.php +++ b/patterns/cta/cta-transformation.php @@ -24,7 +24,7 @@ - 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/cta/cta-work-together.php b/patterns/cta/cta-work-together.php index 45773b3..75b99da 100644 --- a/patterns/cta/cta-work-together.php +++ b/patterns/cta/cta-work-together.php @@ -28,29 +28,29 @@ -
+ -
+
- + - + - +
diff --git a/patterns/faq/accordion-faq.php b/patterns/faq/accordion-faq.php index 54a113f..aa1e690 100644 --- a/patterns/faq/accordion-faq.php +++ b/patterns/faq/accordion-faq.php @@ -28,7 +28,7 @@

Can\'t find what you\'re looking for? Feel free to contact our support team for personalized assistance.

- +
diff --git a/patterns/faq/faq-accordion-animated.php b/patterns/faq/faq-accordion-animated.php index e7318c8..ae008dc 100644 --- a/patterns/faq/faq-accordion-animated.php +++ b/patterns/faq/faq-accordion-animated.php @@ -28,7 +28,7 @@

Can\'t find what you\'re looking for? Feel free to contact our support team for personalized assistance.

- +
diff --git a/patterns/features/features-accordion.php b/patterns/features/features-accordion.php index 1aa0510..5966059 100644 --- a/patterns/features/features-accordion.php +++ b/patterns/features/features-accordion.php @@ -24,7 +24,7 @@

Explore the powerful capabilities that make our platform the choice of professionals worldwide.

- +
diff --git a/patterns/features/features-class-types.php b/patterns/features/features-class-types.php index 5d8d4de..47364b5 100644 --- a/patterns/features/features-class-types.php +++ b/patterns/features/features-class-types.php @@ -95,7 +95,7 @@ - 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/footer/footer-dark.php b/patterns/footer/footer-dark.php index 2b3ff5f..662be84 100644 --- a/patterns/footer/footer-dark.php +++ b/patterns/footer/footer-dark.php @@ -22,7 +22,7 @@
diff --git a/patterns/gallery/gallery-portfolio-projects.php b/patterns/gallery/gallery-portfolio-projects.php index 3e5d6f4..e48b53e 100644 --- a/patterns/gallery/gallery-portfolio-projects.php +++ b/patterns/gallery/gallery-portfolio-projects.php @@ -26,7 +26,7 @@
- +
diff --git a/patterns/gallery/gallery-property-showcase.php b/patterns/gallery/gallery-property-showcase.php index 3bd1143..7b1c8c0 100644 --- a/patterns/gallery/gallery-property-showcase.php +++ b/patterns/gallery/gallery-property-showcase.php @@ -83,7 +83,7 @@ - diff --git a/patterns/gallery/gallery-selected-work.php b/patterns/gallery/gallery-selected-work.php index 1d6b386..82850b5 100644 --- a/patterns/gallery/gallery-selected-work.php +++ b/patterns/gallery/gallery-selected-work.php @@ -26,7 +26,7 @@
- +
diff --git a/patterns/header/header-cta-overlay.php b/patterns/header/header-cta-overlay.php index 0878354..8db6d85 100644 --- a/patterns/header/header-cta-overlay.php +++ b/patterns/header/header-cta-overlay.php @@ -23,7 +23,7 @@
diff --git a/patterns/header/header-cta.php b/patterns/header/header-cta.php index fece71f..9060b31 100644 --- a/patterns/header/header-cta.php +++ b/patterns/header/header-cta.php @@ -23,7 +23,7 @@
diff --git a/patterns/header/header-dark-overlay.php b/patterns/header/header-dark-overlay.php index 5b4cce6..558fee5 100644 --- a/patterns/header/header-dark-overlay.php +++ b/patterns/header/header-dark-overlay.php @@ -23,7 +23,7 @@ diff --git a/patterns/header/header-dark.php b/patterns/header/header-dark.php index 14c7cb1..17c9a74 100644 --- a/patterns/header/header-dark.php +++ b/patterns/header/header-dark.php @@ -23,7 +23,7 @@ diff --git a/patterns/header/header-topbar.php b/patterns/header/header-topbar.php index ba918f9..391b63c 100644 --- a/patterns/header/header-topbar.php +++ b/patterns/header/header-topbar.php @@ -41,7 +41,7 @@ diff --git a/patterns/headings/hero-centered-heading.php b/patterns/headings/hero-centered-heading.php index e3349e8..72d2ef5 100644 --- a/patterns/headings/hero-centered-heading.php +++ b/patterns/headings/hero-centered-heading.php @@ -13,7 +13,7 @@ 'title' => __( 'Hero Centered Heading', 'airo-wp' ), 'categories' => array( 'airo-wp-headings' ), 'viewportWidth' => 1200, - 'content' => '

Design.Build.Launch.

Everything you need to create stunning websites that convert visitors into customers.

+ 'content' => '

Design.Build.Launch.

Everything you need to create stunning websites that convert visitors into customers.

', ); diff --git a/patterns/headings/hero-split-heading.php b/patterns/headings/hero-split-heading.php index 700b106..758c775 100644 --- a/patterns/headings/hero-split-heading.php +++ b/patterns/headings/hero-split-heading.php @@ -13,9 +13,9 @@ 'title' => __( 'Hero Split Heading', 'airo-wp' ), 'categories' => array( 'airo-wp-headings' ), 'viewportWidth' => 1200, - 'content' => '

You need apowerful website

Launch faster with beautiful, conversion-focused designs that adapt to any brand and speak to your audience.

+ 'content' => '

You need apowerful website

Launch faster with beautiful, conversion-focused designs that adapt to any brand and speak to your audience.

', ); diff --git a/patterns/hero/hero-agency-dark.php b/patterns/hero/hero-agency-dark.php index 0cca67b..dce663a 100644 --- a/patterns/hero/hero-agency-dark.php +++ b/patterns/hero/hero-agency-dark.php @@ -29,11 +29,11 @@ - diff --git a/patterns/hero/hero-bento.php b/patterns/hero/hero-bento.php index 88a5b75..16308a1 100644 --- a/patterns/hero/hero-bento.php +++ b/patterns/hero/hero-bento.php @@ -13,7 +13,7 @@ 'title' => __( 'Hero Bento', 'airo-wp' ), 'categories' => array( 'airo-wp-hero' ), 'viewportWidth' => 1200, - 'content' => '

Strategic growth solutions built for ambitious startups.

We partner with founders and teams to unlock potential, streamline operations, and accelerate sustainable business growth.

+ 'content' => '

Strategic growth solutions built for ambitious startups.

We partner with founders and teams to unlock potential, streamline operations, and accelerate sustainable business growth.

Modern collaborative workspace with warm wood accents and natural light
', ); diff --git a/patterns/hero/hero-centered.php b/patterns/hero/hero-centered.php index 5c63bfe..2562874 100644 --- a/patterns/hero/hero-centered.php +++ b/patterns/hero/hero-centered.php @@ -24,7 +24,7 @@

Create stunning websites with powerful blocks designed for modern WordPress.

- +
diff --git a/patterns/hero/hero-double-image.php b/patterns/hero/hero-double-image.php index 8b8085c..3cb68c4 100644 --- a/patterns/hero/hero-double-image.php +++ b/patterns/hero/hero-double-image.php @@ -13,7 +13,7 @@ 'title' => __( 'Hero Double Image', 'airo-wp' ), 'categories' => array( 'airo-wp-hero' ), 'viewportWidth' => 1200, - 'content' => '

Elevate Your Space

Thoughtfully designed pieces that transform everyday moments into extraordinary experiences

+ 'content' => '

Elevate Your Space

Thoughtfully designed pieces that transform everyday moments into extraordinary experiences

Modern living room with elegant furniture and warm natural lighting
Curated home accessories and decor with minimalist styling
', ); diff --git a/patterns/hero/hero-ecommerce-store.php b/patterns/hero/hero-ecommerce-store.php index 0e62347..fcb2169 100644 --- a/patterns/hero/hero-ecommerce-store.php +++ b/patterns/hero/hero-ecommerce-store.php @@ -27,11 +27,11 @@ - diff --git a/patterns/hero/hero-education-platform.php b/patterns/hero/hero-education-platform.php index 3006b38..20449e0 100644 --- a/patterns/hero/hero-education-platform.php +++ b/patterns/hero/hero-education-platform.php @@ -27,11 +27,11 @@ -
+ diff --git a/patterns/hero/hero-event-conference.php b/patterns/hero/hero-event-conference.php index 35f8182..fb9357a 100644 --- a/patterns/hero/hero-event-conference.php +++ b/patterns/hero/hero-event-conference.php @@ -26,15 +26,15 @@ -
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..6599802 100644 --- a/patterns/hero/hero-event-countdown.php +++ b/patterns/hero/hero-event-countdown.php @@ -28,17 +28,17 @@
-
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-fitness-bold.php b/patterns/hero/hero-fitness-bold.php index 48cb3bb..7bb1c88 100644 --- a/patterns/hero/hero-fitness-bold.php +++ b/patterns/hero/hero-fitness-bold.php @@ -28,11 +28,11 @@ - diff --git a/patterns/hero/hero-luxury-realestate.php b/patterns/hero/hero-luxury-realestate.php index 7ae6954..d6e2e2c 100644 --- a/patterns/hero/hero-luxury-realestate.php +++ b/patterns/hero/hero-luxury-realestate.php @@ -28,11 +28,11 @@ - diff --git a/patterns/hero/hero-modern-saas.php b/patterns/hero/hero-modern-saas.php index 28c154b..5e7f8c4 100644 --- a/patterns/hero/hero-modern-saas.php +++ b/patterns/hero/hero-modern-saas.php @@ -25,11 +25,11 @@ -
+ diff --git a/patterns/hero/hero-nonprofit-cause.php b/patterns/hero/hero-nonprofit-cause.php index 7a80f88..643f975 100644 --- a/patterns/hero/hero-nonprofit-cause.php +++ b/patterns/hero/hero-nonprofit-cause.php @@ -28,11 +28,11 @@ - diff --git a/patterns/hero/hero-parallax.php b/patterns/hero/hero-parallax.php index 1f50f72..e59cd84 100644 --- a/patterns/hero/hero-parallax.php +++ b/patterns/hero/hero-parallax.php @@ -24,11 +24,11 @@ - diff --git a/patterns/hero/hero-portfolio-personal.php b/patterns/hero/hero-portfolio-personal.php index 3408265..ccc35f0 100644 --- a/patterns/hero/hero-portfolio-personal.php +++ b/patterns/hero/hero-portfolio-personal.php @@ -29,11 +29,11 @@ -
+ @@ -44,15 +44,15 @@ - diff --git a/patterns/hero/hero-professional-services.php b/patterns/hero/hero-professional-services.php index 2ad9934..a6d3278 100644 --- a/patterns/hero/hero-professional-services.php +++ b/patterns/hero/hero-professional-services.php @@ -29,11 +29,11 @@ - diff --git a/patterns/hero/hero-saas-startup.php b/patterns/hero/hero-saas-startup.php index 8291326..b46bf82 100644 --- a/patterns/hero/hero-saas-startup.php +++ b/patterns/hero/hero-saas-startup.php @@ -25,11 +25,11 @@ -
+ diff --git a/patterns/hero/hero-split.php b/patterns/hero/hero-split.php index 9d48117..4d81686 100644 --- a/patterns/hero/hero-split.php +++ b/patterns/hero/hero-split.php @@ -27,7 +27,7 @@ - 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 @@ - @@ -366,7 +366,7 @@

Let us discuss your project and explore how we can help you achieve your goals.

- +
diff --git a/patterns/homepage/homepage-ecommerce.php b/patterns/homepage/homepage-ecommerce.php index 1a65090..9f23396 100644 --- a/patterns/homepage/homepage-ecommerce.php +++ b/patterns/homepage/homepage-ecommerce.php @@ -27,11 +27,11 @@ - @@ -78,7 +78,7 @@
- +
@@ -352,7 +352,7 @@ -
+ diff --git a/patterns/homepage/homepage-education.php b/patterns/homepage/homepage-education.php index 6664622..d7bc5a2 100644 --- a/patterns/homepage/homepage-education.php +++ b/patterns/homepage/homepage-education.php @@ -27,11 +27,11 @@ -
+ @@ -170,7 +170,7 @@
- +
@@ -460,11 +460,11 @@ - diff --git a/patterns/homepage/homepage-event.php b/patterns/homepage/homepage-event.php index 218f518..5caa178 100644 --- a/patterns/homepage/homepage-event.php +++ b/patterns/homepage/homepage-event.php @@ -26,15 +26,15 @@ -
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
+
00
Days
00
Hours
00
Min
00
Sec
The countdown has ended!
- @@ -159,7 +159,7 @@ - @@ -392,7 +392,7 @@
- +
@@ -444,7 +444,7 @@
- +
@@ -490,7 +490,7 @@
- +
@@ -536,7 +536,7 @@ - diff --git a/patterns/homepage/homepage-fitness.php b/patterns/homepage/homepage-fitness.php index 35fa772..396b68e 100644 --- a/patterns/homepage/homepage-fitness.php +++ b/patterns/homepage/homepage-fitness.php @@ -28,11 +28,11 @@ - @@ -141,7 +141,7 @@ - @@ -202,7 +202,7 @@ - + @@ -266,7 +266,7 @@ - + @@ -322,7 +322,7 @@ - + @@ -376,7 +376,7 @@ - + @@ -464,7 +464,7 @@ - diff --git a/patterns/homepage/homepage-luxury-realestate.php b/patterns/homepage/homepage-luxury-realestate.php index 283e601..756b0a9 100644 --- a/patterns/homepage/homepage-luxury-realestate.php +++ b/patterns/homepage/homepage-luxury-realestate.php @@ -28,11 +28,11 @@ - @@ -147,7 +147,7 @@ - @@ -384,8 +384,8 @@

Schedule a Consultation

- -
+ +
diff --git a/patterns/homepage/homepage-modern-saas.php b/patterns/homepage/homepage-modern-saas.php index 1217d37..20697c6 100644 --- a/patterns/homepage/homepage-modern-saas.php +++ b/patterns/homepage/homepage-modern-saas.php @@ -25,11 +25,11 @@ -
+ @@ -318,7 +318,7 @@
- +
@@ -370,7 +370,7 @@
- + @@ -412,7 +412,7 @@ - + @@ -429,11 +429,11 @@ - diff --git a/patterns/homepage/homepage-nonprofit.php b/patterns/homepage/homepage-nonprofit.php index b5e5174..5a4ba25 100644 --- a/patterns/homepage/homepage-nonprofit.php +++ b/patterns/homepage/homepage-nonprofit.php @@ -28,11 +28,11 @@ - @@ -78,7 +78,7 @@

Our approach focuses on long-term solutions that empower communities to thrive independently. We invest in local leaders, provide training and resources, and measure our impact to ensure every dollar makes a difference.

- + @@ -174,19 +174,19 @@ - @@ -314,8 +314,8 @@

Sign Up to Volunteer

- -
+ +
@@ -342,7 +342,7 @@ - diff --git a/patterns/homepage/homepage-portfolio.php b/patterns/homepage/homepage-portfolio.php index 531f5a8..3d313d6 100644 --- a/patterns/homepage/homepage-portfolio.php +++ b/patterns/homepage/homepage-portfolio.php @@ -29,11 +29,11 @@ -
+ @@ -44,15 +44,15 @@ - @@ -204,7 +204,7 @@
- +
@@ -342,29 +342,29 @@ -
+ -
+
- + - + - +
diff --git a/patterns/homepage/homepage-professional.php b/patterns/homepage/homepage-professional.php index f80486f..854de61 100644 --- a/patterns/homepage/homepage-professional.php +++ b/patterns/homepage/homepage-professional.php @@ -29,11 +29,11 @@ - @@ -463,8 +463,8 @@

Request a Consultation

- -
+ +
diff --git a/patterns/homepage/homepage-restaurant.php b/patterns/homepage/homepage-restaurant.php index de00f70..afb3cbf 100644 --- a/patterns/homepage/homepage-restaurant.php +++ b/patterns/homepage/homepage-restaurant.php @@ -28,11 +28,11 @@ - @@ -181,7 +181,7 @@ -
@@ -328,7 +328,7 @@
- +
@@ -352,7 +352,7 @@ - diff --git a/patterns/homepage/homepage-saas.php b/patterns/homepage/homepage-saas.php index 2ad5505..44d6723 100644 --- a/patterns/homepage/homepage-saas.php +++ b/patterns/homepage/homepage-saas.php @@ -25,11 +25,11 @@ -
+ @@ -324,7 +324,7 @@ - +
@@ -370,7 +370,7 @@ - +
@@ -414,7 +414,7 @@ - +
@@ -475,11 +475,11 @@ -
+ diff --git a/patterns/modal/announcement-promo.php b/patterns/modal/announcement-promo.php index ffc4574..7271c8f 100644 --- a/patterns/modal/announcement-promo.php +++ b/patterns/modal/announcement-promo.php @@ -14,7 +14,7 @@ 'categories' => array( 'airo-wp-modal' ), 'viewportWidth' => 1200, 'content' => ' -
- +
diff --git a/patterns/pricing/pricing-saas-tiers.php b/patterns/pricing/pricing-saas-tiers.php index ff43fe5..922dfe0 100644 --- a/patterns/pricing/pricing-saas-tiers.php +++ b/patterns/pricing/pricing-saas-tiers.php @@ -60,7 +60,7 @@ - +
@@ -106,7 +106,7 @@ - +
@@ -150,7 +150,7 @@ - +
diff --git a/patterns/pricing/pricing-tabs.php b/patterns/pricing/pricing-tabs.php index ddbd1b2..eb2a693 100644 --- a/patterns/pricing/pricing-tabs.php +++ b/patterns/pricing/pricing-tabs.php @@ -80,7 +80,7 @@ - +
@@ -142,7 +142,7 @@ - +
@@ -202,7 +202,7 @@ - +
@@ -262,7 +262,7 @@ - +
@@ -326,7 +326,7 @@ - +
@@ -388,7 +388,7 @@ - +
diff --git a/patterns/team/team-speaker-showcase.php b/patterns/team/team-speaker-showcase.php index d02a6b3..946062e 100644 --- a/patterns/team/team-speaker-showcase.php +++ b/patterns/team/team-speaker-showcase.php @@ -111,7 +111,7 @@ - diff --git a/patterns/testimonials/reviews-star-rating-cards.php b/patterns/testimonials/reviews-star-rating-cards.php new file mode 100644 index 0000000..5792075 --- /dev/null +++ b/patterns/testimonials/reviews-star-rating-cards.php @@ -0,0 +1,109 @@ + __( 'Reviews with Star Rating', 'airo-wp' ), + 'categories' => array( 'airo-wp-testimonials' ), + 'viewportWidth' => 1200, + 'content' => ' +
+
+

What Reviewers Say

+ + + +

Verified reviews, scored out of five

+
+ + + +
+
+ + +

"This product has completely transformed how we work. The interface is intuitive and the features are exactly what we needed. Highly recommended!"

+ + + +
+
Amanda Peters
+ + + +
+

Amanda Peters

+ + + +

Marketing Director

+
+
+
+ + + +
+ + +

"The customer support is outstanding. Every time I had a question, they responded quickly and resolved my issues efficiently. Great experience overall."

+ + + +
+
David Kim
+ + + +
+

David Kim

+ + + +

Software Engineer

+
+
+
+ + + +
+ + +

"We have seen a 40% increase in productivity since implementing this solution. The ROI has been incredible and the team loves using it daily."

+ + + +
+
Sarah Mitchell
+ + + +
+

Sarah Mitchell

+ + + +

Operations Manager

+
+
+
+
+
+', +); diff --git a/patterns/testimonials/testimonials-video.php b/patterns/testimonials/testimonials-video.php index e75dbb3..ef3ff58 100644 --- a/patterns/testimonials/testimonials-video.php +++ b/patterns/testimonials/testimonials-video.php @@ -16,7 +16,7 @@ 'content' => '
-
+ @@ -52,7 +52,7 @@ -