diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 026765a..ceb3c2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,8 @@ jobs: if: steps.version_check.outputs.new_version run: | VERSION="${{ steps.version_check.outputs.new_version }}" - git archive --prefix "vip-governance/" HEAD -o "vip-governance-${VERSION}.zip" + npm run release:zip + mv vip-governance.zip "vip-governance-${VERSION}.zip" - name: Create Release if: steps.version_check.outputs.new_version diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index dc690b0..8f025bd 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -12,7 +12,7 @@ concurrency: jobs: lint-js: - name: JavaScript linting + name: Frontend linting and type checking runs-on: ubuntu-latest strategy: @@ -28,15 +28,22 @@ jobs: node-version: '24' cache: npm - - name: Build the plugin + - name: Install dependencies + run: npm ci + + - name: Verify production bundles run: | - npm ci + npm run build + git diff --exit-code -- build - name: Lint JS files run: npm run lint:js + - name: Type check frontend files + run: npm run typecheck + unit-js: - name: JavaScript Unit Tests + name: Frontend Unit Tests runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index 50e45af..72040e6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,5 @@ vendor/* /playwright/.cache/ /artifacts -# Map file which is generated by webpack -build/index.js.map \ No newline at end of file +# Development-only source maps generated by webpack. +build/*.js.map diff --git a/AGENTS.md b/AGENTS.md index 49fdb20..0cf5733 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ The plugin uses a JSON-based rules system (`governance-rules.json`) with support ### Tech Stack - **PHP 8.2+** — Backend logic, rule parsing, REST API, settings panel -- **JavaScript/JSX** — Block editor integration via WordPress filters +- **TypeScript/TSX** — Block editor integration via WordPress filters - **WordPress 6.8+** — Target platform - **Webpack** — Build system (via `@wordpress/scripts`) - **Composer** — PHP dependency management @@ -39,13 +39,14 @@ governance/ settings/ settings.php # Admin settings page registration settings-view.php # Settings page HTML template - settings.js # Settings page frontend logic settings.css # Settings page styles src/ - index.js # JS entry point — block editor filter setup - block-utils.js # Block name matching, hierarchy validation - block-locking.jsx # React UI for block locking - nested-governance-loader.js # Loads nested governance settings + index.ts # Frontend entry point + editor.ts # Block editor filter setup + block-utils.ts # Block name matching, hierarchy validation + block-locking.tsx # React UI for block locking + nested-settings-filter.ts # Resolves nested governance settings + settings/ # Settings page frontend logic bin/release # Creates and commits major/minor/patch release branches build/ # Compiled JS output (do not edit directly) tests/ @@ -67,7 +68,7 @@ WPCOMVIP__GOVERNANCE__RULES_REST_ROUTE // 'vip-governance/v1' WPCOMVIP_GOVERNANCE_RULES_FILENAME // 'governance-rules.json' ``` -### JavaScript Global +### Frontend Global The plugin exposes `VIP_GOVERNANCE` to the block editor with: @@ -181,7 +182,7 @@ npm run test # Runs both PHP and JS unit tests ### Code Standards - **PHP**: WordPress-VIP-Go + WordPress-Extra via PHPCS -- **JS**: `@automattic/eslint-plugin-wpvip` +- **TypeScript/JavaScript**: `@automattic/eslint-plugin-wpvip` - **Formatting**: Prettier with `@automattic/eslint-plugin-wpvip/prettierrc` ### CI Matrix (GitHub Actions) @@ -219,17 +220,17 @@ class MyNewTest extends TestCase { } ``` -### JS Unit Tests (Jest) +### Frontend Unit Tests (Jest) -- **Location**: Co-located with source as `src/foo.test.js` next to `src/foo.js` +- **Location**: Co-located with source as `src/foo.test.ts` or `src/foo.test.tsx` - **Framework**: Jest with `@wordpress/jest-preset-default` - **WordPress mocks**: Mock `@wordpress/hooks`, `@wordpress/data`, etc. via `jest.mock()` - **Style**: `describe` / `it` blocks -- **Run one file**: `npx jest src/block-utils.test.js` +- **Run one file**: `npx jest src/block-utils.test.ts` Example structure: -```js +```ts import { myFunction } from './my-module'; describe( 'myFunction', () => { @@ -241,16 +242,16 @@ describe( 'myFunction', () => { ### E2E Tests (Playwright) -- **Location**: `tests/e2e/*.spec.js` +- **Location**: `tests/e2e/*.spec.ts` - **Framework**: Playwright with `@wordpress/e2e-test-utils-playwright` -- **Global setup**: `tests/e2e/globalSetup.js` — authenticates as admin, resets posts/blocks/preferences +- **Global setup**: `tests/e2e/globalSetup.ts` — authenticates as admin, resets posts/blocks/preferences - **Auth state**: Saved to `artifacts/storage-states/admin.json` - **Fixtures**: Uses WordPress `admin`, `editor`, and `page` fixtures from `@wordpress/e2e-test-utils-playwright` -- **Run one file**: `npx playwright test tests/e2e/my-test.spec.js` +- **Run one file**: `npx playwright test tests/e2e/my-test.spec.ts` Example structure: -```js +```ts import { expect, test } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'My Feature', () => { @@ -288,7 +289,7 @@ test.describe( 'My Feature', () => { - **Rules not applying?** Check that `governance-rules.json` is in the private directory and validates against the schema. Use the admin settings page to verify. - **Block still visible?** Starting from WordPress 6.8, the block inserter shows all blocks — disallowed ones trigger a snackbar on insert attempt. -- **JS not updating?** Run `npm run build` or `npm run dev` (watch mode). Check that `build/index.js` was regenerated. +- **Frontend not updating?** Run `npm run build` or `npm run dev` (watch mode). Check that `build/index.js` was regenerated. - **PHP changes not reflecting?** Ensure `wp-env` is running. No build step needed for PHP. - **Test failures in CI?** Check the CI matrix — tests run against PHP 8.2–8.5 and WP 6.8/latest. Failures may be version-specific. - **Unexpected vendor changes?** Composer regenerates tracked metadata under `vendor/composer`. Only commit those changes when intentionally updating the production dependency bundle. diff --git a/README.md b/README.md index c833920..298cc1a 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,7 @@ With this rule set, the following rules will apply: - Support for `color.duotone` has not been implemented. - Currently, the plugin is restricted to the post editor only and won't work on other pages like site-editor, widgets, etc. -- Starting from WordPress 6.8, the block inserter sidebar will show all the blocks regardless of the ability to insert them or not. Upon attempting to insert a block that isn't allowed, a snackbar will show up in the lower left corner to highlight that this isn't possible. The regular `/blockname` approach will work just fine, and would be the recommended way of inserting blocks when using this plugin. +- In WordPress 6.8 and 6.9, the main block inserter can show blocks that governance will not allow. Attempting to insert one is still rejected and displays a snackbar notice. WordPress 7.0 and later filter those unavailable blocks from the inserter again, following the [Gutenberg 22.4 inserter fix][gutenberg-22-4]. ## Code Filters @@ -855,7 +855,7 @@ The npm `postinstall` script installs production Composer dependencies, and the ### Tests -The PHP, JavaScript, and end-to-end tests can be run locally with [`wp-env`][wp-env] and Docker. +The PHP, TypeScript, and end-to-end tests can be run locally with [`wp-env`][wp-env] and Docker. The default configuration starts a development site at `http://localhost:8888`. Automated tests use an isolated configuration at `http://localhost:8889`, which mounts the governance fixture from `tests/private` without affecting the development site. @@ -866,7 +866,7 @@ npx wp-env start --config=.wp-env.test.json composer run test ``` -For the JS unit tests: +For the frontend unit tests: ``` npm run test:js @@ -882,7 +882,7 @@ npm run test:e2e Run multisite PHP coverage with `composer run test-multisite`. Playwright uses the isolated test site at `http://localhost:8889`, with `admin` / `password` as the default credentials. -Run both PHP and JavaScript unit tests with `npm test` after the isolated test environment is running. +Run both PHP and TypeScript unit tests with `npm test` after the isolated test environment is running. @@ -892,6 +892,7 @@ Run both PHP and JavaScript unit tests with `npm test` after the isolated test e [repo-governance-file-location]: governance-rules.json [repo-schema-location]: governance-schema.json [gutenberg-block-settings]: https://developer.wordpress.org/block-editor/how-to-guides/themes/theme-json/#settings +[gutenberg-22-4]: https://make.wordpress.org/core/2026/01/22/whats-new-in-gutenberg-22-4-20-january/ [repo-analytics]: governance/analytics.php [repo-releases]: https://github.com/automattic/vip-governance-plugin/releases [vip-go-mu-plugins]: https://github.com/Automattic/vip-go-mu-plugins/ diff --git a/build/index.asset.php b/build/index.asset.php index 82aab39..e218816 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '06926ed746d6307eb9a6'); + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-notices'), 'version' => '26c4d053b38ba922995b'); diff --git a/build/index.js b/build/index.js index e0aee59..596a138 100644 --- a/build/index.js +++ b/build/index.js @@ -1,2 +1 @@ -(()=>{"use strict";var e={122(e,o,t){t.d(o,{G:()=>d});var n=t(715),r=t(427),i=t(491),c=t(143),s=t(619),l=t(418),a=t(790);function d(e){const o=(0,i.createHigherOrderComponent)(o=>t=>{const{name:i,clientId:d}=t,{getBlockParents:u,getBlockName:g}=(0,c.select)(n.store),f=u(d,!0),v=f.some(e=>function(e){return e in p}(e));if(v)return(0,a.jsx)(o,{...t});const m=f.map(e=>g(e));let w=(0,l.yw)(i,m,e);if(w=(0,s.applyFilters)("vip_governance__is_block_allowed_for_editing",w,i,m,e),w)return(0,a.jsx)(o,{...t});if(wp?.blockEditor?.useBlockEditingMode){const{useBlockEditingMode:e}=wp.blockEditor;e("disabled")}return function(e){p[e]=!0}(d),(0,a.jsx)(a.Fragment,{children:(0,a.jsx)(r.Disabled,{children:(0,a.jsx)("div",{style:{opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},children:(0,a.jsx)(o,{...t})})})})},"withDisabledBlocks");(0,s.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",o)}const p={}},418(e,o,t){t.d(o,{aV:()=>s,yw:()=>c});var n=t(619),r=t(325);const i={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function c(e,o,t){const c=(0,n.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,o,t)||0===o.length?[...t.allowedBlocks]:[];if(o.length>0){if(i[o[0]]&&i[o[0]].includes(e))return!0;if(t.blockSettings){const e=(0,r.W)(o.reverse(),"allowedBlocks",t.blockSettings);e&&e.value&&c.push(...e.value)}}return function(e,o){return o.some(o=>s(e,o))}(e,c)}function s(e,o){return o.includes("*")?e.match(new RegExp(o.replace("*",".*"))):o===e}},325(e,o,t){function n(e,o={},t=!1){const r=["allowedBlocks"];for(const[i,s]of Object.entries(e))if(!r.includes(i))if(i.includes("/")||"*"===i)Object.entries(e).forEach(([e,t])=>{r.includes(e)||n(t,o,e)});else if(!1!==t){const e=c(s,`${i}.`);o[t]={...o[t]??{},...e}}return o}function r(e,o,t,n={depth:0,value:void 0},c=1){const[s,...l]=e,a=t[s];if(0===l.length){const e=i(a,o);return void 0!==e&&c>=n.depth&&(n.depth=c,n.value=e),n}return void 0!==a&&(n=r(l,o,a,n,c+1)),r(l,o,t,n,c)}function i(e,o,t=void 0){const n=Array.isArray(o)?o:o.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(!n.length||void 0===n[0])return e;const r=n[0];return"object"==typeof e&&null!==e&&r in e&&void 0!==e[r]?i(e[r],n.slice(1),t):t}function c(e,o=""){const t={};return Object.entries(e).forEach(([e,n])=>{"object"==typeof n&&Boolean(n)&&!Array.isArray(n)?(t[`${o}${e}`]=!0,Object.assign(t,c(n,`${o}${e}.`))):t[`${o}${e}`]=!0}),t}t.d(o,{S:()=>n,W:()=>r})},790(e){e.exports=window.ReactJSXRuntime},715(e){e.exports=window.wp.blockEditor},427(e){e.exports=window.wp.components},491(e){e.exports=window.wp.compose},143(e){e.exports=window.wp.data},619(e){e.exports=window.wp.hooks},723(e){e.exports=window.wp.i18n},692(e){e.exports=window.wp.notices}};const o={};function t(n){const r=o[n];if(void 0!==r)return r.exports;const i=o[n]={exports:{}};return e[n](i,i.exports,t),i.exports}t.n=e=>{const o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},t.d=(e,o)=>{if(Array.isArray(o))for(var n=0;nObject.hasOwn(e,o);var n=t(715),r=t(143),i=t(619),c=t(723),s=t(692),l=t(122),a=t(418),d=t(325);!function(){if(VIP_GOVERNANCE.error)return void(0,r.dispatch)(s.store).createErrorNotice(VIP_GOVERNANCE.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,c.__)("Open governance settings"),url:VIP_GOVERNANCE.urlSettingsPage}]});const e=VIP_GOVERNANCE.governanceRules;(0,i.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(o,t,c,{getBlock:s})=>{if(!1===o)return o;let l=[];if(c){const{getBlockParents:e,getBlockName:o}=(0,r.select)(n.store),t=s(c),i=e(c,!0);l=[t.clientId,...i].map(e=>o(e))}const d=(0,a.yw)(t.name,l,e);return(0,i.applyFilters)("vip_governance__is_block_allowed_for_insertion",d,t.name,l,e)});const o=VIP_GOVERNANCE.nestedSettings,t=(0,d.S)(o),p={},u={};for(const e in t)-1===e.indexOf("*")?u[e]=t[e]:p[e]=t[e];(0,i.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,t,i,c)=>{if(!c)return e;if(void 0!==u[c]&&!0===u[c][t]){const c=[i,...(0,r.select)(n.store).getBlockParents(i,!0)].map(e=>(0,r.select)(n.store).getBlockName(e)).reverse();return({value:e}=(0,d.W)(c,t,o)),e&&e.theme?e.theme:e}if(0!==p.length)for(const s in p)if((0,a.aV)(c,s)&&!0===p[s][t]){const c=[i,...(0,r.select)(n.store).getBlockParents(i,!0)].map(e=>(0,r.select)(n.store).getBlockName(e)).reverse();return-1!==s.indexOf("*")&&(c[c.length-1]=s),({value:e}=(0,d.W)(c,t,o)),e&&e.theme?e.theme:e}return e}),e?.allowedBlocks&&(0,l.G)(e)}()})(); -//# sourceMappingURL=index.js.map \ No newline at end of file +(()=>{"use strict";const e=window.wp.blockEditor,t=window.wp.data,n=window.wp.hooks,o=window.wp.i18n,r=window.wp.notices,i=window.wp.components,c=window.wp.element;function l(e,t){return t.includes("*")?e.match(new RegExp(t.replace("*",".*"))):t===e}function s(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function a(e,t,n){if("object"==typeof e&&null!==e)for(const[o,r]of Object.entries(e)){const e=`${t}${o}`;n.add(e),s(r)&&a(r,`${e}.`,n)}}function u(e,t){const n=Array.isArray(t)?t:t.replace(/(\[(\d)\])/g,".$2").replace(/^\./,"").split(".");if(0===n.length)return e;if("object"!=typeof e||null===e)return;const[o,...r]=n;return o in e?u(e[o],r):void 0}function d(e,t,n,o={depth:0,value:void 0},r=1){if(0===e.length)return o;const i=e.at(-1);if(void 0===i)return o;const c=e.slice(0,-1),l=n[i],a=s(l)?l:void 0;if(0===c.length){const e=u(a,t);return void 0!==e&&r>=o.depth?{depth:r,value:e}:o}return a&&(o=d(c,t,a,o,r+1)),d(c,t,n,o,r)}function p(e,t,n,o){return[o??n(e),...t(e,!0).map(n)].filter(e=>void 0!==e)}function f(e){return s(e)&&e.theme?e.theme:e}const g={"core/list":["core/list-item"],"core/columns":["core/column"],"core/page-list":["core/page-list-item"],"core/navigation":["core/navigation-link","core/navigation-submenu"],"core/navigation-link":["core/navigation-link","core/navigation-submenu","core/page-list"],"core/quote":["core/paragraph"],"core/media-text":["core/paragraph"],"core/social-links":["core/social-link"],"core/comments-pagination":["core/comments-pagination-previous","core/comments-pagination-numbers","core/comments-pagination-next"]};function v(e,t,o){const r=(0,n.applyFilters)("vip_governance__is_block_allowed_in_hierarchy",!0,e,t,o)||0===t.length?[...o.allowedBlocks]:[];if(t.length>0){const n=g[t[0]];if(n?.includes(e))return!0;r.push(...function(e,t){if(!t)return[];const{value:n}=d(e,"allowedBlocks",t);return Array.isArray(n)&&n.every(e=>"string"==typeof e)?n:[]}(t,o.blockSettings))}return function(e,t){return t.some(t=>l(e,t))}(e,r)}const m=window.ReactJSXRuntime,k={opacity:.6,backgroundColor:"#eee",border:"2px dashed #999"},w=(0,c.createContext)(!1);!function(u){if(u.error)return void(0,t.dispatch)(r.store).createErrorNotice(u.error,{id:"wpcomvip-governance-error",isDismissible:!0,actions:[{label:(0,o.__)("Open governance settings","vip-governance"),url:u.urlSettingsPage}]});const{governanceRules:g}=u,{getBlockParents:h,getBlockName:b}=(0,t.select)(e.store);(0,n.addFilter)("blockEditor.__unstableCanInsertBlockType","wpcomvip-governance/block-insertion",(e,t,o,{getBlock:r})=>{if(!1===e)return!1;let i=[];if(o){const e=r(o),t=h(o,!0).map(b).filter(e=>void 0!==e),n=e?.name??b(o);i=n?[n,...t]:t}const c=v(t.name,i,g);return(0,n.applyFilters)("vip_governance__is_block_allowed_for_insertion",c,t.name,i,g)});const _=function(e){const t=new Map,n=(e,o)=>{for(const[r,i]of Object.entries(e))if("allowedBlocks"!==r)if((r.includes("/")||"*"===r)&&s(i))n(i,r);else if(o){const e=t.get(o)??new Set;a(i,`${r}.`,e),t.set(o,e)}};return n(e),{pathsByBlock:t,settings:e}}(u.nestedSettings);(0,n.addFilter)("blockEditor.useSetting.before","wpcomvip-governance/nested-block-settings",(e,t,n,o)=>o?function({clientId:e,currentBlockName:t,defaultValue:n,getBlockName:o,getBlockParents:r,path:i,rules:c}){return function({blockNames:e,defaultValue:t,path:n,rules:o}){const r=e[0];if(!r)return t;if(o.pathsByBlock.get(r)?.has(n))return f(d(e,n,o.settings).value);for(const[t,i]of o.pathsByBlock)if(t.includes("*")&&i.has(n)&&l(r,t)){const r=[...e];return r[0]=t,f(d(r,n,o.settings).value)}return t}({blockNames:p(e,r,o,t),defaultValue:n,path:i,rules:c})}({clientId:n,currentBlockName:o,defaultValue:e,getBlockName:b,getBlockParents:h,path:t,rules:_}):e),g.allowedBlocks&&function(o){(0,n.addFilter)("editor.BlockEdit","wpcomvip-governance/with-disabled-blocks",r=>function(o,r){return function(l){const{name:s,clientId:a}=l,u=(0,c.useContext)(w),d=function(e,t,o,r){if(r)return!0;const i=v(e,t,o);return(0,n.applyFilters)("vip_governance__is_block_allowed_for_editing",i,e,t,o)}(s,(0,t.useSelect)(t=>{const{getBlockParents:n,getBlockName:o}=t(e.store);return n(a,!0).map(o).filter(e=>void 0!==e)},[a]),r,u);return(0,e.useBlockEditingMode)(d?void 0:"disabled"),d?(0,m.jsx)(o,{...l}):(0,m.jsx)(w.Provider,{value:!0,children:(0,m.jsx)(i.Disabled,{children:(0,m.jsx)("div",{style:k,children:(0,m.jsx)(o,{...l})})})})}}(r,o))}(g)}(VIP_GOVERNANCE)})(); \ No newline at end of file diff --git a/build/settings.asset.php b/build/settings.asset.php new file mode 100644 index 0000000..a33bbb2 --- /dev/null +++ b/build/settings.asset.php @@ -0,0 +1 @@ + array('wp-api-fetch'), 'version' => 'f88a32824cccaaaaee1c'); diff --git a/build/settings.js b/build/settings.js new file mode 100644 index 0000000..690e7f3 --- /dev/null +++ b/build/settings.js @@ -0,0 +1 @@ +(()=>{"use strict";const e={n:t=>{const n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{if(Array.isArray(n))for(var s=0;sObject.hasOwn(e,t)},t=window.wp.apiFetch;!function({root:e=document,request:t}){const n=e.getElementById("user-role-selector"),s=e.getElementById("post-type-selector"),r=e.getElementById("view-rules-button"),a=e.querySelector(".vip-governance-query-spinner"),o=e.getElementById("json");if(!(n instanceof HTMLSelectElement&&s instanceof HTMLSelectElement&&r instanceof HTMLButtonElement&&a instanceof HTMLElement&&o instanceof HTMLElement))return;const l=()=>{const e={};return n.value&&(e.role=n.value),s.value&&(e.postType=s.value),e},c=()=>{const e=Object.keys(l()).length>0;r.hidden=!e,e||(o.hidden=!0)},i=e=>{o.textContent=e,o.hidden=!1};n.addEventListener("change",c),s.addEventListener("change",c),r.addEventListener("click",()=>{(async()=>{const e=l();if(0!==Object.keys(e).length){a.hidden=!1,a.classList.add("is-active");try{const n=await t({path:`/vip-governance/v1/rules?${new URLSearchParams(e)}`});i(JSON.stringify(n,null,4))}catch(e){i(function(e,t){return e instanceof Error&&e.message||"object"==typeof e&&null!==e&&"message"in e&&"string"==typeof e.message&&e.message?e.message:t}(e,o.dataset.errorMessage??"Unable to load rules."))}finally{a.hidden=!0,a.classList.remove("is-active")}}})()}),n.value="",s.value="",c()}({request:e.n(t)()})})(); \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 500ef09..edc7acf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,10 +8,13 @@ module.exports = [ ...wpvip.configs.formatting, ...wpvip.configs.testing, ...wpvip.configs.react, + ...wpvip.configs.typescript, ...wpvip.configs.prettier, { languageOptions: { globals: { + document: 'readonly', + URLSearchParams: 'readonly', VIP_GOVERNANCE: 'readonly', }, }, diff --git a/governance/init-governance.php b/governance/init-governance.php index 1002a52..ab9c0f8 100644 --- a/governance/init-governance.php +++ b/governance/init-governance.php @@ -75,6 +75,7 @@ public static function load_settings(): void { $asset_file['version'], true /* in_footer */ ); + wp_set_script_translations( 'wpcomvip-governance', 'vip-governance' ); $nested_settings_and_css = self::$governance_configuration['nestedSettingsAndCss']; diff --git a/governance/settings/settings-view.php b/governance/settings/settings-view.php index 0324eb6..3ea0c33 100644 --- a/governance/settings/settings-view.php +++ b/governance/settings/settings-view.php @@ -79,24 +79,34 @@ static function ( $line ) {

- - - - - - - - +
+ + + + + + + +
+
diff --git a/governance/settings/settings.css b/governance/settings/settings.css index 1694f41..54b20fe 100644 --- a/governance/settings/settings.css +++ b/governance/settings/settings.css @@ -1,4 +1,5 @@ -.governance-rules pre, .combined-governance-rules pre { +.governance-rules pre, +.combined-governance-rules pre { background: rgba(0, 0, 0, .07); padding: 1rem 2rem 1rem 1rem; margin: 0; @@ -28,31 +29,54 @@ display: inline-block; } -.governance-rules-json summary, .combined-governance-rules summary { +.governance-rules-json summary, +.combined-governance-rules summary { cursor: pointer; padding: 0.5rem 0 1rem 0; } -.validation-errors, .validation-warnings { +.validation-errors, +.validation-warnings { font-size: 1rem; font-weight: 700; } -.vip-governance-query-spinner span { +.vip-governance-rule-filters { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0 0.5rem; +} + +.vip-governance-rule-filters label { + display: grid; + gap: 0.25rem; +} + +.vip-governance-rule-filters [hidden] { + display: none !important; +} + +.vip-governance-query-spinner { float: none; - margin-top: 0; + margin: 0 0 0.25rem; +} + +.combined-governance-rules-json { + margin: 1rem 0; } -pre { +.governance-rules pre { counter-reset: line; tab-size: 4; } -code { +.governance-rules pre code { counter-increment: line; } -code:before { +.governance-rules pre code::before { content: counter(line); width: 2rem; display: inline-block; diff --git a/governance/settings/settings.js b/governance/settings/settings.js deleted file mode 100644 index ad8d0f7..0000000 --- a/governance/settings/settings.js +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable no-undef */ -// eslint-disable-next-line no-unused-vars -( () => { - function showRulesForRuleType() { - const roleSelector = document.getElementById( 'user-role-selector' ); - const rolePicked = roleSelector?.value ?? null; - - const postTypeSelector = document.getElementById( 'post-type-selector' ); - const postTypePicked = postTypeSelector?.value ?? null; - - if ( ( rolePicked || postTypePicked ) && window.wp && window.wp.apiRequest ) { - dataToBeSent = {}; - if ( rolePicked ) { - dataToBeSent.role = rolePicked; - } - - if ( postTypePicked ) { - dataToBeSent.postType = postTypePicked; - } - - document.querySelector( '.vip-governance-query-spinner' ).classList.add( 'is-active' ); - window.wp - .apiRequest( { - path: `/vip-governance/v1/rules`, - data: dataToBeSent, - } ) - .done( rules => { - document.getElementById( 'json' ).textContent = JSON.stringify( rules, undefined, 4 ); - document.getElementById( 'json' ).hidden = false; - } ) - .fail( error => { - document.getElementById( 'json' ).textContent = error.responseJSON.message; - document.getElementById( 'json' ).hidden = false; - } ) - .complete( () => { - document.querySelector( '.vip-governance-query-spinner' ).classList.remove( 'is-active' ); - } ); - } - } - - const roleSelector = document.getElementById( 'user-role-selector' ); - - if ( roleSelector ) { - // Reset to the default value on refresh - roleSelector.value = ''; - - roleSelector.addEventListener( 'change', () => { - const postTypePicked = document.getElementById( 'post-type-selector' )?.value ?? null; - if ( roleSelector.value ) { - document.getElementById( 'view-rules-button' ).style.display = 'inline'; - } else if ( ! postTypePicked ) { - document.getElementById( 'view-rules-button' ).style.display = 'none'; - document.getElementById( 'json' ).hidden = true; - } - } ); - } - - const postTypeSelector = document.getElementById( 'post-type-selector' ); - - if ( postTypeSelector ) { - // Reset to the default value on refresh - postTypeSelector.value = ''; - - postTypeSelector.addEventListener( 'change', () => { - const rolePicked = document.getElementById( 'user-role-selector' )?.value ?? null; - if ( postTypeSelector.value ) { - document.getElementById( 'view-rules-button' ).style.display = 'inline'; - } else if ( ! rolePicked ) { - document.getElementById( 'view-rules-button' ).style.display = 'none'; - document.getElementById( 'json' ).hidden = true; - } - } ); - } - - const viewButton = document.getElementById( 'view-rules-button' ); - - if ( viewButton ) { - viewButton.style.display = 'none'; - - viewButton.addEventListener( 'click', showRulesForRuleType ); - } -} )(); diff --git a/governance/settings/settings.php b/governance/settings/settings.php index 800d482..de186e5 100644 --- a/governance/settings/settings.php +++ b/governance/settings/settings.php @@ -91,11 +91,25 @@ public static function enqueue_resources(): void { * @access private */ public static function enqueue_scripts(): void { + $asset_file_path = WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_DIR . '/build/settings.asset.php'; + if ( ! is_readable( $asset_file_path ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'VIP Block Governance settings asset manifest is not readable: %s', $asset_file_path ) ); + return; + } + + $asset_file = include $asset_file_path; + if ( ! is_array( $asset_file ) || ! isset( $asset_file['dependencies'], $asset_file['version'] ) || ! is_array( $asset_file['dependencies'] ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'VIP Block Governance settings asset manifest is invalid: %s', $asset_file_path ) ); + return; + } + wp_enqueue_script( 'wpcomvip-governance-settings', - plugins_url( '/governance/settings/settings.js', WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_FILE ), - /* dependencies */ [ 'wp-api' ], - WPCOMVIP__GOVERNANCE__PLUGIN_VERSION, + plugins_url( '/build/settings.js', WPCOMVIP_GOVERNANCE_ROOT_PLUGIN_FILE ), + $asset_file['dependencies'], + $asset_file['version'], /* in footer */ true ); } diff --git a/package-lock.json b/package-lock.json index 5858682..fcff633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,22 +11,24 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@types/jest": "^30.0.0", + "@types/react": "^18.3.31", + "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", - "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", "@wordpress/i18n": "^6.25.0", "@wordpress/notices": "^5.52.0", "@wordpress/scripts": "^34.0.0", - "babel-jest": "^30.4.1", "eslint": "^9.39.5", "lint-staged": "^17.3.0", "prettier": "npm:wp-prettier@3.0.3", "simple-git-hooks": "^2.13.1", - "webpack": "^5.109.2" + "typescript": "~6.0.3" } }, "node_modules/@ariakit/components": { @@ -1541,6 +1543,8 @@ }, "node_modules/@babel/plugin-transform-runtime": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1560,6 +1564,8 @@ }, "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2146,6 +2152,8 @@ }, "node_modules/@csstools/media-query-list-parser": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz", + "integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==", "dev": true, "funding": [ { @@ -2402,6 +2410,8 @@ }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", + "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2455,7 +2465,9 @@ } }, "node_modules/@eslint/compat": { - "version": "2.1.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6841,6 +6853,8 @@ }, "node_modules/@stylistic/stylelint-plugin": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.3.tgz", + "integrity": "sha512-85fsmzgsIVmyG3/GFrjuYj6Cz8rAM7IZiPiXCMiSMfoDOC1lOrzrXPDk24WqviAghnPqGpx8b0caK2PuewWGFg==", "dev": true, "license": "MIT", "dependencies": { @@ -7366,6 +7380,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/jsdom": { "version": "21.1.7", "dev": true, @@ -7414,6 +7439,8 @@ }, "node_modules/@types/minimist": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true, "license": "MIT" }, @@ -7447,6 +7474,8 @@ }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true, "license": "MIT" }, @@ -7490,6 +7519,8 @@ }, "node_modules/@types/react": { "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", "dependencies": { @@ -7497,14 +7528,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -8355,6 +8378,22 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/api-fetch": { + "version": "7.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.53.0.tgz", + "integrity": "sha512-601xtWJS35s/1TaFEIP5gAeR87Y4EfGhByTYoqoX4MvBo+h/yBM75M/O7ZuqPT1Q8Se0aDwI2eDw6r/nbCW5Mg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^6.26.0", + "@wordpress/private-apis": "^1.53.0", + "@wordpress/url": "^4.53.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/autop": { "version": "4.52.0", "dev": true, @@ -8365,7 +8404,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.52.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.54.0.tgz", + "integrity": "sha512-L2X5Neh5qTpa7THqBFATmGnfnhXsAGrDCwNYJSJ6pfgnCSyXNPKR07bbU0hreVTl1G+c42di4z7PH8sFdUxLdA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8375,8 +8416,8 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^6.52.0", - "@wordpress/warning": "^3.52.0", + "@wordpress/browserslist-config": "^6.54.0", + "@wordpress/warning": "^3.54.0", "browserslist": "^4.28.4", "core-js": "^3.31.0", "react": "^18.3.1" @@ -8546,7 +8587,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.52.0", + "version": "6.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.54.0.tgz", + "integrity": "sha512-6fH6DVILWiAhDgtCvEe8ZMOkKdBaJXXs68cKVnUJVPv8pZTCsJ3zABoUAxoj6U0Kiu65leSO/mMyoP7lJmcgJQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8653,19 +8696,21 @@ } }, "node_modules/@wordpress/compose": { - "version": "8.5.0", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-8.7.0.tgz", + "integrity": "sha512-wx+J+ffWOiKhFsmCLio9dE8JGK1/xh6IIEuVqQ7KV/Hakrwt6C1Zdfyhv+6tiNzYowB9gX2aXi5ESnHtDNzd5Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.52.0", - "@wordpress/dom": "^4.52.0", - "@wordpress/element": "^8.4.0", - "@wordpress/is-shallow-equal": "^5.52.0", - "@wordpress/keycodes": "^4.52.0", - "@wordpress/priority-queue": "^3.52.0", - "@wordpress/private-apis": "^1.52.0", - "@wordpress/undo-manager": "^1.52.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/dom": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/is-shallow-equal": "^5.54.0", + "@wordpress/keycodes": "^4.54.0", + "@wordpress/priority-queue": "^3.54.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/undo-manager": "^1.54.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5", "use-memo-one": "^1.1.1" @@ -8777,7 +8822,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "6.52.0", + "version": "6.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.54.0.tgz", + "integrity": "sha512-ttmz4kHACQlh4LffKBowI1MjdofrYtHITPvkv58r1shBUS+F9BAntwXKAwjdKpQJ3/FGBzWiOS/AZ1KYI+3Y2A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8792,11 +8839,13 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.54.0.tgz", + "integrity": "sha512-CpTcvBtDLStxslBm2HvMrQVbZ8cS0iYjKQvaW2vJcJxf+C0CDO0bS86juBnjcxtSRXFPGtBhxx6DKVP6L3pntw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^4.52.0" + "@wordpress/hooks": "^4.54.0" }, "engines": { "node": ">=18.12.0", @@ -8804,11 +8853,13 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.54.0.tgz", + "integrity": "sha512-HcxOhNVwkFqOakvViddUsSjjkbqHyRhFqmjkPJpiLWWtuIaCzIenPhT/GJID8W8MgLhWFU87krLDXg3yJuLnzQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.52.0" + "@wordpress/deprecated": "^4.54.0" }, "engines": { "node": ">=18.12.0", @@ -8825,7 +8876,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.54.0.tgz", + "integrity": "sha512-xpsb5c8T8VXBBrYfr98OqK2yevtiHVwpVE+u0vqYBYgWNiqQJj8Eyq7geRdY6v4SctbAVe8vGFVV+DfPaEhvRg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8845,34 +8898,46 @@ } }, "node_modules/@wordpress/element": { - "version": "8.4.0", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-8.6.0.tgz", + "integrity": "sha512-9stRsEfNoJf7K3jmP4GEvdUPdRqUn/JCYobSsrDzREmaAKNy1OBVW5ZqgK3/s+hbKBlkX8WKU48sTgJ/9aqCzQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@types/react": "^18.3.27", - "@types/react-dom": "^18.3.1", - "@wordpress/deprecated": "^4.52.0", - "@wordpress/escape-html": "^3.52.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/escape-html": "^3.54.0", "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "is-plain-object": "^5.1.0" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@wordpress/env": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.12.0.tgz", - "integrity": "sha512-P1mYGEgnkYCbBPRaT9uAyxe3hFfsfkvxXKQ+Pfb00z/mXz5xbPC48ZdXTQmxTcpaj0nDwJfrBz0vzc074B5nrA==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.14.0.tgz", + "integrity": "sha512-Q/XUhGjFJtlfybw/mq+7H5MsF2u3zWH5YxNR5y473+CeIjZx20wt5Dx0RDHAJcG2ICTky4pJevNlIq/EFnUwIQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", "@wp-playground/cli": "^3.0.48", - "adm-zip": "^0.5.9", + "adm-zip": "^0.6.0", "chalk": "^4.1.1", "copy-dir": "^1.3.0", "cross-spawn": "^7.0.6", @@ -8893,7 +8958,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.54.0.tgz", + "integrity": "sha512-dbXSgUZgJ4KcZF9OLO+suayc7PxptZ0qhSE1bgJraD8bnk/jkaCohp5J7WQKzTWw1OSiJtQFZLw2T+BnyqQOpA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8902,20 +8969,22 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "25.8.0", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-25.10.0.tgz", + "integrity": "sha512-Lx3LVVVQuhCOfwVQDUL7Mh8c++y/JKHh+GozJmYNEyOosEZGW+LFTWS6GQiqoR3d4qIRJTZ3lRlmRf0Md568aQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "^7.28.6", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.0", "@eslint/compat": "^2.0.0", - "@wordpress/babel-preset-default": "^8.52.0", - "@wordpress/prettier-config": "^4.52.0", - "@wordpress/theme": "^1.1.0", + "@wordpress/babel-preset-default": "^8.54.0", + "@wordpress/prettier-config": "^4.54.0", + "@wordpress/theme": "^2.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^29.16.0", "eslint-plugin-jsdoc": "^50.0.0", "eslint-plugin-jsx-a11y": "^6.10.0", @@ -8948,6 +9017,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/@es-joy/jsdoccomment": { "version": "0.50.2", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", + "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", "dev": true, "license": "MIT", "dependencies": { @@ -8961,8 +9032,67 @@ "node": ">=18" } }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/style-runtime": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.10.0.tgz", + "integrity": "sha512-yZm2r8qzt++oW+4X2UEHl2FBuO/9O+BazXsQbSjGxcabA2g4apjBH2pEZqKbdbNqlArkj5KZm0LCxY1p6DKYTQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wordpress/eslint-plugin/node_modules/@wordpress/theme": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-2.0.0.tgz", + "integrity": "sha512-4yFei1ayJinMOVY6cTzKZV961p2Vjex9Nst1SReIFKv3ckb6ih5QN+qMpJQdpuukwabXrBTaoiA4ZB1akl2CfQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.7.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/style-runtime": "^0.10.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0", + "npm": ">=10.2.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "esbuild": ">=0.27.2 <1.0.0", + "postcss": "^8.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "stylelint": "^16 || ^17", + "vite": "^7 || ^8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "postcss": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@wordpress/eslint-plugin/node_modules/comment-parser": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", "dev": true, "license": "MIT", "engines": { @@ -8971,6 +9101,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dev": true, "license": "MIT", "dependencies": { @@ -8986,6 +9118,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/eslint-plugin-jsdoc": { "version": "50.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.8.0.tgz", + "integrity": "sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9009,6 +9143,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/globals": { "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -9020,6 +9156,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/jsdoc-type-pratt-parser": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, "license": "MIT", "engines": { @@ -9028,6 +9166,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9039,6 +9179,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/spdx-expression-parse": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9048,6 +9190,8 @@ }, "node_modules/@wordpress/eslint-plugin/node_modules/yaml": { "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -9076,7 +9220,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.54.0.tgz", + "integrity": "sha512-9eMe48ixxAd+ILuXe3n+zOc1XlphfPppf0drTuJEjJy0xGXV7eUw459KEvk4KCrznb9pKemHjj4mGjogV5f1CQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9094,12 +9240,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.25.0", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.27.0.tgz", + "integrity": "sha512-6+mBSNGrB9EvnGa33tgNzZ7/vq98yCJ75eX81CU1XBKcoIvPcz5/G+zURpSuoOwAgAYaCxoXMDFX4gJDJjSbaw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.52.0", + "@wordpress/hooks": "^4.54.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -9176,7 +9324,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.54.0.tgz", + "integrity": "sha512-j5mskkhAD3jWrgNSdoSsexos1dxI9EQftfAZAbI2GZwFi6I20cgSrktgCewAUSZDqJ+vPwaJs0bTDhtUDMHXLg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9185,7 +9335,9 @@ } }, "node_modules/@wordpress/jest-console": { - "version": "9.0.0", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-9.2.0.tgz", + "integrity": "sha512-IzcyKAUrD33xRs+jq0wWsiyB76pL/ddORxOPj21CXdq1/NirMwOLcrPsIymlJvi6G8ZcxqeCWMBmAPtskSMz/Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9201,11 +9353,13 @@ } }, "node_modules/@wordpress/jest-preset-default": { - "version": "13.0.0", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-14.1.0.tgz", + "integrity": "sha512-TE2cSni2tEsmOcpWWCVCI9xKvLGxeTQRJEiHJWZ84qJKndk5eXCO3ANZFXO6whW7Sr/8AG/lXH2vUG/odk9uBg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/jest-console": "^9.0.0", + "@wordpress/jest-console": "^9.2.0", "babel-jest": "^30.4.1", "change-case": "^4.1.2" }, @@ -9242,11 +9396,13 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.54.0.tgz", + "integrity": "sha512-WeaKup+THfx62hGOOVZpeK0AvEA0d3xwWo2P44pUbUKaBFydTXWqmm7HNsZ7bX9lDdk7JshuLSwhpHfwFPOQEw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^6.25.0" + "@wordpress/i18n": "^6.27.0" }, "engines": { "node": ">=18.12.0", @@ -9286,7 +9442,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.54.0.tgz", + "integrity": "sha512-T3041Brq2tD9ugUVBWj1P3/VWn87dKEpn3O9r987wdZ4MjLBpWaoER066MHISzB/DE0fSK0SuM0KGRT+30kpsQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9298,12 +9456,14 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "5.52.0", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.54.0.tgz", + "integrity": "sha512-T54ouByzhopTMfCwvCtgK+KdmBzf7+a/0Mv7NFfQuXYP3+kFV+SyyYUnRC5Js3SnDANL2Ui0RLil3M2e8SIzUw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^12.0.0", - "@wordpress/browserslist-config": "^6.52.0", + "@wordpress/base-styles": "^13.0.0", + "@wordpress/browserslist-config": "^6.54.0", "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, @@ -9315,6 +9475,17 @@ "postcss": "^8.0.0" } }, + "node_modules/@wordpress/postcss-plugins-preset/node_modules/@wordpress/base-styles": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-13.0.0.tgz", + "integrity": "sha512-IBDH2iG+U373NiY8l6UVNvjMXKVtYN/iFaLpavt1vO/I/S2aOE+m693+iMJv9ELC5oYQZSXzTWh+p/1RjmmC6w==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/preferences": { "version": "4.52.0", "dev": true, @@ -9348,7 +9519,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "4.52.0", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.54.0.tgz", + "integrity": "sha512-uwiRJ3S2d/T/+ZwB2HjehVfu3rw+LYBjSiJcE/CSxbBC7eobtQ3WZsNsUc5p1M7Be6orpowtl2wtAFg/bUDJuA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9382,7 +9555,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.54.0.tgz", + "integrity": "sha512-YOJAKb80OFzHCdamyfKu9L3k+AZWexo5f1Jjm8HFs3+6CIxmkPyTe7uYccpRvmgag8MkVAdus/VaO5thzOy+gA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9394,7 +9569,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.54.0.tgz", + "integrity": "sha512-OOZXNT5O0D+w6pPipc0RYIfxxNQm8ahzQjIyYkVq6Qep6sq9sXut6cjgiznOApIywQNCsVOn/S34anZ4f2ONMg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9451,24 +9628,26 @@ } }, "node_modules/@wordpress/scripts": { - "version": "34.0.0", + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-34.2.0.tgz", + "integrity": "sha512-BDSNYJgx6c8V6IJZMLc8hjQXt4+qzJ3JHwKRmWBqupFj85nKpcrZbRXpTzLxULrx4wfpCCnvt27R2zw7xmVCKg==", "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.52.0", - "@wordpress/browserslist-config": "^6.52.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.52.0", - "@wordpress/e2e-test-utils-playwright": "^1.52.0", - "@wordpress/eslint-plugin": "^25.8.0", - "@wordpress/jest-preset-default": "^13.0.0", - "@wordpress/npm-package-json-lint-config": "^5.52.0", - "@wordpress/postcss-plugins-preset": "^5.52.0", - "@wordpress/prettier-config": "^4.52.0", - "@wordpress/stylelint-config": "^24.1.0", - "adm-zip": "^0.5.9", + "@wordpress/babel-preset-default": "^8.54.0", + "@wordpress/browserslist-config": "^6.54.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.54.0", + "@wordpress/e2e-test-utils-playwright": "^1.54.0", + "@wordpress/eslint-plugin": "^25.10.0", + "@wordpress/jest-preset-default": "^14.1.0", + "@wordpress/npm-package-json-lint-config": "^5.54.0", + "@wordpress/postcss-plugins-preset": "^5.54.0", + "@wordpress/prettier-config": "^4.54.0", + "@wordpress/stylelint-config": "^24.3.0", + "adm-zip": "^0.6.0", "babel-jest": "^30.4.1", "babel-loader": "^9.2.1", "browserslist": "^4.28.4", @@ -9769,12 +9948,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "24.1.0", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-24.3.0.tgz", + "integrity": "sha512-DfuhPR402icUZd/6MyWRm6jwr7tY5Fbtt+sswFVql8Z+95sNe0kdS4vJyAwhRGWNnZkJ1SuQk6ye6nHFu+cm/g==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^3.1.3", - "@wordpress/theme": "^1.1.0", + "@wordpress/theme": "^2.0.0", "stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended-scss": "^14.1.0" }, @@ -9787,6 +9968,63 @@ "stylelint-scss": "^6.4.0" } }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/style-runtime": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.10.0.tgz", + "integrity": "sha512-yZm2r8qzt++oW+4X2UEHl2FBuO/9O+BazXsQbSjGxcabA2g4apjBH2pEZqKbdbNqlArkj5KZm0LCxY1p6DKYTQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wordpress/stylelint-config/node_modules/@wordpress/theme": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-2.0.0.tgz", + "integrity": "sha512-4yFei1ayJinMOVY6cTzKZV961p2Vjex9Nst1SReIFKv3ckb6ih5QN+qMpJQdpuukwabXrBTaoiA4ZB1akl2CfQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^8.7.0", + "@wordpress/deprecated": "^4.54.0", + "@wordpress/element": "^8.6.0", + "@wordpress/private-apis": "^1.54.0", + "@wordpress/style-runtime": "^0.10.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0", + "npm": ">=10.2.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "esbuild": ">=0.27.2 <1.0.0", + "postcss": "^8.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "stylelint": "^16 || ^17", + "vite": "^7 || ^8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "postcss": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@wordpress/theme": { "version": "1.1.0", "dev": true, @@ -9875,11 +10113,13 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.54.0.tgz", + "integrity": "sha512-6b10QkKqvvWl1v67CXT04V/eM9dOzSoTcu7ZenA8ZmImLjYT1fDJ5ckJnldY5HkCMO8STd6gnJMgs/ZkXYv35A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^5.52.0" + "@wordpress/is-shallow-equal": "^5.54.0" }, "engines": { "node": ">=18.12.0", @@ -9919,7 +10159,9 @@ } }, "node_modules/@wordpress/url": { - "version": "4.52.0", + "version": "4.53.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.53.0.tgz", + "integrity": "sha512-o/s7CRfmxT4XAQWeBCfaURUQv15HBmLAuCQV/DdO8h3+c0cLJX+mcIDrdMWA49ANo4ncGQdZRtTKU0rY/I6+wA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9957,7 +10199,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.52.0", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.54.0.tgz", + "integrity": "sha512-0QWxBc/CHQdgzTwnUg+Ha5Ir0qyuIQUDzA2gmGSbbVshgm3d5FugLGpZHL1/S2+KlczyjKS2fftDJmK1dxdsEg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -10299,11 +10543,13 @@ } }, "node_modules/adm-zip": { - "version": "0.5.18", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { @@ -10345,6 +10591,8 @@ }, "node_modules/ajv-errors": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -10697,6 +10945,8 @@ }, "node_modules/arrify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true, "license": "MIT", "engines": { @@ -10765,7 +11015,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.4", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz", + "integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==", "dev": true, "funding": [ { @@ -10783,8 +11035,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -11130,7 +11382,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.12", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -11270,7 +11524,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -11288,11 +11544,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -11530,6 +11786,8 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -11546,6 +11804,8 @@ }, "node_modules/camelcase-keys/node_modules/camelcase": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", "engines": { @@ -11564,7 +11824,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -12155,6 +12417,8 @@ }, "node_modules/core-js": { "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -12624,6 +12888,8 @@ }, "node_modules/decamelize": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, "license": "MIT", "engines": { @@ -12632,6 +12898,8 @@ }, "node_modules/decamelize-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "dev": true, "license": "MIT", "dependencies": { @@ -12647,6 +12915,8 @@ }, "node_modules/decamelize-keys/node_modules/map-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "dev": true, "license": "MIT", "engines": { @@ -13072,7 +13342,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.401", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -13874,6 +14146,8 @@ }, "node_modules/eslint-plugin-playwright": { "version": "2.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.11.0.tgz", + "integrity": "sha512-zOIEYyv1TSn2izXkyg/yj0LXc4YBQI6pl3xIFWwMCcXt/dgQCz8dBqXiXFMoM3xc/GqZThAtaGYo8aPu/vBd+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -14803,6 +15077,8 @@ }, "node_modules/fraction.js": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { @@ -15339,6 +15615,8 @@ }, "node_modules/hard-rejection": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "dev": true, "license": "MIT", "engines": { @@ -15483,6 +15761,8 @@ }, "node_modules/hosted-git-info": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, "license": "ISC", "dependencies": { @@ -15494,6 +15774,8 @@ }, "node_modules/hosted-git-info/node_modules/lru-cache": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { @@ -15505,6 +15787,8 @@ }, "node_modules/hosted-git-info/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, @@ -16003,6 +16287,8 @@ }, "node_modules/irregular-plurals": { "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", "dev": true, "license": "MIT", "engines": { @@ -16359,7 +16645,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "dev": true, "license": "MIT", "engines": { @@ -16476,6 +16764,8 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", "engines": { @@ -17388,6 +17678,8 @@ }, "node_modules/json2php": { "version": "0.0.9", + "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz", + "integrity": "sha512-fQMYwvPsQt8hxRnCGyg1r2JVi6yL8Um0DIIawiKiMK9yhAAkcRNj5UsBWoaFvFzPpcWbgw9L6wzj+UMYA702Mw==", "dev": true, "license": "BSD" }, @@ -17819,6 +18111,8 @@ }, "node_modules/log-symbols": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { @@ -17909,6 +18203,8 @@ }, "node_modules/map-obj": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true, "license": "MIT", "engines": { @@ -18187,6 +18483,8 @@ }, "node_modules/meow": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18212,6 +18510,8 @@ }, "node_modules/meow/node_modules/type-fest": { "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -18223,6 +18523,8 @@ }, "node_modules/meow/node_modules/yargs-parser": { "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", "engines": { @@ -18336,6 +18638,8 @@ }, "node_modules/min-indent": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", "engines": { @@ -18390,6 +18694,8 @@ }, "node_modules/minimist-options": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, "license": "MIT", "dependencies": { @@ -18403,6 +18709,8 @@ }, "node_modules/minimist-options/node_modules/is-plain-obj": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "dev": true, "license": "MIT", "engines": { @@ -18697,7 +19005,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.52", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -18706,6 +19016,8 @@ }, "node_modules/normalize-package-data": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -18720,6 +19032,8 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -18770,6 +19084,8 @@ }, "node_modules/npm-package-json-lint": { "version": "6.4.0", + "resolved": "https://registry.npmjs.org/npm-package-json-lint/-/npm-package-json-lint-6.4.0.tgz", + "integrity": "sha512-cuXAJJB1Rdqz0UO6w524matlBqDBjcNt7Ru+RDIu4y6RI1gVqiWBnylrK8sPRk81gGBA0X8hJbDXolVOoTc+sA==", "dev": true, "license": "MIT", "dependencies": { @@ -18801,6 +19117,8 @@ }, "node_modules/npm-package-json-lint/node_modules/array-union": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -18809,6 +19127,8 @@ }, "node_modules/npm-package-json-lint/node_modules/globby": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -18828,6 +19148,8 @@ }, "node_modules/npm-package-json-lint/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -18836,11 +19158,15 @@ }, "node_modules/npm-package-json-lint/node_modules/jsonc-parser": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, "license": "MIT" }, "node_modules/npm-package-json-lint/node_modules/semver": { "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -18852,6 +19178,8 @@ }, "node_modules/npm-package-json-lint/node_modules/type-fest": { "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -19742,14 +20070,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "dev": true, @@ -19906,6 +20226,8 @@ }, "node_modules/plur": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", "dev": true, "license": "MIT", "dependencies": { @@ -20045,7 +20367,9 @@ } }, "node_modules/postcss-import": { - "version": "16.1.1", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.2.0.tgz", + "integrity": "sha512-0mQUGlSp87Zl70K58RNwQAN1WS9plFE6KWGui9nyK6ninduMyjW/Khaoy/BpBoFTBh7ndAvAKrSKVbwy6vd/BA==", "dev": true, "license": "MIT", "dependencies": { @@ -20117,6 +20441,8 @@ }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, "license": "MIT" }, @@ -20499,6 +20825,8 @@ }, "node_modules/postcss-scss": { "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", "dev": true, "funding": [ { @@ -20901,6 +21229,8 @@ }, "node_modules/quick-lru": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true, "license": "MIT", "engines": { @@ -21002,6 +21332,7 @@ "version": "18.3.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21115,15 +21446,16 @@ } }, "node_modules/read-cache": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } + "license": "MIT" }, "node_modules/read-pkg": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "dev": true, "license": "MIT", "dependencies": { @@ -21138,6 +21470,8 @@ }, "node_modules/read-pkg-up": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", "dev": true, "license": "MIT", "dependencies": { @@ -21154,6 +21488,8 @@ }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -21162,11 +21498,15 @@ }, "node_modules/read-pkg/node_modules/hosted-git-info": { "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true, "license": "ISC" }, "node_modules/read-pkg/node_modules/normalize-package-data": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -21178,6 +21518,8 @@ }, "node_modules/read-pkg/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { @@ -21186,6 +21528,8 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -21230,6 +21574,8 @@ }, "node_modules/redent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { @@ -21392,6 +21738,8 @@ }, "node_modules/requireindex": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", "dev": true, "license": "MIT", "engines": { @@ -21771,6 +22119,7 @@ "version": "0.23.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -22404,6 +22753,8 @@ }, "node_modules/spdx-correct": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -22413,6 +22764,8 @@ }, "node_modules/spdx-correct/node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -22810,6 +23163,8 @@ }, "node_modules/strip-indent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -22861,6 +23216,8 @@ }, "node_modules/style-search": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true, "license": "ISC" }, @@ -22943,6 +23300,8 @@ }, "node_modules/stylelint-config-recommended": { "version": "14.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", + "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -22964,6 +23323,8 @@ }, "node_modules/stylelint-config-recommended-scss": { "version": "14.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.1.0.tgz", + "integrity": "sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==", "dev": true, "license": "MIT", "dependencies": { @@ -22986,6 +23347,8 @@ }, "node_modules/stylelint-scss": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.14.0.tgz", + "integrity": "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==", "dev": true, "license": "MIT", "dependencies": { @@ -23006,7 +23369,9 @@ } }, "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { - "version": "7.1.4", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -23923,6 +24288,8 @@ }, "node_modules/trim-newlines": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "dev": true, "license": "MIT", "engines": { @@ -24110,9 +24477,10 @@ }, "node_modules/typescript": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24279,7 +24647,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -24471,6 +24841,8 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -24480,6 +24852,8 @@ }, "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -24489,6 +24863,8 @@ }, "node_modules/validate-npm-package-name": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "dev": true, "license": "ISC", "engines": { diff --git a/package.json b/package.json index a78f1e7..f79ec12 100644 --- a/package.json +++ b/package.json @@ -16,32 +16,34 @@ "devDependencies": { "@automattic/eslint-plugin-wpvip": "^1.3.0", "@playwright/test": "^1.62.1", + "@types/jest": "^30.0.0", + "@types/react": "^18.3.31", + "@wordpress/api-fetch": "^7.53.0", "@wordpress/block-editor": "^16.1.0", "@wordpress/components": "^38.0.0", - "@wordpress/compose": "^8.5.0", "@wordpress/data": "^10.52.0", "@wordpress/e2e-test-utils-playwright": "^1.52.0", + "@wordpress/element": "^8.4.0", "@wordpress/env": "^11.12.0", "@wordpress/hooks": "^4.52.0", "@wordpress/i18n": "^6.25.0", "@wordpress/notices": "^5.52.0", "@wordpress/scripts": "^34.0.0", - "babel-jest": "^30.4.1", "eslint": "^9.39.5", "lint-staged": "^17.3.0", "prettier": "npm:wp-prettier@3.0.3", "simple-git-hooks": "^2.13.1", - "webpack": "^5.109.2" + "typescript": "~6.0.3" }, "scripts": { - "build": "webpack --mode production", - "dev": "webpack --watch", + "build": "wp-scripts build", + "dev": "wp-scripts start", "cmd:format": "prettier '**/*.(js|json|jsx|md|ts|tsx|yml|yaml)'", "cmd:lint:js": "eslint '**/*.{js,jsx,ts,tsx}' --quiet", "format": "npm run cmd:format -- --write", "format:check": "npm run cmd:format -- --check", - "lint": "npm run format:check && npm run lint:js && npm run lint:php", - "lint:fix": "npm run cmd:lint:js . -- --fix", + "lint": "npm run format:check && npm run lint:js && npm run typecheck && npm run lint:php", + "lint:fix": "npm run cmd:lint:js -- --fix", "lint:js": "npm run cmd:lint:js", "lint:php": "npm run lint:phpcs", "lint:phpcs": "vendor/squizlabs/php_codesniffer/bin/phpcs --cache", @@ -50,16 +52,18 @@ "prepare": "npx simple-git-hooks", "postinstall": "composer install --no-dev", "release": "./bin/release", + "release:zip": "npm run build && git diff --exit-code HEAD -- build && wp-scripts plugin-zip --root-folder=vip-governance", "test": "npm run test:unit", "test:unit": "npm run test:php && npm run test:js", "test:e2e": "npx playwright test", "test:php": "composer run test", "test:js": "wp-scripts test-unit-js", + "typecheck": "tsc --noEmit", "wp-cli": "wp-env run cli -- wp" }, "jest": { "testMatch": [ - "/src/**/*.test.js" + "/src/**/*.test.[jt]s?(x)" ], "preset": "@wordpress/jest-preset-default", "testPathIgnorePatterns": [ diff --git a/playwright.config.js b/playwright.config.ts similarity index 95% rename from playwright.config.js rename to playwright.config.ts index fa6a1f3..f60a165 100644 --- a/playwright.config.js +++ b/playwright.config.ts @@ -15,7 +15,7 @@ const config = defineConfig( { timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. // Don't report slow test "files", as we will be running our tests in serial. reportSlowTests: null, - globalSetup: require.resolve( './tests/e2e/globalSetup.js' ), + globalSetup: require.resolve( './tests/e2e/globalSetup.ts' ), testDir: 'tests/e2e', outputDir: path.join( process.cwd(), 'artifacts/test-results' ), snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', diff --git a/src/block-locking.jsx b/src/block-locking.jsx deleted file mode 100644 index 39fc2ef..0000000 --- a/src/block-locking.jsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * WordPress dependencies - */ -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { Disabled } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { select } from '@wordpress/data'; -import { addFilter, applyFilters } from '@wordpress/hooks'; - -/** - * Internal dependencies - */ -import { isBlockAllowedInHierarchy } from './block-utils'; - -export function setupBlockLocking( governanceRules ) { - const withDisabledBlocks = createHigherOrderComponent( BlockEdit => { - return props => { - const { name: blockName, clientId } = props; - - const { getBlockParents, getBlockName } = select( blockEditorStore ); - const parentClientIds = getBlockParents( clientId, true ); - - const isParentLocked = parentClientIds.some( parentClientId => - isBlockLocked( parentClientId ) - ); - - if ( isParentLocked ) { - // To avoid layout issues, only disable the outermost locked block - return ; - } - - const parentBlockNames = parentClientIds.map( parentClientId => - getBlockName( parentClientId ) - ); - - let isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); - - /** - * Change what blocks are allowed to be edited in the block editor. - * - * @param {bool} isAllowed Whether or not the block will be allowed. - * @param {string} blockName The name of the block to be edited. - * @param {string[]} parentBlockNames An array of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - */ - isAllowed = applyFilters( - 'vip_governance__is_block_allowed_for_editing', - isAllowed, - blockName, - parentBlockNames, - governanceRules - ); - - if ( isAllowed ) { - return ; - } else { - // Only available on WP 6.4 and above, so this guards against that. - if ( wp?.blockEditor?.useBlockEditingMode ) { - const { useBlockEditingMode } = wp.blockEditor; - useBlockEditingMode( 'disabled' ); - } - - // Mark block as locked so that children can detect they're within an existing locked block - setBlockLocked( clientId ); - - return ( - <> - -
- -
-
- - ); - } - }; - }, 'withDisabledBlocks' ); - - addFilter( 'editor.BlockEdit', 'wpcomvip-governance/with-disabled-blocks', withDisabledBlocks ); -} - -/** - * In-memory map of block clientIds that have been marked as locked. - * - * This replaces using props.setAttributes() to set lock status, as this caused an - * "unsaved changes" warning to appear in the editor when block locking was in use. - */ -const lockedBlockMap = {}; - -/** - * Marks a block as locked via the block's clientId. - * - * @param {string} clientId Block clientId in editor - * @returns {void} - */ -function setBlockLocked( clientId ) { - lockedBlockMap[ clientId ] = true; -} - -/** - * Returns true if a block has previously been marked as locked, false otherwise. - * - * @param {string} clientId Block clientId in editor - * @returns {boolean} - */ -function isBlockLocked( clientId ) { - return clientId in lockedBlockMap; -} diff --git a/src/block-locking.test.tsx b/src/block-locking.test.tsx new file mode 100644 index 0000000..8be3bc7 --- /dev/null +++ b/src/block-locking.test.tsx @@ -0,0 +1,124 @@ +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { useContext } from '@wordpress/element'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { + createGovernedBlockEdit, + isBlockAllowedForEditing, + setupBlockLocking, + type BlockEditProps, +} from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; + +import type { ReactElement } from 'react'; + +jest.mock( '@wordpress/block-editor', () => ( { + store: 'core/block-editor', + useBlockEditingMode: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + Disabled: jest.fn(), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), +} ) ); + +jest.mock( '@wordpress/element', () => ( { + createContext: jest.fn( () => ( { Provider: jest.fn() } ) ), + useContext: jest.fn(), +} ) ); + +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn(), + applyFilters: jest.fn(), +} ) ); + +jest.mock( './block-utils', () => ( { + isBlockAllowedInHierarchy: jest.fn(), +} ) ); + +describe( 'block locking', () => { + type ProviderElement = ReactElement< { value: boolean; children: ReactElement } >; + + const governanceRules = { allowedBlocks: [ 'core/paragraph' ] }; + const props = { clientId: 'current', name: 'core/heading' }; + + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( useSelect ).mockReturnValue( [ 'core/group' ] ); + jest.mocked( useContext ).mockReturnValue( false ); + } ); + + it( 'applies the public editing filter to the hierarchy decision', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( false ); + jest.mocked( applyFilters ).mockReturnValue( true ); + + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, false ) + ).toBe( true ); + expect( applyFilters ).toHaveBeenCalledWith( + 'vip_governance__is_block_allowed_for_editing', + false, + 'core/heading', + [ 'core/group' ], + governanceRules + ); + } ); + + it( 'allows a child to inherit an existing parent lock without reevaluating it', () => { + expect( + isBlockAllowedForEditing( 'core/heading', [ 'core/group' ], governanceRules, true ) + ).toBe( true ); + expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); + expect( applyFilters ).not.toHaveBeenCalled(); + } ); + + it( 'keeps allowed blocks editable without adding a redundant provider', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( true ); + jest.mocked( applyFilters ).mockReturnValue( true ); + const BlockEdit = jest.fn( () => null ); + const GovernedBlockEdit = createGovernedBlockEdit( BlockEdit, governanceRules ) as ( + blockProps: BlockEditProps + ) => ReactElement; + + const result = GovernedBlockEdit( props ); + + expect( useBlockEditingMode ).toHaveBeenCalledWith( undefined ); + expect( result.type ).toBe( BlockEdit ); + } ); + + it( 'disables a governed block and propagates the locked state', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( false ); + jest.mocked( applyFilters ).mockReturnValue( false ); + const BlockEdit = jest.fn( () => null ); + const GovernedBlockEdit = createGovernedBlockEdit( BlockEdit, governanceRules ) as ( + blockProps: BlockEditProps + ) => ProviderElement; + + const result = GovernedBlockEdit( props ); + + expect( useBlockEditingMode ).toHaveBeenCalledWith( 'disabled' ); + expect( result.props.value ).toBe( true ); + const disabledElement = result.props.children as ReactElement< { + children: ReactElement< { style: Record< string, string | number > } >; + } >; + expect( disabledElement.props.children.props.style ).toEqual( { + opacity: 0.6, + backgroundColor: '#eee', + border: '2px dashed #999', + } ); + } ); + + it( 'registers the Gutenberg BlockEdit wrapper once', () => { + setupBlockLocking( governanceRules ); + + expect( addFilter ).toHaveBeenCalledWith( + 'editor.BlockEdit', + 'wpcomvip-governance/with-disabled-blocks', + expect.any( Function ) + ); + } ); +} ); diff --git a/src/block-locking.tsx b/src/block-locking.tsx new file mode 100644 index 0000000..4383151 --- /dev/null +++ b/src/block-locking.tsx @@ -0,0 +1,101 @@ +import { store as blockEditorStore, useBlockEditingMode } from '@wordpress/block-editor'; +import { Disabled } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { createContext, useContext } from '@wordpress/element'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { isBlockAllowedInHierarchy } from './block-utils'; + +import type { BlockEditorSelectors, BlockName, ClientId, GovernanceRules } from './types'; +import type { ComponentType } from 'react'; + +const LOCKED_BLOCK_STYLES = { + opacity: 0.6, + backgroundColor: '#eee', + border: '2px dashed #999', +}; + +const GovernanceLockContext = createContext( false ); + +export interface BlockEditProps { + clientId: ClientId; + name: BlockName; + [ key: string ]: unknown; +} + +type BlockEditComponent = ComponentType< BlockEditProps >; + +/** Create the governed block editor wrapper. Exported for focused integration tests. */ +export function createGovernedBlockEdit( + BlockEdit: BlockEditComponent, + governanceRules: GovernanceRules +): BlockEditComponent { + return function GovernedBlockEdit( props: BlockEditProps ) { + const { name: blockName, clientId } = props; + const isParentLocked = useContext( GovernanceLockContext ); + const parentBlockNames = useSelect( + selectStore => { + const { getBlockParents, getBlockName } = selectStore( + blockEditorStore + ) as BlockEditorSelectors; + return getBlockParents( clientId, true ) + .map( getBlockName ) + .filter( ( name ): name is BlockName => name !== undefined ); + }, + [ clientId ] + ); + const isAllowed = isBlockAllowedForEditing( + blockName, + parentBlockNames, + governanceRules, + isParentLocked + ); + + useBlockEditingMode( isAllowed ? undefined : 'disabled' ); + + if ( isAllowed ) { + return ; + } + + return ( + + +
+ +
+
+
+ ); + }; +} + +/** Register governance-driven editing restrictions with Gutenberg. */ +export function setupBlockLocking( governanceRules: GovernanceRules ): void { + addFilter( + 'editor.BlockEdit', + 'wpcomvip-governance/with-disabled-blocks', + ( BlockEdit: BlockEditComponent ) => createGovernedBlockEdit( BlockEdit, governanceRules ) + ); +} + +/** Determine whether a block should remain editable. */ +export function isBlockAllowedForEditing( + blockName: BlockName, + parentBlockNames: BlockName[], + governanceRules: GovernanceRules, + isParentLocked: boolean +): boolean { + if ( isParentLocked ) { + return true; + } + + const isAllowed = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); + + return applyFilters( + 'vip_governance__is_block_allowed_for_editing', + isAllowed, + blockName, + parentBlockNames, + governanceRules + ) as boolean; +} diff --git a/src/block-matching.ts b/src/block-matching.ts new file mode 100644 index 0000000..22e4e98 --- /dev/null +++ b/src/block-matching.ts @@ -0,0 +1,26 @@ +import type { BlockName } from './types'; + +type BlockRuleMatch = boolean | RegExpMatchArray | null; + +/** Match a block name using the plugin's legacy wildcard semantics. */ +export function doesBlockNameMatchBlockWildcard( + blockName: BlockName, + rule: string +): BlockRuleMatch { + if ( rule.includes( '*' ) ) { + // Keep compatibility with existing rules: only the first wildcard is expanded, + // other regular-expression syntax remains active, and matching is not anchored. + // eslint-disable-next-line security/detect-non-literal-regexp + return blockName.match( new RegExp( rule.replace( '*', '.*' ) ) ); + } + + return rule === blockName; +} + +/** Return whether a block name matches at least one rule. */ +export function isBlockAllowedByBlockWildcards( + blockName: BlockName, + rules: readonly string[] +): boolean { + return rules.some( rule => doesBlockNameMatchBlockWildcard( blockName, rule ) ); +} diff --git a/src/block-utils.js b/src/block-utils.js deleted file mode 100644 index d4fcc55..0000000 --- a/src/block-utils.js +++ /dev/null @@ -1,114 +0,0 @@ -import { applyFilters } from '@wordpress/hooks'; - -import { getNestedSetting } from './nested-governance-loader'; - -// The list of default core blocks that should be allowed to be inserted, in order to make life easier. -const DEFAULT_CORE_BLOCK_LIST = { - 'core/list': [ 'core/list-item' ], - 'core/columns': [ 'core/column' ], - 'core/page-list': [ 'core/page-list-item' ], - 'core/navigation': [ 'core/navigation-link', 'core/navigation-submenu' ], - 'core/navigation-link': [ 'core/navigation-link', 'core/navigation-submenu', 'core/page-list' ], - 'core/quote': [ 'core/paragraph' ], - 'core/media-text': [ 'core/paragraph' ], - 'core/social-links': [ 'core/social-link' ], - 'core/comments-pagination': [ - 'core/comments-pagination-previous', - 'core/comments-pagination-numbers', - 'core/comments-pagination-next', - ], -}; - -/** - * Given a block name, a parent list and a set of governance rules, determine if - * the block can be inserted. - * - * By default, will return if the block is allowed to be inserted at the root level - * per the user's rules. If a parent block contains a rule for allowedBlocks, - * the function will return if the block is allowed as a child of that parent. - * - * Rules declared in allowedBlocks will override root level rules when the block - * is currently a child of the parent with allowedBlocks. - * - * @param {string} blockName The current block's name. - * @param {string[]} parentBlockNames A list of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - * @returns True if the block is allowed in set of parent blocks, or false otherwise. - */ -export function isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ) { - // Filter to decide if the mode should be cascading or restrictive, where true is cascading and false is restrictive. - const isInCascadingMode = applyFilters( - 'vip_governance__is_block_allowed_in_hierarchy', - true, - blockName, - parentBlockNames, - governanceRules - ); - - // Build the blocks that are allowed using the root level blocks for cascading mode or if no parent has been past, or empty otherwise. - const blocksAllowedToBeInserted = - isInCascadingMode || parentBlockNames.length === 0 ? [ ...governanceRules.allowedBlocks ] : []; - - // Only execute this if we are determining the block under a parent. - if ( parentBlockNames.length > 0 ) { - // Shortcircuit the parent-child hierarchy for some core blocks - if ( - DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ] && - DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ].includes( blockName ) - ) { - return true; - } - - // Only do a search if there are block settings to search through. - if ( governanceRules.blockSettings ) { - // Get the child block's parent block settings at whatever depth its located at. - const nestedSetting = getNestedSetting( - parentBlockNames.reverse(), - 'allowedBlocks', - governanceRules.blockSettings - ); - - // If we found the allowedBlocks for the parent block, add that to the array of blocks that can be inserted. - if ( nestedSetting && nestedSetting.value ) { - blocksAllowedToBeInserted.push( ...nestedSetting.value ); - } - } - } - - // Check if the block is allowed using the array of blocks that can be inserted. - return isBlockAllowedByBlockWildcards( blockName, blocksAllowedToBeInserted ); -} - -/** - * Matches a block name to a list of block wildcard rules. - * For wildcard rules, see doesBlockNameMatchBlockWildcard(). - * - * @param {string} blockName - * @param {string[]} rules - * @returns True if the block name matches any of the rules, false otherwise. - */ -export function isBlockAllowedByBlockWildcards( blockName, rules ) { - return rules.some( rule => doesBlockNameMatchBlockWildcard( blockName, rule ) ); -} - -/** - * Matches a rule to a block name, with the following cases being possible: - * - * 1. ['*'] - matches all blocks - * 2. '*' can be located somewhere else alongside a string, e.g. 'core/*' - matches all core blocks - * 3. ['core/paragraph'] - matches only the core/paragraph block - * - * @param {string} blockName - * @param {string} rule - * @returns True if the block name matches the rule, or false otherwise - */ -export function doesBlockNameMatchBlockWildcard( blockName, rule ) { - if ( rule.includes( '*' ) ) { - // eslint-disable-next-line security/detect-non-literal-regexp - return blockName.match( new RegExp( rule.replace( '*', '.*' ) ) ); - } - - return rule === blockName; -} diff --git a/src/block-utils.test.js b/src/block-utils.test.ts similarity index 79% rename from src/block-utils.test.js rename to src/block-utils.test.ts index 92dd5d2..23bf17e 100644 --- a/src/block-utils.test.js +++ b/src/block-utils.test.ts @@ -14,7 +14,7 @@ describe( 'blockUtils', () => { describe( 'isBlockAllowedInHierarchy', () => { describe( 'cascading mode', () => { beforeEach( () => { - applyFilters.mockImplementation( () => true ); + jest.mocked( applyFilters ).mockImplementation( () => true ); } ); it( 'should return true if the child block is a special core block', () => { @@ -49,6 +49,7 @@ describe( 'blockUtils', () => { const result = isBlockAllowedInHierarchy( blockName, parentBlockNames, governanceRules ); expect( result ).toBe( true ); + expect( parentBlockNames ).toEqual( [ 'core/media-text' ] ); } ); it( 'should return false if the child block is not allowed in the hierarchy', () => { @@ -87,6 +88,40 @@ describe( 'blockUtils', () => { expect( result ).toBe( false ); } ); + it( 'keeps the documented nearest-parent-first order after a nested lookup', () => { + const parentBlockNames = [ 'core/quote', 'core/group' ]; + + isBlockAllowedInHierarchy( 'core/heading', parentBlockNames, { + allowedBlocks: [ 'core/paragraph' ], + blockSettings: { + 'core/group': { + 'core/quote': { + allowedBlocks: [ 'core/heading' ], + }, + }, + }, + } ); + + expect( parentBlockNames ).toEqual( [ 'core/quote', 'core/group' ] ); + } ); + + it( 'resolves nested allowedBlocks through repeated parent names', () => { + expect( + isBlockAllowedInHierarchy( 'core/heading', [ 'core/quote', 'core/group', 'core/group' ], { + allowedBlocks: [ 'core/paragraph' ], + blockSettings: { + 'core/group': { + 'core/group': { + 'core/quote': { + allowedBlocks: [ 'core/heading' ], + }, + }, + }, + }, + } ) + ).toBe( true ); + } ); + it( 'should return true if the child block is allowed in the hierarchy with no blockSettings', () => { const blockName = 'core/heading'; const parentBlockNames = [ 'core/media-text' ]; @@ -113,7 +148,7 @@ describe( 'blockUtils', () => { it( 'should return true if the root block is allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/heading', 'core/paragraph' ], }; @@ -125,7 +160,7 @@ describe( 'blockUtils', () => { it( 'should return false if the root block is not allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/paragraph' ], }; @@ -134,11 +169,24 @@ describe( 'blockUtils', () => { expect( result ).toBe( false ); } ); + + it( 'does not apply allowedBlocks declared under a wildcard parent', () => { + const result = isBlockAllowedInHierarchy( 'core/heading', [ 'core/group' ], { + allowedBlocks: [ 'core/group' ], + blockSettings: { + 'core/*': { + allowedBlocks: [ 'core/heading' ], + }, + }, + } ); + + expect( result ).toBe( false ); + } ); } ); describe( 'restrictive mode', () => { beforeEach( () => { - applyFilters.mockImplementation( () => false ); + jest.mocked( applyFilters ).mockImplementation( () => false ); } ); it( 'should return true if the child block is a special core block', () => { @@ -218,7 +266,7 @@ describe( 'blockUtils', () => { it( 'should return true if the root block is allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/heading', 'core/paragraph' ], }; @@ -230,7 +278,7 @@ describe( 'blockUtils', () => { it( 'should return false if the root block is not allowed in the hierarchy', () => { const blockName = 'core/heading'; - const parentBlockNames = []; + const parentBlockNames: string[] = []; const governanceRules = { allowedBlocks: [ 'core/paragraph' ], }; @@ -286,6 +334,23 @@ describe( 'blockUtils', () => { expect( result ).toBeFalsy(); } ); + it( 'preserves unanchored wildcard matching from existing rules', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/*' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'custom/core/heading', 'core/*' ) ).toBeTruthy(); + } ); + + it( 'preserves regular-expression syntax in wildcard rules', () => { + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/head*' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', '*heading' ) ).toBeTruthy(); + expect( doesBlockNameMatchBlockWildcard( 'core/heading', 'core/.+*' ) ).toBeTruthy(); + } ); + + it( 'only expands the first wildcard for compatibility', () => { + expect( () => doesBlockNameMatchBlockWildcard( 'core/heading', 'core/**' ) ).toThrow( + SyntaxError + ); + } ); + it( 'should return true if the block name matches any of the rules', () => { const blockName = 'core/heading'; const rules = 'core/heading'; diff --git a/src/block-utils.ts b/src/block-utils.ts new file mode 100644 index 0000000..b0430a7 --- /dev/null +++ b/src/block-utils.ts @@ -0,0 +1,67 @@ +import { applyFilters } from '@wordpress/hooks'; + +export { doesBlockNameMatchBlockWildcard, isBlockAllowedByBlockWildcards } from './block-matching'; +import { isBlockAllowedByBlockWildcards } from './block-matching'; +import { getNestedSetting } from './nested-settings-filter'; + +import type { BlockName, BlockSettings, GovernanceRules } from './types'; + +const DEFAULT_CORE_BLOCK_LIST: Readonly< Record< BlockName, readonly BlockName[] > > = { + 'core/list': [ 'core/list-item' ], + 'core/columns': [ 'core/column' ], + 'core/page-list': [ 'core/page-list-item' ], + 'core/navigation': [ 'core/navigation-link', 'core/navigation-submenu' ], + 'core/navigation-link': [ 'core/navigation-link', 'core/navigation-submenu', 'core/page-list' ], + 'core/quote': [ 'core/paragraph' ], + 'core/media-text': [ 'core/paragraph' ], + 'core/social-links': [ 'core/social-link' ], + 'core/comments-pagination': [ + 'core/comments-pagination-previous', + 'core/comments-pagination-numbers', + 'core/comments-pagination-next', + ], +}; + +function getNestedAllowedBlocks( + parentBlockNames: BlockName[], + blockSettings?: BlockSettings +): BlockName[] { + if ( ! blockSettings ) { + return []; + } + + const { value } = getNestedSetting( parentBlockNames, 'allowedBlocks', blockSettings ); + + return Array.isArray( value ) && value.every( item => typeof item === 'string' ) ? value : []; +} + +/** Determine whether a block can be inserted or edited at a hierarchy location. */ +export function isBlockAllowedInHierarchy( + blockName: BlockName, + parentBlockNames: BlockName[], + governanceRules: GovernanceRules +): boolean { + const isInCascadingMode = applyFilters( + 'vip_governance__is_block_allowed_in_hierarchy', + true, + blockName, + parentBlockNames, + governanceRules + ) as boolean; + + const allowedBlocks = + isInCascadingMode || parentBlockNames.length === 0 ? [ ...governanceRules.allowedBlocks ] : []; + + if ( parentBlockNames.length > 0 ) { + const immediateParentDefaults = DEFAULT_CORE_BLOCK_LIST[ parentBlockNames[ 0 ] ]; + if ( immediateParentDefaults?.includes( blockName ) ) { + return true; + } + + allowedBlocks.push( + ...getNestedAllowedBlocks( parentBlockNames, governanceRules.blockSettings ) + ); + } + + return isBlockAllowedByBlockWildcards( blockName, allowedBlocks ); +} diff --git a/src/editor.test.ts b/src/editor.test.ts new file mode 100644 index 0000000..81cdac2 --- /dev/null +++ b/src/editor.test.ts @@ -0,0 +1,119 @@ +import { dispatch, select } from '@wordpress/data'; +import { addFilter, applyFilters } from '@wordpress/hooks'; + +import { setupBlockLocking } from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; +import { setupGovernance } from './editor'; +import { resolveNestedSetting } from './nested-settings-filter'; + +import type { GovernanceRuntimeConfig } from './types'; + +jest.mock( '@wordpress/block-editor', () => ( { store: 'core/block-editor' } ) ); +jest.mock( '@wordpress/data', () => ( { dispatch: jest.fn(), select: jest.fn() } ) ); +jest.mock( '@wordpress/hooks', () => ( { addFilter: jest.fn(), applyFilters: jest.fn() } ) ); +jest.mock( '@wordpress/i18n', () => ( { __: jest.fn( ( value: string ) => value ) } ) ); +jest.mock( '@wordpress/notices', () => ( { store: 'core/notices' } ) ); +jest.mock( './block-locking', () => ( { setupBlockLocking: jest.fn() } ) ); +jest.mock( './block-utils', () => ( { isBlockAllowedInHierarchy: jest.fn() } ) ); +jest.mock( './nested-settings-filter', () => ( { + createNestedSettingRules: jest.fn( () => ( { pathsByBlock: new Map(), settings: {} } ) ), + resolveNestedSetting: jest.fn(), +} ) ); + +const config: GovernanceRuntimeConfig = { + error: false, + governanceRules: { + allowedBlocks: [ 'core/paragraph' ], + allowedFeatures: [], + blockSettings: {}, + }, + nestedSettings: {}, + urlSettingsPage: '/wp-admin/admin.php?page=vip-block-governance', +}; + +type FilterCallback = ( ...args: unknown[] ) => unknown; + +function getRegisteredFilter( hookName: string ): FilterCallback { + const filterCall = jest.mocked( addFilter ).mock.calls.find( call => call[ 0 ] === hookName ); + if ( ! filterCall ) { + throw new Error( `Filter ${ hookName } was not registered.` ); + } + + return filterCall[ 2 ] as FilterCallback; +} + +describe( 'setupGovernance', () => { + const createErrorNotice = jest.fn( () => Promise.resolve() ); + const getBlockName = jest.fn( ( clientId: string ) => { + return clientId === 'ancestor' ? 'core/group' : 'core/quote'; + } ); + const getBlockParents = jest.fn( () => [ 'ancestor' ] ); + + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( dispatch ).mockReturnValue( { createErrorNotice } ); + jest.mocked( select ).mockReturnValue( { getBlockName, getBlockParents } as never ); + } ); + + it( 'shows a settings action and registers no filters when configuration failed', () => { + setupGovernance( { ...config, error: 'Rules failed to load.' } ); + + expect( createErrorNotice ).toHaveBeenCalledWith( 'Rules failed to load.', { + id: 'wpcomvip-governance-error', + isDismissible: true, + actions: [ + { + label: 'Open governance settings', + url: config.urlSettingsPage, + }, + ], + } ); + expect( addFilter ).not.toHaveBeenCalled(); + } ); + + it( 'preserves an insertion veto from WordPress without evaluating governance', () => { + setupGovernance( config ); + const insertionFilter = getRegisteredFilter( 'blockEditor.__unstableCanInsertBlockType' ); + + expect( + insertionFilter( false, { name: 'core/heading' }, undefined, { + getBlock: jest.fn(), + } ) + ).toBe( false ); + expect( isBlockAllowedInHierarchy ).not.toHaveBeenCalled(); + } ); + + it( 'passes the nearest-parent-first hierarchy through the public insertion filter', () => { + jest.mocked( isBlockAllowedInHierarchy ).mockReturnValue( true ); + jest.mocked( applyFilters ).mockReturnValue( false ); + setupGovernance( config ); + const insertionFilter = getRegisteredFilter( 'blockEditor.__unstableCanInsertBlockType' ); + + expect( + insertionFilter( true, { name: 'core/heading' }, 'parent', { + getBlock: () => ( { clientId: 'parent', name: 'core/quote' } ), + } ) + ).toBe( false ); + expect( isBlockAllowedInHierarchy ).toHaveBeenCalledWith( + 'core/heading', + [ 'core/quote', 'core/group' ], + config.governanceRules + ); + expect( applyFilters ).toHaveBeenCalledWith( + 'vip_governance__is_block_allowed_for_insertion', + true, + 'core/heading', + [ 'core/quote', 'core/group' ], + config.governanceRules + ); + } ); + + it( 'registers nested settings and block locking while preserving missing block names', () => { + setupGovernance( config ); + const settingsFilter = getRegisteredFilter( 'blockEditor.useSetting.before' ); + + expect( settingsFilter( 'original', 'color.text', 'current', undefined ) ).toBe( 'original' ); + expect( resolveNestedSetting ).not.toHaveBeenCalled(); + expect( setupBlockLocking ).toHaveBeenCalledWith( config.governanceRules ); + } ); +} ); diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..c438e7d --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,107 @@ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { dispatch, select } from '@wordpress/data'; +import { addFilter, applyFilters } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import { store as noticeStore } from '@wordpress/notices'; + +import { setupBlockLocking } from './block-locking'; +import { isBlockAllowedInHierarchy } from './block-utils'; +import { createNestedSettingRules, resolveNestedSetting } from './nested-settings-filter'; + +import type { + BlockEditorInsertionSelectors, + BlockEditorSelectors, + BlockName, + BlockType, + ClientId, + GovernanceRuntimeConfig, +} from './types'; + +type InsertionFilterSelectors = Pick< BlockEditorInsertionSelectors, 'getBlock' >; + +/** Register editor governance filters from the server-localized configuration. */ +export function setupGovernance( config: GovernanceRuntimeConfig ): void { + if ( config.error ) { + void dispatch( noticeStore ).createErrorNotice( config.error, { + id: 'wpcomvip-governance-error', + isDismissible: true, + actions: [ + { + label: __( 'Open governance settings', 'vip-governance' ), + url: config.urlSettingsPage, + }, + ], + } ); + return; + } + + const { governanceRules } = config; + const { getBlockParents, getBlockName } = select( blockEditorStore ) as BlockEditorSelectors; + + addFilter( + 'blockEditor.__unstableCanInsertBlockType', + 'wpcomvip-governance/block-insertion', + ( + canInsert: boolean, + blockType: BlockType, + rootClientId: ClientId | undefined, + { getBlock }: InsertionFilterSelectors + ): boolean => { + if ( canInsert === false ) { + return false; + } + + let parentBlockNames: BlockName[] = []; + if ( rootClientId ) { + const parentBlock = getBlock( rootClientId ); + const ancestorNames = getBlockParents( rootClientId, true ) + .map( getBlockName ) + .filter( ( name ): name is BlockName => name !== undefined ); + const immediateParentName = parentBlock?.name ?? getBlockName( rootClientId ); + + parentBlockNames = immediateParentName + ? [ immediateParentName, ...ancestorNames ] + : ancestorNames; + } + + const isAllowed = isBlockAllowedInHierarchy( + blockType.name, + parentBlockNames, + governanceRules + ); + + return applyFilters( + 'vip_governance__is_block_allowed_for_insertion', + isAllowed, + blockType.name, + parentBlockNames, + governanceRules + ) as boolean; + } + ); + + const nestedSettingRules = createNestedSettingRules( config.nestedSettings ); + addFilter( + 'blockEditor.useSetting.before', + 'wpcomvip-governance/nested-block-settings', + ( defaultValue: unknown, path: string, clientId: ClientId, blockName?: BlockName ): unknown => { + if ( ! blockName ) { + return defaultValue; + } + + return resolveNestedSetting( { + clientId, + currentBlockName: blockName, + defaultValue, + getBlockName, + getBlockParents, + path, + rules: nestedSettingRules, + } ); + } + ); + + if ( governanceRules.allowedBlocks ) { + setupBlockLocking( governanceRules ); + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 5e0aef3..0000000 --- a/src/index.js +++ /dev/null @@ -1,156 +0,0 @@ -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { dispatch, select } from '@wordpress/data'; -import { addFilter, applyFilters } from '@wordpress/hooks'; -import { __ } from '@wordpress/i18n'; -import { store as noticeStore } from '@wordpress/notices'; - -import { setupBlockLocking } from './block-locking'; -import { doesBlockNameMatchBlockWildcard, isBlockAllowedInHierarchy } from './block-utils'; -import { getNestedSetting, getNestedSettingPaths } from './nested-governance-loader'; - -function setup() { - if ( VIP_GOVERNANCE.error ) { - dispatch( noticeStore ).createErrorNotice( VIP_GOVERNANCE.error, { - id: 'wpcomvip-governance-error', - isDismissible: true, - actions: [ - { - label: __( 'Open governance settings' ), - url: VIP_GOVERNANCE.urlSettingsPage, - }, - ], - } ); - - return; - } - - const governanceRules = VIP_GOVERNANCE.governanceRules; - - addFilter( - 'blockEditor.__unstableCanInsertBlockType', - `wpcomvip-governance/block-insertion`, - ( canInsert, blockType, rootClientId, { getBlock } ) => { - if ( canInsert === false ) { - return canInsert; - } - - let parentBlockNames = []; - - if ( rootClientId ) { - // This block has parents. Build a list of parentBlockNames - const { getBlockParents, getBlockName } = select( blockEditorStore ); - const parentBlock = getBlock( rootClientId ); - const ancestorClientIds = getBlockParents( rootClientId, true ); - - parentBlockNames = [ parentBlock.clientId, ...ancestorClientIds ].map( parentClientId => - getBlockName( parentClientId ) - ); - } - - const isAllowed = isBlockAllowedInHierarchy( - blockType.name, - parentBlockNames, - governanceRules - ); - - /** - * Change what blocks are allowed to be inserted in the block editor. - * - * @param {bool} isAllowed Whether or not the block will be allowed. - * @param {string} blockName The name of the block to be inserted. - * @param {string[]} parentBlockNames An array of zero or more parent block names, - * starting with the most recent parent ancestor. - * @param {Object} governanceRules An object containing the full set of governance - * rules for the current user. - */ - return applyFilters( - 'vip_governance__is_block_allowed_for_insertion', - isAllowed, - blockType.name, - parentBlockNames, - governanceRules - ); - } - ); - - const nestedSettings = VIP_GOVERNANCE.nestedSettings; - const nestedSettingPaths = getNestedSettingPaths( nestedSettings ); - - const nestedWildcardPaths = {}; - const nestedNonWildcardPaths = {}; - - for ( const blockName in nestedSettingPaths ) { - if ( blockName.indexOf( '*' ) === -1 ) { - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ] = nestedSettingPaths[ blockName ]; - } else { - // eslint-disable-next-line security/detect-object-injection - nestedWildcardPaths[ blockName ] = nestedSettingPaths[ blockName ]; - } - } - - addFilter( - 'blockEditor.useSetting.before', - `wpcomvip-governance/nested-block-settings`, - ( result, path, clientId, blockName ) => { - if ( ! blockName ) { - return result; - } - - // Test if the blockName is in the nestedNonWildcardPaths. - if ( - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ] !== undefined && - // eslint-disable-next-line security/detect-object-injection - nestedNonWildcardPaths[ blockName ][ path ] === true - ) { - const blockNamePath = [ - clientId, - ...select( blockEditorStore ).getBlockParents( clientId, /* ascending */ true ), - ] - .map( candidateId => select( blockEditorStore ).getBlockName( candidateId ) ) - .reverse(); - ( { value: result } = getNestedSetting( blockNamePath, path, nestedSettings ) ); - - // This is necessary because the nestedSettingPaths are flattened, so a child's path could match the parent's path. - return result && result.theme ? result.theme : result; - // Test if the blockName is in the nestedWildcardPaths. - } else if ( nestedWildcardPaths.length !== 0 ) { - for ( const nestedBlockName in nestedWildcardPaths ) { - if ( - doesBlockNameMatchBlockWildcard( blockName, nestedBlockName ) && - // eslint-disable-next-line security/detect-object-injection - nestedWildcardPaths[ nestedBlockName ][ path ] === true - ) { - const blockNamePath = [ - clientId, - ...select( blockEditorStore ).getBlockParents( clientId, /* ascending */ true ), - ] - .map( candidateId => select( blockEditorStore ).getBlockName( candidateId ) ) - .reverse(); - - // Replace the original block name with the matched wildcard block name, for easier lookup. - // This will be at the end of the blockNamePath array. - if ( nestedBlockName.indexOf( '*' ) !== -1 ) { - blockNamePath[ blockNamePath.length - 1 ] = nestedBlockName; - } - - ( { value: result } = getNestedSetting( blockNamePath, path, nestedSettings ) ); - - // This is necessary because the nestedSettingPaths are flattened, so a child's path could match the parent's path. - return result && result.theme ? result.theme : result; - } - } - } - - return result; - } - ); - - // Block locking - if ( governanceRules?.allowedBlocks ) { - setupBlockLocking( governanceRules ); - } -} - -setup(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..97e7f3b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import { setupGovernance } from './editor'; + +setupGovernance( VIP_GOVERNANCE ); diff --git a/src/nested-governance-loader.js b/src/nested-governance-loader.js deleted file mode 100644 index 5ce913e..0000000 --- a/src/nested-governance-loader.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Find the list of nestedPaths that can be found in the block settings, so that - * it's faster to find out if a deeper nested setting exists or not. - * - * @param {Object} nestedSettings the nestedSettings found from the governance rules. - * @param {Object} nestedMetadata the nestedMetadata object that's to be populated with the paths. - * @param {String} currentBlock the current nested block name being processed. - * @returns {Object} Map of the block name along with the nested paths that can be found inside. - */ -export function getNestedSettingPaths( nestedSettings, nestedMetadata = {}, currentBlock = false ) { - const SETTINGS_TO_SKIP = [ 'allowedBlocks' ]; - for ( const [ settingKey, settingValue ] of Object.entries( nestedSettings ) ) { - if ( SETTINGS_TO_SKIP.includes( settingKey ) ) { - continue; - } - - // A nested block would be in the form of blockName/childBlockName or blockName/* or * - const isNestedBlock = settingKey.includes( '/' ) || settingKey === '*'; - - if ( isNestedBlock ) { - // This setting contains another block, look at the child for metadata - Object.entries( nestedSettings ).forEach( ( [ blockName, blockNestedSettings ] ) => { - if ( ! SETTINGS_TO_SKIP.includes( blockName ) ) { - getNestedSettingPaths( blockNestedSettings, nestedMetadata, blockName ); - } - } ); - } else if ( currentBlock !== false ) { - // This is a leaf block, add setting paths to nestedMetadata - const settingPaths = flattenSettingPaths( settingValue, `${ settingKey }.` ); - - // eslint-disable-next-line security/detect-object-injection - nestedMetadata[ currentBlock ] = { - // eslint-disable-next-line security/detect-object-injection - ...( nestedMetadata[ currentBlock ] ?? {} ), - ...settingPaths, - }; - } - } - - return nestedMetadata; -} - -/** - * Find block settings nested in other block settings. - * - * Given an array of blocks names from the top level of the editor to the - * current block (`blockNamePath`), return the value for the deepest-nested - * settings value that applies to the current block. - * - * If two setting values share the same nesting depth, use the last one that - * occurs in settings (like CSS). - * - * @param {string[]} blockNamePath Block names representing the path to the - * current block from the top level of the - * block editor. - * @param {string} normalizedPath Path to the setting being retrieved. - * @param {Object} settings Object containing all block settings. - * @param {Object} result Optional. Object with keys `depth` and - * `value` used to track current most-nested - * setting. - * @param {number} depth Optional. The current recursion depth used - * to calculate the most-nested setting. - * @return {Object} Object with keys `depth` and `value`. - * Destructure the `value` key for the result. - */ -export function getNestedSetting( - blockNamePath, - normalizedPath, - settings, - result = { depth: 0, value: undefined }, - depth = 1 -) { - const [ currentBlockName, ...remainingBlockNames ] = blockNamePath; - // eslint-disable-next-line security/detect-object-injection - const blockSettings = settings[ currentBlockName ]; - - if ( remainingBlockNames.length === 0 ) { - const settingValue = deepGet( blockSettings, normalizedPath ); - - if ( settingValue !== undefined && depth >= result.depth ) { - result.depth = depth; - result.value = settingValue; - } - - return result; - } else if ( blockSettings !== undefined ) { - // Recurse into the parent block's settings - result = getNestedSetting( - remainingBlockNames, - normalizedPath, - blockSettings, - result, - depth + 1 - ); - } - - // Continue down the array of blocks - return getNestedSetting( remainingBlockNames, normalizedPath, settings, result, depth ); -} - -/** - * Port of lodash's get function from https://gist.github.com/andrewchilds/30a7fb18981d413260c7a36428ed13da?permalink_comment_id=4433741#gistcomment-4433741 - * @param {Object} value The value to query. - * @param {String} query The query to run. - * @param {Object} defaultVal The default value to return if the query doesn't exist. - * @returns - */ -function deepGet( value, query, defaultVal = undefined ) { - const splitQuery = Array.isArray( query ) - ? query - : query - .replace( /(\[(\d)\])/g, '.$2' ) - .replace( /^\./, '' ) - .split( '.' ); - - if ( ! splitQuery.length || splitQuery[ 0 ] === undefined ) return value; - - const key = splitQuery[ 0 ]; - - if ( - typeof value !== 'object' || - value === null || - ! ( key in value ) || - // eslint-disable-next-line security/detect-object-injection - value[ key ] === undefined - ) { - return defaultVal; - } - - // eslint-disable-next-line security/detect-object-injection - return deepGet( value[ key ], splitQuery.slice( 1 ), defaultVal ); -} - -/** - * Flatten a nested object into a map of paths. - * @param {Object} settings The settings value that is to be flattened. - * @param {String} prefix The key for the settings value. - * @returns {Object} the flattened settings object. - */ -function flattenSettingPaths( settings, prefix = '' ) { - const result = {}; - - Object.entries( settings ).forEach( ( [ key, value ] ) => { - const isRegularObject = - typeof value === 'object' && Boolean( value ) && ! Array.isArray( value ); - - if ( isRegularObject ) { - result[ `${ prefix }${ key }` ] = true; - Object.assign( result, flattenSettingPaths( value, `${ prefix }${ key }.` ) ); - } else { - result[ `${ prefix }${ key }` ] = true; - } - } ); - - return result; -} diff --git a/src/nested-governance-loader.test.js b/src/nested-governance-loader.test.js deleted file mode 100644 index 5d8f997..0000000 --- a/src/nested-governance-loader.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { getNestedSettingPaths, getNestedSetting } from './nested-governance-loader'; - -describe( 'getNestedSettingPaths', () => { - describe( 'getNestedSettingPaths', () => { - it( 'should return the nested setting paths given the nested settings', () => { - const result = getNestedSettingPaths( getTestNestedSettings() ); - expect( result ).toEqual( { - 'core/heading': { - 'color.palette': true, - 'color.palette.theme': true, - }, - 'core/paragraph': { - 'color.palette': true, - 'color.palette.theme': true, - }, - } ); - } ); - } ); - - describe( 'getNestedSetting', () => { - it( 'should return the nested setting found at the top level', () => { - const result = getNestedSetting( - [ 'core/heading' ], - 'color.palette.theme', - getTestNestedSettings() - ); - expect( result ).toEqual( { - depth: 1, - value: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - } ); - } ); - - it( 'should return the nested setting found deep inside a nested block', () => { - const result = getNestedSetting( - [ 'core/media-text', 'core/quote', 'core/paragraph' ], - 'color.palette.theme', - getTestNestedSettings() - ); - expect( result ).toEqual( { - depth: 3, - value: [ - { - color: '#F00001', - name: 'Tertiary', - slug: 'tertiary', - }, - ], - } ); - } ); - } ); -} ); - -function getTestNestedSettings() { - return { - 'core/heading': { - color: { - palette: { - theme: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - }, - }, - }, - 'core/quote': { - allowedBlocks: [ 'core/paragraph' ], - 'core/paragraph': { - color: { - palette: { - theme: [ - { - color: '#F00000', - name: 'Secondary', - slug: 'secondary', - }, - ], - }, - }, - }, - }, - 'core/media-text': { - allowedBlocks: [ 'core/heading', 'core/paragraph' ], - 'core/heading': { - color: { - palette: { - theme: [ - { - color: '#FFFFF', - name: 'Primary', - slug: 'primary', - }, - ], - }, - }, - }, - 'core/quote': { - allowedBlocks: [ 'core/paragraph' ], - 'core/paragraph': { - color: { - palette: { - theme: [ - { - color: '#F00001', - name: 'Tertiary', - slug: 'tertiary', - }, - ], - }, - }, - }, - }, - }, - }; -} diff --git a/src/nested-settings-filter.test.ts b/src/nested-settings-filter.test.ts new file mode 100644 index 0000000..88bf80b --- /dev/null +++ b/src/nested-settings-filter.test.ts @@ -0,0 +1,261 @@ +import { + createNestedSettingRules, + getNestedSetting, + resolveNestedSetting, +} from './nested-settings-filter'; + +import type { BlockSettings } from './types'; + +describe( 'nested settings filter', () => { + const namesById: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/heading', + }; + const getBlockName = jest.fn( ( id: string ) => namesById[ id ] ); + + function resolve( + nestedSettings: BlockSettings, + options: { + getBlockName?: ( id: string ) => string | undefined; + getBlockParents?: () => string[]; + path?: string; + } = {} + ) { + return resolveNestedSetting( { + defaultValue: 'original', + path: 'color.text', + clientId: 'current', + rules: createNestedSettingRules( nestedSettings ), + getBlockParents: () => [ 'root' ], + getBlockName, + ...options, + } ); + } + + it( 'prefers an exact block rule over a wildcard at the same depth', () => { + expect( + resolve( { + 'core/*': { color: { text: false } }, + 'core/heading': { color: { text: true } }, + } ) + ).toBe( true ); + } ); + + it( 'applies a wildcard block setting when no exact rule exists', () => { + expect( + resolve( { + 'core/*': { color: { text: false } }, + } ) + ).toBe( false ); + } ); + + it( 'returns the original value when no governed path applies', () => { + expect( + resolve( { 'core/*': { color: { text: false } } }, { path: 'typography.dropCap' } ) + ).toBe( 'original' ); + } ); + + it( 'uses the deepest exact setting in the current hierarchy', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'uses the deepest setting when ancestor block names repeat', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/group', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/paragraph': { color: { text: true } }, + 'core/group': { + 'core/group': { + 'core/paragraph': { color: { text: false } }, + }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'uses the later setting when matching paths have equal depth', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + parent: 'core/quote', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/group': { + 'core/paragraph': { color: { text: true } }, + }, + 'core/quote': { + 'core/paragraph': { color: { text: false } }, + }, + }, + { + getBlockParents: () => [ 'parent', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'ignores a missing ancestor name while resolving nested settings', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + current: 'core/heading', + }; + + expect( + resolve( + { + 'core/group': { + 'core/heading': { color: { text: false } }, + }, + }, + { + getBlockParents: () => [ 'missing', 'root' ], + getBlockName: ( id: string ) => hierarchyNames[ id ], + } + ) + ).toBe( false ); + } ); + + it( 'preserves a root exact rule instead of using a deeper wildcard rule', () => { + expect( + resolve( + { + 'core/heading': { color: { text: true } }, + 'core/quote': { + 'core/*': { color: { text: false } }, + }, + }, + { getBlockParents: () => [ 'parent' ] } + ) + ).toBe( true ); + } ); + + it( 'preserves the legacy limitation on wildcard ancestors', () => { + const hierarchyNames: Record< string, string > = { + root: 'core/group', + current: 'core/paragraph', + }; + + expect( + resolve( + { + 'core/*': { + 'core/paragraph': { color: { text: false } }, + }, + }, + { getBlockName: ( id: string ) => hierarchyNames[ id ] } + ) + ).toBeUndefined(); + } ); + + it( 'uses the first matching wildcard declaration', () => { + expect( + resolve( { + '*': { color: { text: true } }, + 'core/*': { color: { text: false } }, + } ) + ).toBe( true ); + } ); + + it( 'unwraps the theme value expected by the WordPress settings API', () => { + const palette = [ { color: '#fff', name: 'White', slug: 'white' } ]; + + expect( + resolve( + { + 'core/heading': { color: { palette: { theme: palette } } }, + }, + { path: 'color.palette' } + ) + ).toBe( palette ); + } ); + + it( 'preserves the legacy limitation on top-level primitive settings', () => { + expect( + resolve( + { + 'core/heading': { useRootPaddingAwareAlignments: true }, + }, + { path: 'useRootPaddingAwareAlignments' } + ) + ).toBe( 'original' ); + } ); + + it( 'returns an array setting without changing its value', () => { + const units = [ 'px', 'rem', '%' ]; + + expect( + resolve( + { + 'core/heading': { spacing: { units } }, + }, + { path: 'spacing.units' } + ) + ).toBe( units ); + } ); + + it( 'preserves exact-name nested allowedBlocks traversal', () => { + const settings = { + 'core/group': { + allowedBlocks: [ 'core/heading' ], + 'core/quote': { allowedBlocks: [ 'core/paragraph' ] }, + }, + }; + + expect( getNestedSetting( [ 'core/quote', 'core/group' ], 'allowedBlocks', settings ) ).toEqual( + { depth: 2, value: [ 'core/paragraph' ] } + ); + } ); + + it( 'preserves bracket path lookup compatibility', () => { + expect( + getNestedSetting( [ 'core/group' ], 'items[0].enabled', { + 'core/group': { items: [ { enabled: true } ] }, + } ).value + ).toBe( true ); + } ); + + it( 'preserves the documented wildcard limitation for parent allowedBlocks', () => { + expect( + getNestedSetting( [ 'core/group' ], 'allowedBlocks', { + 'core/*': { allowedBlocks: [ 'core/paragraph' ] }, + } ) + ).toEqual( { depth: 0, value: undefined } ); + } ); +} ); diff --git a/src/nested-settings-filter.ts b/src/nested-settings-filter.ts new file mode 100644 index 0000000..ac9b831 --- /dev/null +++ b/src/nested-settings-filter.ts @@ -0,0 +1,202 @@ +import { doesBlockNameMatchBlockWildcard } from './block-matching'; + +import type { BlockEditorSelectors, BlockName, BlockSettings, ClientId } from './types'; + +const ALLOWED_BLOCKS_KEY = 'allowedBlocks'; + +export interface NestedSettingRules { + pathsByBlock: Map< BlockName, Set< string > >; + settings: BlockSettings; +} + +interface ResolveForBlockNamesOptions { + blockNames: BlockName[]; + defaultValue: unknown; + path: string; + rules: NestedSettingRules; +} + +interface ResolveNestedSettingOptions + extends Omit< ResolveForBlockNamesOptions, 'blockNames' >, + BlockEditorSelectors { + clientId: ClientId; + currentBlockName?: BlockName; +} + +export interface NestedSettingResult { + depth: number; + value: unknown; +} + +function isRecord( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +function addSettingPaths( value: unknown, prefix: string, paths: Set< string > ): void { + if ( typeof value !== 'object' || value === null ) { + return; + } + + for ( const [ key, childValue ] of Object.entries( value ) ) { + const path = `${ prefix }${ key }`; + paths.add( path ); + + if ( isRecord( childValue ) ) { + addSettingPaths( childValue, `${ path }.`, paths ); + } + } +} + +/** Index visual/editor setting paths by their final block name or wildcard. */ +export function createNestedSettingRules( settings: BlockSettings ): NestedSettingRules { + const pathsByBlock: NestedSettingRules[ 'pathsByBlock' ] = new Map(); + + const visit = ( nestedSettings: BlockSettings, currentBlock?: BlockName ) => { + for ( const [ key, value ] of Object.entries( nestedSettings ) ) { + if ( key === ALLOWED_BLOCKS_KEY ) { + continue; + } + + if ( ( key.includes( '/' ) || key === '*' ) && isRecord( value ) ) { + visit( value, key ); + } else if ( currentBlock ) { + const paths = pathsByBlock.get( currentBlock ) ?? new Set(); + addSettingPaths( value, `${ key }.`, paths ); + pathsByBlock.set( currentBlock, paths ); + } + } + }; + + visit( settings ); + return { pathsByBlock, settings }; +} + +function deepGet( value: unknown, path: string | string[] ): unknown { + const keys = Array.isArray( path ) + ? path + : path + .replace( /(\[(\d)\])/g, '.$2' ) + .replace( /^\./, '' ) + .split( '.' ); + + if ( keys.length === 0 ) { + return value; + } + + if ( typeof value !== 'object' || value === null ) { + return undefined; + } + + const [ key, ...remainingPath ] = keys; + return key in value + ? deepGet( ( value as Record< string, unknown > )[ key ], remainingPath ) + : undefined; +} + +/** Resolve an exact-name setting from a nearest-block-first hierarchy. */ +export function getNestedSetting( + blockNamePath: BlockName[], + normalizedPath: string, + settings: BlockSettings, + result: NestedSettingResult = { depth: 0, value: undefined }, + depth = 1 +): NestedSettingResult { + if ( blockNamePath.length === 0 ) { + return result; + } + + const currentBlockName = blockNamePath.at( -1 ); + if ( currentBlockName === undefined ) { + return result; + } + + const remainingBlockNames = blockNamePath.slice( 0, -1 ); + const candidate = settings[ currentBlockName ]; + const blockSettings = isRecord( candidate ) ? candidate : undefined; + + if ( remainingBlockNames.length === 0 ) { + const value = deepGet( blockSettings, normalizedPath ); + return value !== undefined && depth >= result.depth ? { depth, value } : result; + } + + if ( blockSettings ) { + result = getNestedSetting( + remainingBlockNames, + normalizedPath, + blockSettings, + result, + depth + 1 + ); + } + + return getNestedSetting( remainingBlockNames, normalizedPath, settings, result, depth ); +} + +/** Return block names from the current block through the root ancestor. */ +export function getBlockNamePath( + clientId: ClientId, + getBlockParents: BlockEditorSelectors[ 'getBlockParents' ], + getBlockName: BlockEditorSelectors[ 'getBlockName' ], + currentBlockName?: BlockName +): BlockName[] { + return [ + currentBlockName ?? getBlockName( clientId ), + ...getBlockParents( clientId, true ).map( getBlockName ), + ].filter( ( blockName ): blockName is BlockName => blockName !== undefined ); +} + +function unwrapThemeValue( value: unknown ): unknown { + return isRecord( value ) && value.theme ? value.theme : value; +} + +/** Resolve a governed value for an already resolved block-name hierarchy. */ +export function resolveSettingForBlockNames( { + blockNames, + defaultValue, + path, + rules, +}: ResolveForBlockNamesOptions ): unknown { + const currentBlockName = blockNames[ 0 ]; + + if ( ! currentBlockName ) { + return defaultValue; + } + + if ( rules.pathsByBlock.get( currentBlockName )?.has( path ) ) { + return unwrapThemeValue( getNestedSetting( blockNames, path, rules.settings ).value ); + } + + for ( const [ blockPattern, settingPaths ] of rules.pathsByBlock ) { + if ( + blockPattern.includes( '*' ) && + settingPaths.has( path ) && + doesBlockNameMatchBlockWildcard( currentBlockName, blockPattern ) + ) { + const normalizedBlockNames = [ ...blockNames ]; + normalizedBlockNames[ 0 ] = blockPattern; + return unwrapThemeValue( + getNestedSetting( normalizedBlockNames, path, rules.settings ).value + ); + } + } + + return defaultValue; +} + +/** Resolve the legacy-precedence setting for a block editor client ID. */ +export function resolveNestedSetting( { + clientId, + currentBlockName, + defaultValue, + getBlockName, + getBlockParents, + path, + rules, +}: ResolveNestedSettingOptions ): unknown { + return resolveSettingForBlockNames( { + blockNames: getBlockNamePath( clientId, getBlockParents, getBlockName, currentBlockName ), + defaultValue, + path, + rules, + } ); +} diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 0000000..59a7d35 --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1,5 @@ +import apiFetch from '@wordpress/api-fetch'; + +import { initializeRulesViewer } from './rules-viewer'; + +initializeRulesViewer( { request: apiFetch } ); diff --git a/src/settings/rules-viewer.test.ts b/src/settings/rules-viewer.test.ts new file mode 100644 index 0000000..da5ea00 --- /dev/null +++ b/src/settings/rules-viewer.test.ts @@ -0,0 +1,105 @@ +import { initializeRulesViewer } from './rules-viewer'; + +const SETTINGS_MARKUP = ` + + + + + +`; + +const roleSelector = () => document.getElementById( 'user-role-selector' ) as HTMLSelectElement; +const postTypeSelector = () => document.getElementById( 'post-type-selector' ) as HTMLSelectElement; +const viewButton = () => document.getElementById( 'view-rules-button' ) as HTMLButtonElement; +const spinner = () => document.querySelector( '.vip-governance-query-spinner' ) as HTMLElement; +const output = () => document.getElementById( 'json' ) as HTMLElement; + +describe( 'initializeRulesViewer', () => { + beforeEach( () => { + document.body.innerHTML = SETTINGS_MARKUP; + } ); + + it( 'only shows the action when at least one filter is selected', () => { + initializeRulesViewer( { request: jest.fn() } ); + + expect( viewButton().hidden ).toBe( true ); + roleSelector().value = 'editor'; + roleSelector().dispatchEvent( new Event( 'change' ) ); + + expect( viewButton().hidden ).toBe( false ); + } ); + + it( 'resets browser-restored selections during initialization', () => { + roleSelector().value = 'editor'; + postTypeSelector().value = 'page'; + + initializeRulesViewer( { request: jest.fn() } ); + + expect( roleSelector().value ).toBe( '' ); + expect( postTypeSelector().value ).toBe( '' ); + expect( viewButton().hidden ).toBe( true ); + } ); + + it( 'requests and displays rules for both selected filters', async () => { + const request = jest.fn().mockResolvedValue( { allowedBlocks: [ 'core/paragraph' ] } ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + postTypeSelector().value = 'page'; + viewButton().click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( request ).toHaveBeenCalledWith( { + path: '/vip-governance/v1/rules?role=editor&postType=page', + } ); + expect( output().textContent ).toBe( + JSON.stringify( { allowedBlocks: [ 'core/paragraph' ] }, null, 4 ) + ); + } ); + + it( 'displays a useful request error and restores the spinner', async () => { + const request = jest.fn().mockRejectedValue( { + code: 'rest_forbidden', + message: 'Request failed', + } ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + viewButton().click(); + + expect( spinner().hidden ).toBe( false ); + + await Promise.resolve(); + await Promise.resolve(); + + expect( output().textContent ).toBe( 'Request failed' ); + expect( spinner().hidden ).toBe( true ); + } ); + + it( 'uses the localized fallback when a request has no error message', async () => { + const request = jest.fn().mockRejectedValue( {} ); + initializeRulesViewer( { request } ); + + roleSelector().value = 'editor'; + viewButton().click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect( output().textContent ).toBe( 'Unable to load governance rules.' ); + } ); + + it( 'does nothing when the settings markup is not present', () => { + document.body.innerHTML = ''; + + expect( () => initializeRulesViewer( { request: jest.fn() } ) ).not.toThrow(); + } ); +} ); diff --git a/src/settings/rules-viewer.ts b/src/settings/rules-viewer.ts new file mode 100644 index 0000000..6c08831 --- /dev/null +++ b/src/settings/rules-viewer.ts @@ -0,0 +1,111 @@ +const RULES_ENDPOINT = '/vip-governance/v1/rules'; + +type RulesRequest = ( options: { path: string } ) => Promise< unknown >; + +interface RulesViewerOptions { + request: RulesRequest; + root?: Document; +} + +type RequestData = Record< string, string >; + +function getErrorMessage( error: unknown, fallback: string ): string { + if ( error instanceof Error && error.message ) { + return error.message; + } + + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message + ) { + return error.message; + } + + return fallback; +} + +/** Initialize the role and post type rules viewer on the plugin settings page. */ +export function initializeRulesViewer( { root = document, request }: RulesViewerOptions ): void { + const roleSelector = root.getElementById( 'user-role-selector' ); + const postTypeSelector = root.getElementById( 'post-type-selector' ); + const viewButton = root.getElementById( 'view-rules-button' ); + const spinner = root.querySelector( '.vip-governance-query-spinner' ); + const output = root.getElementById( 'json' ); + + if ( + ! ( roleSelector instanceof HTMLSelectElement ) || + ! ( postTypeSelector instanceof HTMLSelectElement ) || + ! ( viewButton instanceof HTMLButtonElement ) || + ! ( spinner instanceof HTMLElement ) || + ! ( output instanceof HTMLElement ) + ) { + return; + } + + const getRequestData = (): RequestData => { + const data: RequestData = {}; + + if ( roleSelector.value ) { + data.role = roleSelector.value; + } + + if ( postTypeSelector.value ) { + data.postType = postTypeSelector.value; + } + + return data; + }; + + const updateControls = () => { + const hasSelection = Object.keys( getRequestData() ).length > 0; + viewButton.hidden = ! hasSelection; + + if ( ! hasSelection ) { + output.hidden = true; + } + }; + + const showOutput = ( value: string ) => { + output.textContent = value; + output.hidden = false; + }; + + const handleRequest = async () => { + const data = getRequestData(); + + if ( Object.keys( data ).length === 0 ) { + return; + } + + spinner.hidden = false; + spinner.classList.add( 'is-active' ); + + try { + const rules = await request( { + path: `${ RULES_ENDPOINT }?${ new URLSearchParams( data ) }`, + } ); + + showOutput( JSON.stringify( rules, null, 4 ) ); + } catch ( error ) { + showOutput( + getErrorMessage( error, output.dataset.errorMessage ?? 'Unable to load rules.' ) + ); + } finally { + spinner.hidden = true; + spinner.classList.remove( 'is-active' ); + } + }; + + roleSelector.addEventListener( 'change', updateControls ); + postTypeSelector.addEventListener( 'change', updateControls ); + viewButton.addEventListener( 'click', () => { + void handleRequest(); + } ); + + roleSelector.value = ''; + postTypeSelector.value = ''; + updateControls(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..20fea41 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export type BlockName = string; +export type ClientId = string; + +export type BlockSettings = Record< string, unknown >; + +export interface GovernanceRules { + allowedBlocks: BlockName[]; + allowedFeatures?: string[]; + blockSettings?: BlockSettings; +} + +export interface GovernanceRuntimeConfig { + error: false | string; + governanceRules: GovernanceRules; + nestedSettings: BlockSettings; + urlSettingsPage: string; +} + +export interface BlockEditorSelectors { + getBlockName: ( clientId: ClientId ) => BlockName | undefined; + getBlockParents: ( clientId: ClientId, ascending?: boolean ) => ClientId[]; +} + +export interface BlockRecord { + clientId: ClientId; + name: BlockName; +} + +export interface BlockEditorInsertionSelectors extends BlockEditorSelectors { + getBlock: ( clientId: ClientId ) => BlockRecord | null; +} + +export interface BlockType { + name: BlockName; +} + +declare global { + const VIP_GOVERNANCE: GovernanceRuntimeConfig; +} diff --git a/src/wordpress-block-editor.d.ts b/src/wordpress-block-editor.d.ts new file mode 100644 index 0000000..8330dcd --- /dev/null +++ b/src/wordpress-block-editor.d.ts @@ -0,0 +1,6 @@ +declare module '@wordpress/block-editor' { + export type BlockEditingMode = 'disabled' | 'contentOnly' | 'default'; + + export const store: never; + export function useBlockEditingMode( mode?: BlockEditingMode ): BlockEditingMode; +} diff --git a/tests/e2e/default-administrator-rule-check.spec.js b/tests/e2e/default-administrator-rule-check.spec.ts similarity index 79% rename from tests/e2e/default-administrator-rule-check.spec.js rename to tests/e2e/default-administrator-rule-check.spec.ts index cf1bf88..f79c9d7 100644 --- a/tests/e2e/default-administrator-rule-check.spec.js +++ b/tests/e2e/default-administrator-rule-check.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from '@wordpress/e2e-test-utils-playwright'; -async function openTextColorPicker( page ) { +import type { Page } from '@playwright/test'; + +interface BlockWithClientId { + clientId: string; + name: string; +} + +async function openTextColorPicker( page: Page ) { // WordPress 6.8 labels this control "Text". Current WordPress versions label // the typography control "Color" and also expose a later background control. const legacyTextControl = page.getByText( 'Text', { exact: true } ); @@ -14,7 +21,7 @@ async function openTextColorPicker( page ) { test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () => { test.beforeEach( async ( { admin } ) => { - await admin.createNewPost( { legacyCanvas: true } ); + await admin.createNewPost( { legacyCanvas: true } as never ); } ); test( 'should confirm that only the administrator and default allowedBlocks are allowed to be inserted', async ( { @@ -41,7 +48,7 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) ); - // WordPress 6.8+ displays disallowed blocks, but rejects their insertion. + // WordPress 6.8 and 6.9 display disallowed blocks, but reject their insertion. await editor.insertBlock( { name: 'core/list' } ); await expect .poll( async () => ( await editor.getBlocks() ).map( ( { name } ) => name ) ) @@ -87,9 +94,9 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) ); - const [ mediaText ] = ( await editor.getBlocks( { full: true } ) ).filter( - ( { name } ) => name === 'core/media-text' - ); + const [ mediaText ] = ( + ( await editor.getBlocks( { full: true } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/media-text' ); await editor.insertBlock( { name: 'core/list' }, { clientId: mediaText.clientId } ); await expect .poll( async () => { @@ -134,9 +141,9 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () // Insert a media-text, and a heading under it as that should be allowed as well. await editor.insertBlock( { name: 'core/media-text' } ); - const [ mediaText ] = ( await editor.getBlocks( { full: true } ) ).filter( - ( { name } ) => name === 'core/media-text' - ); + const [ mediaText ] = ( + ( await editor.getBlocks( { full: true } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/media-text' ); await editor.insertBlock( { name: 'core/heading', @@ -153,10 +160,20 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ) .toContain( 'core/heading' ); const [ nestedHeadingBlock ] = ( - await editor.getBlocks( { clientId: mediaText.clientId, full: true } ) - ).filter( ( { name } ) => name === 'core/heading' ); + ( await editor.getBlocks( { + clientId: mediaText.clientId, + full: true, + } ) ) as unknown as BlockWithClientId[] + ).filter( ( { name } ) => name === 'core/heading' ); await page.evaluate( clientId => { - globalThis.wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId ); + const { wp } = globalThis as unknown as { + wp: { + data: { + dispatch: ( storeName: string ) => { selectBlock: ( id: string ) => void }; + }; + }; + }; + wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId ); }, nestedHeadingBlock.clientId ); // Pick the custom red colour for the heading. @@ -220,11 +237,25 @@ test.describe( 'Role/Post Type - Default, Administrator and Post Rules Flow', () ] ); // Verify if the CSS was actually applied. - const frame = page.frame( 'editor-canvas' ); + const frame = page.frameLocator( '[name="editor-canvas"]' ); const rootHeading = frame.locator( 'text="This is a heading"' ); await expect( rootHeading ).toHaveCSS( 'color', 'rgb(255, 255, 0)' ); const nestedHeading = frame.locator( 'text="This is a heading inside a media-text"' ); await expect( nestedHeading ).toHaveCSS( 'color', 'rgb(255, 0, 0)' ); } ); + + test( 'disables a governed parent without wrapping its children again', async ( { editor } ) => { + await editor.setContent( ` + +
+

Nested governed content

+
+ + ` ); + + const governedWrappers = editor.canvas.locator( 'div[style*="opacity: 0.6"]' ); + await expect( governedWrappers ).toHaveCount( 1 ); + await expect( governedWrappers ).toContainText( 'Nested governed content' ); + } ); } ); diff --git a/tests/e2e/globalSetup.js b/tests/e2e/globalSetup.ts similarity index 89% rename from tests/e2e/globalSetup.js rename to tests/e2e/globalSetup.ts index 27314f7..98a2b10 100644 --- a/tests/e2e/globalSetup.js +++ b/tests/e2e/globalSetup.ts @@ -2,12 +2,14 @@ * External dependencies */ import { request } from '@playwright/test'; +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +import type { FullConfig } from '@playwright/test'; /** * WordPress dependencies */ -import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; -async function globalSetup( config ) { +async function globalSetup( config: FullConfig ) { const { storageState, baseURL } = config.projects[ 0 ].use; const storageStatePath = typeof storageState === 'string' ? storageState : undefined; diff --git a/tests/e2e/settings-page.spec.ts b/tests/e2e/settings-page.spec.ts new file mode 100644 index 0000000..1c5177d --- /dev/null +++ b/tests/e2e/settings-page.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Governance settings page', () => { + test( 'loads combined rules for the selected role and post type', async ( { page } ) => { + await page.goto( '/wp-admin/admin.php?page=vip-block-governance' ); + + const roleSelector = page.getByLabel( 'User role' ); + const postTypeSelector = page.getByLabel( 'Post type' ); + const viewButton = page.getByRole( 'button', { name: 'View Rules' } ); + const output = page.locator( '#json' ); + + await expect( viewButton ).toBeHidden(); + await roleSelector.selectOption( 'administrator' ); + await postTypeSelector.selectOption( 'post' ); + await expect( viewButton ).toBeVisible(); + + await viewButton.click(); + + await expect( output ).toBeVisible(); + await expect( output ).toContainText( '"allowedBlocks"' ); + await expect( roleSelector ).toBeEnabled(); + await expect( postTypeSelector ).toBeEnabled(); + await expect( viewButton ).toBeEnabled(); + } ); +} ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9cb9af2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": [ "DOM", "ES2022" ], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ "jest", "node", "react" ] + }, + "include": [ "src/**/*.ts", "src/**/*.tsx", "tests/e2e/**/*.ts", "playwright.config.ts" ] +} diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 265a388..d0a20a6 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'automattic/vip-governance', 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => 'd1e30f0ab91dbffdae04bc2be72ff39e86bea816', + 'reference' => 'd848df6c7675474ecfe400416c9b7fece098900c', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -13,7 +13,7 @@ 'automattic/vip-governance' => array( 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => 'd1e30f0ab91dbffdae04bc2be72ff39e86bea816', + 'reference' => 'd848df6c7675474ecfe400416c9b7fece098900c', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/webpack.config.js b/webpack.config.js index a7c6e00..490e084 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,9 @@ const defaultScriptsConfig = require( '@wordpress/scripts/config/webpack.config' ); -// This is the default webpack config used by `@wordpress/scripts` -// It's been exported so we can extend it in our custom webpack config -// to add our own customizations like HMR, etc. - -module.exports = [ defaultScriptsConfig ]; +module.exports = { + ...defaultScriptsConfig, + entry: { + index: './src/index.ts', + settings: './src/settings/index.ts', + }, +};