From 29a0d182aceb264edee99c82cb4e163085ba1f49 Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Sun, 19 Apr 2026 17:27:00 -0500 Subject: [PATCH] chore: freeze V1 block allow-list from block-library audit Audits every block in @wordpress/block-library v9.44 against the empty-state @wordpress/core-data shim and freezes the V1 enabled_blocks / disabled_blocks config based on that audit. Adds a getEnabledBlockNames() helper on the VisualEditor class that applies the allow-list + deny-list filters and a snapshot test that guards the frozen default set. - docs/block-library-audit.md: per-block classification (green / empty-state / crash) - config/visual-editor.php: frozen enabled_blocks (27) and disabled_blocks (17) - src/VisualEditor.php: getEnabledBlockNames() with allow-list + deny-list + dedup - tests/Unit/VisualEditor/EnabledBlockNamesTest.php: snapshot + 4 behavior tests - README.md: disabled-by-default block summary linking to the audit doc Closes #315 --- README.md | 16 ++ config/visual-editor.php | 57 ++++- docs/block-library-audit.md | 210 ++++++++++++++++++ src/VisualEditor.php | 81 +++++++ .../VisualEditor/EnabledBlockNamesTest.php | 104 +++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 docs/block-library-audit.md create mode 100644 tests/Unit/VisualEditor/EnabledBlockNamesTest.php diff --git a/README.md b/README.md index 6f27bf22c..c5d05d700 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,22 @@ The V1 editor adopts the upstream `@wordpress/*` packages. Some of those package Both shims will be replaced by `artisanpack-ui/cms-framework` (the real Laravel-backed `core-data` store and media bridge). Every selector or filter we implement here is one we have to re-verify against Gutenberg upgrades — expand the surface only when an observed crash demands it. +## Block defaults + +V1 ships with a frozen allow-list of blocks from `@wordpress/block-library`. The defaults in `config/visual-editor.php` expose only blocks that render correctly against the empty-state `@wordpress/core-data` shim — content, media, layout, and simple widget blocks that do not need a WordPress backend to work. + +**Disabled by default.** Every block in the following categories is listed in `disabled_blocks` because it relies on data the shim does not provide: + +- **Site / theme** — `core/navigation`, `core/site-logo`, `core/site-title`, `core/site-tagline`, `core/template-part` +- **Query loop** — `core/query`, `core/query-loop` +- **Post context** — `core/post-content`, `core/post-title`, `core/post-excerpt`, `core/post-date`, `core/post-author`, `core/post-featured-image` +- **Taxonomy widgets** — `core/categories`, `core/tag-cloud`, `core/archives` +- **Comments feeds** — `core/latest-comments` + +These blocks will be re-enabled once `artisanpack-ui/cms-framework` replaces the shim with a real Laravel-backed `core-data` store. The full per-block classification — including blocks that render empty but do not crash — lives in [`docs/block-library-audit.md`](docs/block-library-audit.md). Because the defaults populate `enabled_blocks` as an explicit allow-list, new blocks introduced by a future `@wordpress/block-library` upgrade are implicitly disabled until the audit is revisited. + +Override the defaults by publishing the config to `config/artisanpack/visual-editor.php` and editing the `enabled_blocks` / `disabled_blocks` arrays. The deny-list always wins over the allow-list. + ## i18n Editor strings use `@wordpress/i18n` with the `artisanpack-visual-editor` text domain. The domain is initialized via `bootI18n()` in `resources/js/visual-editor/vendor/i18n.ts`. diff --git a/config/visual-editor.php b/config/visual-editor.php index f34bc811d..7e62968c4 100644 --- a/config/visual-editor.php +++ b/config/visual-editor.php @@ -47,11 +47,64 @@ | always-applied deny-list. The deny-list wins when both are set. Use | fully-qualified block names (e.g. `core/paragraph`, `core/query`). | + | The frozen V1 defaults follow the M5 block-library audit + | (see docs/block-library-audit.md). Only blocks that render correctly + | against the empty-state @wordpress/core-data shim are enabled; every + | block that needs a real Laravel-backed core store — navigation, query, + | post-*, site-*, template-part, taxonomy widgets — is disabled until + | the artisanpack-ui/cms-framework package replaces the shim. + | */ - 'enabled_blocks' => [], + 'enabled_blocks' => [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/quote', + 'core/code', + 'core/preformatted', + 'core/pullquote', + 'core/verse', + 'core/table', + 'core/image', + 'core/gallery', + 'core/video', + 'core/audio', + 'core/file', + 'core/embed', + 'core/cover', + 'core/media-text', + 'core/columns', + 'core/group', + 'core/row', + 'core/stack', + 'core/buttons', + 'core/separator', + 'core/spacer', + 'core/details', + 'core/search', + 'core/latest-posts', + ], - 'disabled_blocks' => [], + 'disabled_blocks' => [ + 'core/navigation', + 'core/query', + 'core/query-loop', + 'core/post-content', + 'core/post-title', + 'core/post-excerpt', + 'core/post-date', + 'core/post-author', + 'core/post-featured-image', + 'core/site-logo', + 'core/site-title', + 'core/site-tagline', + 'core/template-part', + 'core/latest-comments', + 'core/archives', + 'core/categories', + 'core/tag-cloud', + ], /* |-------------------------------------------------------------------------- diff --git a/docs/block-library-audit.md b/docs/block-library-audit.md new file mode 100644 index 000000000..fdaf2b3ce --- /dev/null +++ b/docs/block-library-audit.md @@ -0,0 +1,210 @@ +# Block Library Audit + +Classification of every block registered by `@wordpress/block-library` (v9.44.0) +against the current editor's empty-state `@wordpress/core-data` shim +(`resources/js/visual-editor/vendor/core-data-shim.ts`). + +Each block falls into one of three buckets: + +- **green** — inserts, renders, and edits without a backing WordPress data + store. Safe to expose in the default `enabled_blocks` list. +- **empty-state** — inserts and renders without crashing, but every data + selector returns `null` or `[]`, so the block shows an empty shell. Shipped + as **disabled-by-default** until the `artisanpack-ui/cms-framework` package + provides a real Laravel-backed `core` store. +- **crash** — throws on insert or first render because the shim does not + implement the selector or action the block expects. Permanently in + `disabled_blocks` until the offending surface is implemented. + +The frozen config defaults follow this audit exactly — see +`config/visual-editor.php`. + +## Green — enabled by default + +These blocks render correctly against the shim and are exposed to the +inserter out of the box. + +### Content + +| Block | Notes | +| --- | --- | +| `core/paragraph` | Baseline text block. | +| `core/heading` | Renders h1–h6. | +| `core/list` | Container for list items. | +| `core/quote` | Blockquote wrapper. | +| `core/code` | Preformatted code. | +| `core/preformatted` | Plain preformatted text. | +| `core/pullquote` | Emphasized quote variant. | +| `core/verse` | Monospace poetry block. | +| `core/table` | Static table — no data source required. | + +### Media + +| Block | Notes | +| --- | --- | +| `core/image` | M4 media bridge wires `MediaUpload` via `@wordpress/hooks`. | +| `core/gallery` | Uses the same media bridge. | +| `core/video` | URL / upload. | +| `core/audio` | URL / upload. | +| `core/file` | URL / upload. | +| `core/embed` | oEmbed providers resolved client-side. | +| `core/cover` | Background image + nested blocks. | +| `core/media-text` | Side-by-side media + text. | + +### Layout + +| Block | Notes | +| --- | --- | +| `core/columns` | Multi-column container. | +| `core/group` | Generic container. | +| `core/row` | Variation of `core/group` (listed for clarity — no separate registration). | +| `core/stack` | Variation of `core/group` (listed for clarity — no separate registration). | +| `core/buttons` | Button row container. | +| `core/separator` | Horizontal rule. | +| `core/spacer` | Vertical whitespace. | +| `core/details` | Disclosure element. | + +### Simple widgets + +| Block | Notes | +| --- | --- | +| `core/search` | Static form — posts target via host app. | +| `core/latest-posts` | **Stubbed.** Renders an empty list against the core-data shim; flagged here so the UX expectation is explicit — the block is still usable as a placeholder authors can drop in before wiring a real data source. | + +## Empty-state — disabled by default + +These blocks do not crash, but the shim returns empty data for the selectors +they depend on, so the block renders a placeholder, empty list, or blank +title. Better UX is no block at all until `cms-framework` lands. + +| Block | Required data | +| --- | --- | +| `core/latest-comments` | Comments feed. | +| `core/archives` | Archive list query. | +| `core/categories` | Term hierarchy. | +| `core/tag-cloud` | Term weights. | +| `core/rss` | External feed fetch — not blocked by shim but disabled for consistency with the other feed widgets. | +| `core/calendar` | Post date aggregation. | +| `core/page-list` | Hierarchical page query. | +| `core/table-of-contents` | Heading scan against rendered entity. | + +## Crash or backend-required — disabled by default + +These blocks call selectors the shim returns `null` for but which the block's +own edit component assumes are non-null, or they rely on a resolver the shim +does not implement. They are permanently in the deny-list until a real +`core` store replaces the shim. + +### Site / theme blocks + +- `core/navigation` +- `core/navigation-link` +- `core/navigation-submenu` +- `core/navigation-overlay-close` +- `core/home-link` +- `core/site-logo` +- `core/site-title` +- `core/site-tagline` +- `core/template-part` +- `core/breadcrumbs` +- `core/page-list-item` +- `core/loginout` + +### Query loop + +- `core/query` +- `core/query-loop` (listed per spec; no separate upstream block — the block + name is `core/query` and the loop is driven by `core/post-template`). +- `core/query-pagination` +- `core/query-pagination-next` +- `core/query-pagination-numbers` +- `core/query-pagination-previous` +- `core/query-no-results` +- `core/query-title` +- `core/query-total` +- `core/post-template` +- `core/read-more` + +### Post context + +- `core/post-title` +- `core/post-content` +- `core/post-excerpt` +- `core/post-date` +- `core/post-author` +- `core/post-author-name` +- `core/post-author-biography` +- `core/post-featured-image` +- `core/post-navigation-link` +- `core/post-terms` +- `core/post-time-to-read` +- `core/post-comments-form` +- `core/post-comments-count` +- `core/post-comments-link` +- `core/post-comment` + +### Comments + +- `core/comments` +- `core/comment-author-avatar` +- `core/comment-author-name` +- `core/comment-content` +- `core/comment-date` +- `core/comment-edit-link` +- `core/comment-reply-link` +- `core/comment-template` +- `core/comments-title` +- `core/comments-pagination` +- `core/comments-pagination-next` +- `core/comments-pagination-numbers` +- `core/comments-pagination-previous` +- `core/avatar` + +### Taxonomy + +- `core/term-count` +- `core/term-description` +- `core/term-name` +- `core/terms-query` +- `core/term-template` + +### Internal / developer-only + +- `core/missing` (fallback renderer for unknown blocks; internal) +- `core/html` (raw HTML — security review before enabling) +- `core/shortcode` (WordPress-only) +- `core/freeform` (TinyMCE classic block) +- `core/pattern` (pattern insertion — needs pattern registry) +- `core/block` (reusable block — needs entity store) +- `core/footnotes` (needs meta storage) +- `core/more` (classic read-more — post-context only) +- `core/nextpage` (pagination break — post-context only) +- `core/social-links` +- `core/social-link` +- `core/text-columns` (deprecated upstream) +- `core/icon` +- `core/math` + +## Experimental / opt-in blocks + +These blocks only register when a window flag is set +(`window.__experimentalEnableFormBlocks`, +`window.__experimentalEnableBlockExperiments`). The editor does not set any +of these flags, so they are effectively disabled regardless of config. + +- `core/form`, `core/form-input`, `core/form-submit-button`, `core/form-submission-notification` +- `core/tab`, `core/tabs`, `core/tabs-menu`, `core/tabs-menu-item`, `core/tab-panel` +- `core/playlist`, `core/playlist-track` +- `core/accordion`, `core/accordion-item`, `core/accordion-heading`, `core/accordion-panel` + +## How the allow-list + deny-list resolves + +`enabled_blocks` is an allow-list: when non-empty, only the listed names are +exposed to the inserter. `disabled_blocks` is an always-applied deny-list; +even if a block appears on the allow-list, the deny-list removes it. + +The frozen defaults ship with `enabled_blocks` populated — so new blocks +introduced by a future `@wordpress/block-library` upgrade are **implicitly +disabled** until explicitly added to the allow-list. That is intentional: +users should never be surprised by a new block that the shim hasn't been +validated against. diff --git a/src/VisualEditor.php b/src/VisualEditor.php index c207a133c..53c236bb5 100644 --- a/src/VisualEditor.php +++ b/src/VisualEditor.php @@ -91,4 +91,85 @@ public function getRegistry(): BlockTypeRegistry { return $this->registry; } + + /** + * Returns the fully-qualified names of blocks that should be exposed to + * the editor after the allow-list + deny-list filters run. + * + * Resolution order: + * 1. Start with the configured `enabled_blocks` allow-list. When + * empty, fall back to every block currently in the registry — the + * allow-list is only enforced when the host app has opted in. + * 2. Remove anything in the `disabled_blocks` deny-list. + * 3. De-duplicate and preserve authoring order. + * + * The return value is deterministic (no registry lookups, no locale + * sorting) so it can drive a snapshot test. + * + * @since 1.0.0 + * + * @return array + */ + public function getEnabledBlockNames(): array + { + $enabled = $this->stringListFromConfig( 'artisanpack.visual-editor.enabled_blocks' ); + $disabled = $this->stringListFromConfig( 'artisanpack.visual-editor.disabled_blocks' ); + + $candidates = [] === $enabled + ? array_column( $this->registry->all(), 'name' ) + : $enabled; + + $denyIndex = array_flip( $disabled ); + $seen = []; + $result = []; + + foreach ( $candidates as $name ) { + if ( ! is_string( $name ) ) { + continue; + } + + $normalized = trim( $name ); + + if ( '' === $normalized || isset( $denyIndex[ $normalized ] ) || isset( $seen[ $normalized ] ) ) { + continue; + } + + $seen[ $normalized ] = true; + $result[] = $normalized; + } + + return $result; + } + + /** + * Pulls a config key, coerces it to a list of trimmed non-empty strings. + * + * @since 1.0.0 + * + * @return array + */ + protected function stringListFromConfig( string $key ): array + { + $raw = config( $key, [] ); + + if ( ! is_array( $raw ) ) { + return []; + } + + $out = []; + + foreach ( $raw as $value ) { + if ( ! is_string( $value ) ) { + continue; + } + + $trimmed = trim( $value ); + + if ( '' !== $trimmed ) { + $out[] = $trimmed; + } + } + + return $out; + } } diff --git a/tests/Unit/VisualEditor/EnabledBlockNamesTest.php b/tests/Unit/VisualEditor/EnabledBlockNamesTest.php new file mode 100644 index 000000000..ac2a1d891 --- /dev/null +++ b/tests/Unit/VisualEditor/EnabledBlockNamesTest.php @@ -0,0 +1,104 @@ +getEnabledBlockNames() )->toBe( [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/quote', + 'core/code', + 'core/preformatted', + 'core/pullquote', + 'core/verse', + 'core/table', + 'core/image', + 'core/gallery', + 'core/video', + 'core/audio', + 'core/file', + 'core/embed', + 'core/cover', + 'core/media-text', + 'core/columns', + 'core/group', + 'core/row', + 'core/stack', + 'core/buttons', + 'core/separator', + 'core/spacer', + 'core/details', + 'core/search', + 'core/latest-posts', + ] ); +} ); + +it( 'removes deny-listed names even when they appear on the allow-list', function () { + config( [ + 'artisanpack.visual-editor.enabled_blocks' => ['core/paragraph', 'core/query', 'core/heading'], + 'artisanpack.visual-editor.disabled_blocks' => ['core/query'], + ] ); + + $editor = app( VisualEditor::class ); + + expect( $editor->getEnabledBlockNames() )->toBe( ['core/paragraph', 'core/heading'] ); +} ); + +it( 'falls back to the full registry when the allow-list is empty', function () { + config( [ + 'artisanpack.visual-editor.enabled_blocks' => [], + 'artisanpack.visual-editor.disabled_blocks' => [], + ] ); + + $editor = app( VisualEditor::class ); + + $editor->registerBlockType( 'artisanpack/custom-a', ['title' => 'A'] ); + $editor->registerBlockType( 'artisanpack/custom-b', ['title' => 'B'] ); + + $names = $editor->getEnabledBlockNames(); + + expect( $names )->toContain( 'artisanpack/custom-a' ) + ->and( $names )->toContain( 'artisanpack/custom-b' ); +} ); + +it( 'de-duplicates repeated block names while preserving order', function () { + config( [ + 'artisanpack.visual-editor.enabled_blocks' => [ + 'core/paragraph', + 'core/heading', + 'core/paragraph', + ], + 'artisanpack.visual-editor.disabled_blocks' => [], + ] ); + + $editor = app( VisualEditor::class ); + + expect( $editor->getEnabledBlockNames() )->toBe( ['core/paragraph', 'core/heading'] ); +} ); + +it( 'ignores non-string entries in the config arrays', function () { + config( [ + 'artisanpack.visual-editor.enabled_blocks' => [ + 'core/paragraph', + 42, + null, + ' ', + 'core/heading', + ], + 'artisanpack.visual-editor.disabled_blocks' => [false, 'core/paragraph'], + ] ); + + $editor = app( VisualEditor::class ); + + expect( $editor->getEnabledBlockNames() )->toBe( ['core/heading'] ); +} );