diff --git a/docs/plans/font-library.md b/docs/plans/font-library.md new file mode 100644 index 000000000..0c525f09f --- /dev/null +++ b/docs/plans/font-library.md @@ -0,0 +1,189 @@ +# Feature: Font Library & Font Management + +## Problem Statement + +The visual editor currently has no first-class way to manage web fonts. Users have to hand-edit theme CSS or drop external `` tags to load Google Fonts, which bypasses the block typography pipeline, breaks GDPR compliance (fonts loaded from Google CDN), and can't be reused across themes. WordPress's Site Editor Font Library is the reference UX; we want the same first-class treatment inside ArtisanPack UI, but built on our existing Registry + hooks patterns and extensible to any provider. + +## Target User + +- **Site admins / designers** managing typography in the Site Editor who want to browse, install, and use web fonts without touching code. +- **Package developers** who want to register additional font sources (Adobe Fonts, Fontshare, internal libraries) without patching this package. + +## User Stories + +- As a site admin, I want to open a Font Library modal from the Site Editor and browse Google Fonts and Bunny Fonts so I can pick fonts by preview. +- As a site admin, I want to install a font by picking specific weights/styles so I don't download files I won't use. +- As a site admin, I want installed fonts to appear in the typography panel font-family dropdown for every block. +- As a site admin, I want fonts to be self-hosted automatically so I stay GDPR-compliant and my site works offline. +- As a site admin, I want to upload my own `.woff2`/`.ttf`/`.otf` files for licensed or custom fonts. +- As a theme author, I want the fonts my theme depends on to ship as a "font bundle" so the theme is portable. +- As a package developer, I want to implement a `FontProvider` interface and register it via `FontSourceRegistry` so my source shows up alongside Google/Bunny with no core changes. +- As a site owner, I want a dedicated `manage_fonts` capability so I can grant font management independently of full Site Editor access. + +## Scope + +### In scope (v1) + +- Global site-wide **Font Library** (installed fonts) with theme-scoped **font bundles** that reference library entries. +- `FontProvider` interface + `FontSourceRegistry` extensibility layer (mirrors `BlockTypeRegistry` conventions), seeded via `ap.visual-editor.register-font-sources` filter hook. +- First-party providers: **Google Fonts** and **Bunny Fonts**. +- **Self-hosted storage**: on install, provider fetches `.woff2` files to the configured storage disk. No CDN linking in v1. +- **Custom font upload** (`.woff2`, `.woff`, `.ttf`, `.otf`) — treated as a synthetic `custom` provider. +- **Per-font weight/style selection** at install time (400, 700, italic, etc.). +- **Variable font support** — parse axes (`wght`, `slnt`, `wdth`, etc.) from font metadata, expose axis ranges in the picker. +- **Font Library modal** — browse/search catalog, live preview with editable sample text, install with weight selection, **bulk install/uninstall**. +- **Typography panel font picker** — populated from installed fonts, applied via existing block style pipeline. +- **Generated `fonts.css`** — rebuilt on install/uninstall/upload, contains all `@font-face` rules; enqueued in both editor iframe and public site. Exposes CSS custom properties + preset slugs consumable by the block style pipeline (theme.json-style presets). +- **`manage_fonts` capability** — new capability + policy, grantable independently of the Site Editor cap. +- **GDPR notice** — inline notice in the library modal when installing from remote providers, confirming self-hosting is on. + +### Out of scope (v1 — deferred) + +- **Font subsetting** by character set (Latin, Cyrillic, etc.) — install downloads the full subset the provider ships. +- CDN / remote linking mode — always self-host in v1. +- Font optimization (WOFF2 → smaller subsets, unicode-range trimming beyond what providers ship). +- Font pairing recommendations / AI suggestions. +- Import/export font bundles as `.zip` (theme bundles reference library entries only). +- Icon fonts (out of scope — handled by `artisanpack-ui/icons`). + +## Behavior + +### Happy path + +1. Admin opens Site Editor → clicks **Styles → Typography → Manage Fonts**. Font Library modal opens. +2. Modal shows tabs: **Installed**, **Google Fonts**, **Bunny Fonts**, **Upload**, plus any provider registered via the registry. +3. Admin searches "Inter" in the Google tab. Provider returns catalog results with a live preview rendered in the browser using the provider's preview stylesheet (loaded only for the modal session). +4. Admin clicks **Add** on Inter → weight/style modal opens showing available weights (100–900 + italic variants). Admin selects 400, 500, 700 + 400-italic. +5. Admin clicks **Install**. Backend job (or sync request) fetches `.woff2` files from Google to the storage disk, records a `Font` row + `FontFace` rows per weight/style, regenerates `fonts.css`. +6. Modal closes. The typography panel font-family dropdown now includes "Inter" and every block's font-family control can select it. The editor iframe reloads `fonts.css` and previews accurately. +7. On the public site, `fonts.css` is enqueued once in ``; blocks referencing Inter render with the correct `@font-face` rules already present. + +### Font bundles (theme-scoped) + +- A theme declares its **font bundle** in its theme manifest (list of `{provider, family, faces[]}`). +- On theme activation, missing library entries are installed automatically (with admin confirmation for provider fetches). +- Uninstalling a theme leaves library entries intact (they're global). + +### Edge cases + +- **Provider network failure during install**: rollback the DB row, remove partial files, surface a user-visible error with retry. +- **Font already installed at a different weight set**: adding new weights merges into the existing `Font`; removing weights that a block references warns before delete. +- **Uploaded file with invalid format**: reject at validation with a clear message; supported list documented. +- **`manage_fonts` capability missing on the current user**: modal opens read-only (browse allowed, install/upload/delete disabled) with an explanation. +- **Variable font uploaded without axis metadata**: fall back to treating it as a static font at its default weight. +- **Provider returns a remote-only license (no downloadable file)**: surface a "not self-hostable" message; block installation in v1. +- **Bulk uninstall of a font a block references**: warn with the list of affected blocks/pages; require confirmation. +- **Regenerating `fonts.css` fails mid-write**: keep the previous file in place; log and surface an error. + +## Acceptance Criteria + +- [ ] `FontProvider` interface published in `src/Fonts/Contracts/` with methods for catalog listing, search, family detail, and file fetching. +- [ ] `FontSourceRegistry` registers providers and is seeded via `ap.visual-editor.register-font-sources` filter. +- [ ] Google Fonts and Bunny Fonts providers register themselves in the package service provider. +- [ ] Installing a font from any provider downloads files to the configured disk and creates DB rows. +- [ ] A single generated `fonts.css` file is rebuilt after every install/uninstall/upload and is enqueued in both the editor iframe and the public site. +- [ ] Installed fonts appear in every block's typography font-family control via the existing preset pipeline. +- [ ] Bulk install/uninstall works from the modal. +- [ ] Custom fonts can be uploaded as `.woff2`, `.woff`, `.ttf`, `.otf`. +- [ ] Variable font axes are parsed and exposed to the picker. +- [ ] Live preview with editable sample text works in the modal. +- [ ] `manage_fonts` capability gates all mutating actions; missing capability → read-only modal. +- [ ] GDPR notice is visible in the modal for any remote provider. +- [ ] Theme font bundles install missing library entries on theme activation. +- [ ] All new user-facing strings are wrapped with `__()` / `trans_choice()`. +- [ ] Tests cover: provider registration, install/uninstall lifecycle, `fonts.css` regeneration, capability gating, variable font parsing, bulk operations. + +## Implementation Notes + +### New directory: `src/Fonts/` + +Mirrors existing subsystem shape (`src/SiteEditor/`, `src/Registries/`, `src/MediaBridge/`): + +- `src/Fonts/Contracts/FontProvider.php` — interface. +- `src/Fonts/Registries/FontSourceRegistry.php` — provider registry (follows `BlockTypeRegistry` conventions). +- `src/Fonts/Providers/GoogleFontsProvider.php` +- `src/Fonts/Providers/BunnyFontsProvider.php` +- `src/Fonts/Providers/CustomUploadProvider.php` +- `src/Fonts/Services/FontInstaller.php` — orchestrates provider fetch → storage → DB. +- `src/Fonts/Services/FontFileWriter.php` — writes to configured disk. +- `src/Fonts/Services/FontsCssGenerator.php` — rebuilds `fonts.css` bundle. +- `src/Fonts/Services/VariableFontMetadataParser.php` — reads axis metadata from font files. +- `src/Fonts/Services/ThemeFontBundleResolver.php` — applies theme bundles on activation. +- `src/Fonts/Http/Controllers/FontLibraryController.php` — REST endpoints for the modal. +- `src/Fonts/Http/Requests/InstallFontRequest.php`, `UploadFontRequest.php`. +- `src/Fonts/Models/Font.php`, `FontFace.php`, `ThemeFontBundle.php`. +- `src/Fonts/Policies/FontPolicy.php`. +- `src/Fonts/Exceptions/{FontProviderException,FontFileWriteException,VariableFontMetadataException}.php`. + +### Database + +New migrations under `database/migrations/`: + +- `fonts` — `id, provider, family, slug, is_variable, license, source_url, installed_at`. Unique `(provider, slug)`. +- `font_faces` — `id, font_id, weight, style, format, disk, path, file_size, axes (json, nullable)`. +- `theme_font_bundles` — `id, theme_slug, font_id, faces (json)`. + +### JS / editor UI (`resources/js/visual-editor/fonts/`) + +- `FontLibraryModal.tsx` — tabbed modal, catalog browsing, previews, bulk actions. +- `FontFamilyPicker.tsx` — dropdown wired into the block typography panel. +- `FontPreview.tsx` — editable sample text component. +- `providers/` — thin JS adapters mirroring the PHP providers for browse/preview (fetch from our REST endpoints, not third-party APIs directly). +- Store slice for installed fonts + selected preview state. + +### Routes / API + +Add to `routes/` (follow existing REST pattern): + +- `GET /visual-editor/fonts` — list installed fonts. +- `GET /visual-editor/fonts/sources` — list registered providers. +- `GET /visual-editor/fonts/sources/{provider}/catalog?q=…` — browse/search a provider. +- `POST /visual-editor/fonts` — install (body: `{provider, family, faces[]}`). +- `POST /visual-editor/fonts/upload` — upload custom font file. +- `POST /visual-editor/fonts/bulk-uninstall` — bulk delete. +- `DELETE /visual-editor/fonts/{font}` — uninstall single font. + +All mutating routes gated by `FontPolicy` / `manage_fonts` capability. + +### Config + +Add to `config/visual-editor.php`: + +```php +'fonts' => [ + 'disk' => 'public', + 'path' => 'visual-editor/fonts', + 'css_path' => 'visual-editor/fonts/fonts.css', + 'providers' => [ + 'google' => ['enabled' => true, 'api_key' => env('VE_GOOGLE_FONTS_API_KEY')], + 'bunny' => ['enabled' => true], + ], + 'capability' => 'manage_fonts', + 'upload' => [ + 'max_file_size' => 5120, // KB + 'allowed_mime_types' => ['font/woff2', 'font/woff', 'font/ttf', 'font/otf'], + ], +], +``` + +### Dependencies + +- No new PHP packages required for Google/Bunny (both expose REST/HTTPS endpoints usable with Laravel's HTTP client). +- Variable font metadata parsing: evaluate a lightweight OTF/TTF table reader; if none fits our style guidelines, ship a minimal reader in `VariableFontMetadataParser` handling just `fvar` (axes) + `name` table entries needed. +- JS: reuse existing `@artisanpack-ui/react` primitives where possible; note memory rule — avoid `Popover` / `Dropdown` from that package (they freeze the editor); use inline patterns. + +### GDPR + +Font Library modal shows a persistent notice explaining that any font installed from a remote provider is fetched once to local storage and then served from this site — no runtime requests to Google/Bunny CDN. If a provider is later added that requires runtime CDN linking, `FontProvider::isSelfHostable()` returns `false` and the installer refuses with a clear message. + +### Documentation + +- New page: `docs/fonts.md` — user-facing guide. +- New page: `docs/font-providers.md` — developer guide for `FontProvider` + `FontSourceRegistry`. +- Update `docs/site-editor.md` to reference the Font Library entry point. +- Update `docs/Hooks-and-Events.md` with the new filter hook. + +## Open Questions + +- Google Fonts REST API requires an API key. Confirm whether we ship a shared demo key (rate-limited) or require the site owner to provide one — currently planned as env-driven (`VE_GOOGLE_FONTS_API_KEY`). +- Whether `fonts.css` regeneration should run in a queued job by default (large installs) or synchronously (simpler UX for small installs). Current plan: sync, with a config toggle for async. diff --git a/packages/visual-editor-renderer-blade/src/BlockRenderer.php b/packages/visual-editor-renderer-blade/src/BlockRenderer.php index e7f6f862b..88ecac447 100644 --- a/packages/visual-editor-renderer-blade/src/BlockRenderer.php +++ b/packages/visual-editor-renderer-blade/src/BlockRenderer.php @@ -133,11 +133,43 @@ public function renderBlock( array $block ): string $innerBlocks = $this->normalizeInnerBlocks( $block['innerBlocks'] ?? [] ); $innerBlocksHtml = $this->renderInner( $innerBlocks ); - if ( $this->dynamicBlocks->has( $name ) ) { - return $this->renderDynamic( $name, $attributes, $innerBlocksHtml, $innerBlocks, $renderIndex ); + $html = $this->dynamicBlocks->has( $name ) + ? $this->renderDynamic( $name, $attributes, $innerBlocksHtml, $innerBlocks, $renderIndex ) + : $this->renderStatic( $name, $attributes, $innerBlocksHtml, $innerBlocks, $renderIndex ); + + // `ap.visual-editor.rendered-block` — last-mile hook for packages + // that need to post-process a rendered block. Runs on every block + // (static or dynamic) so cross-cutting effects (frosted glass, + // motion wrappers, contrast overlays, etc.) can decorate output + // without each host having to modify the renderer. Callbacks + // receive the final HTML, the block name, and the normalized + // attributes; they must return an HTML string. + // + // RECURSION: `renderInner` walks `innerBlocks` through this same + // method, so the filter fires once per block AT EVERY LEVEL of + // the tree — a callback that wraps `$html` on a container block + // (e.g. `core/group`) will ALSO wrap every descendant block + // unless it gates on `$name` / `$attributes`. Decorators that + // mean "wrap the outer container only" should branch on the + // block name before mutating the HTML. + // + // ATTRIBUTES SHAPE: `$attributes` is the post-normalization + // array, which for site-meta and loginout blocks already carries + // the internal `_resolved*` keys stamped by {@see stampSiteMeta} + // / {@see stampLoginout} (`_resolvedSiteTitle`, + // `_resolvedIsUserLoggedIn`, `_resolvedLoginFormHtml`, etc.). + // Those keys are a package-internal contract and may change + // without notice — callbacks should treat them as read-only + // side-channel data, not stable public attributes. + if ( function_exists( 'applyFilters' ) ) { + $filtered = applyFilters( 'ap.visual-editor.rendered-block', $html, $name, $attributes ); + + if ( is_string( $filtered ) ) { + $html = $filtered; + } } - return $this->renderStatic( $name, $attributes, $innerBlocksHtml, $innerBlocks, $renderIndex ); + return $html; } /** diff --git a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php index 85424a9e9..d4798e401 100644 --- a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php +++ b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php @@ -1229,6 +1229,156 @@ public function render( array $attrs ) expect( $html )->toBe( '' ); } ); +describe( 'ap.visual-editor.rendered-block filter', function (): void { + beforeEach( function (): void { + if ( function_exists( 'removeAllFilters' ) ) { + removeAllFilters( 'ap.visual-editor.rendered-block' ); + } + } ); + + it( 'lets a callback wrap the rendered HTML of a static (partial) block', function (): void { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + addFilter( 'ap.visual-editor.rendered-block', function ( string $html, string $name ): string { + if ( 'core/paragraph' !== $name ) { + return $html; + } + + return sprintf( '
%s
', $html ); + } ); + + $html = makeRenderer()->render( [ makeBlock( 'core/paragraph', [ 'content' => 'Hi' ] ) ] ); + + expect( $html )->toContain( '
' ) + ->toContain( '

Hi

' ) + ->toContain( '
' ); + } ); + + it( 'lets a callback wrap the rendered HTML of a dynamic block', function (): void { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + $registry = app( DynamicBlockRegistry::class ); + + $registry->register( new class extends DynamicBlock { + public function name(): string + { + return 'acme/filter-target'; + } + + public function render( array $attrs ) + { + return '
body
'; + } + } ); + + addFilter( 'ap.visual-editor.rendered-block', function ( string $html, string $name ): string { + if ( 'acme/filter-target' !== $name ) { + return $html; + } + + return sprintf( '
%s
', $html ); + } ); + + $html = makeRenderer()->render( [ makeBlock( 'acme/filter-target' ) ] ); + + expect( $html )->toContain( '
' ) + ->toContain( '
body
' ) + ->toContain( '
' ); + } ); + + it( 'fires once per block at every level of the tree (documented recursion behavior)', function (): void { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + $names = []; + + addFilter( 'ap.visual-editor.rendered-block', function ( string $html, string $name ) use ( &$names ): string { + $names[] = $name; + + return $html; + } ); + + $tree = [ + makeBlock( 'core/group', [], [ + makeBlock( 'core/paragraph', [ 'content' => 'A' ], [], 'p1' ), + makeBlock( 'core/paragraph', [ 'content' => 'B' ], [], 'p2' ), + ] ), + ]; + + makeRenderer()->render( $tree ); + + // Children fire before the parent because the recursion resolves + // innerBlocks (and thus their filter calls) inside `renderInner` + // before the outer `renderBlock` calls `applyFilters`. + expect( $names )->toBe( [ 'core/paragraph', 'core/paragraph', 'core/group' ] ); + } ); + + it( 'discards a non-string return so a misbehaving callback cannot corrupt the output', function (): void { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + addFilter( 'ap.visual-editor.rendered-block', function ( string $html ): array { + // A callback that forgets to return a string must not + // replace the HTML — otherwise the renderer would emit + // something like `Array` on `__toString`. + return [ 'not', 'a', 'string' ]; + } ); + + $html = $this->normalizeHtml( makeRenderer()->render( [ makeBlock( 'core/paragraph', [ 'content' => 'Hi' ] ) ] ) ); + + expect( $html )->toBe( '

Hi

' ); + } ); + + it( 'passes the normalized attributes (including resolver-stamped keys) to the callback', function (): void { + if ( ! function_exists( 'addFilter' ) ) { + expect( true )->toBeTrue(); + + return; + } + + $capturedAttrs = null; + + addFilter( 'ap.visual-editor.rendered-block', function ( string $html, string $name, array $attributes ) use ( &$capturedAttrs ): string { + if ( 'core/site-title' === $name ) { + $capturedAttrs = $attributes; + } + + return $html; + } ); + + $tree = [ makeBlock( 'core/site-title', [ 'level' => 2, '_resolvedSiteTitle' => 'Filtered Site' ] ) ]; + + makeRenderer()->render( $tree ); + + expect( $capturedAttrs )->toBeArray(); + // Author-supplied attribute survives. + expect( $capturedAttrs['level'] ?? null )->toBe( 2 ); + // The stamped `_resolved*` key is part of the shape the + // callback receives — that's the documented (internal) contract. + expect( $capturedAttrs['_resolvedSiteTitle'] ?? null )->toBe( 'Filtered Site' ); + } ); + + it( 'leaves the HTML untouched when no callbacks are registered', function (): void { + $html = $this->normalizeHtml( makeRenderer()->render( [ makeBlock( 'core/paragraph', [ 'content' => 'Untouched' ] ) ] ) ); + + expect( $html )->toBe( '

Untouched

' ); + } ); +} ); + describe( 'Keystone #50 — block-supports compilation on the rendered HTML', function (): void { it( 'renders a custom background color on core/group as both class marker and inline style', function (): void { $tree = [ makeBlock( 'core/group', [ diff --git a/resources/js/visual-editor/editor/__tests__/canvas-styles.filter.test.ts b/resources/js/visual-editor/editor/__tests__/canvas-styles.filter.test.ts new file mode 100644 index 000000000..eb5bc0094 --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/canvas-styles.filter.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for the `ap.visual-editor.canvas-styles` extension filter. + * + * `canvas-styles.ts` evaluates `applyFilters` synchronously at module- + * load time inside an IIFE and freezes the resulting `canvasStyles` + * export for the rest of the session. That's the timing contract the + * public docblock calls out — so these tests register the callback + * FIRST via a `@wordpress/hooks` mock, then dynamically import the + * module so the IIFE runs against the callback. `vi.resetModules` in + * `beforeEach` gives each case a fresh module load; the sibling + * `canvas-styles.test.ts` covers the no-callback base behavior and + * relies on real `@wordpress/hooks`, so mocking here stays isolated + * to this file. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type FilterCallback = (value: unknown, ...args: unknown[]) => unknown; + +const filters = new Map(); + +vi.mock('@wordpress/hooks', () => ({ + addFilter: (hook: string, _id: string, callback: FilterCallback): void => { + if (!filters.has(hook)) { + filters.set(hook, []); + } + filters.get(hook)!.push(callback); + }, + applyFilters: (hook: string, value: unknown, ...args: unknown[]): unknown => { + const callbacks = filters.get(hook) ?? []; + return callbacks.reduce( + (acc, callback) => callback(acc, ...args), + value + ); + }, +})); + +beforeEach(() => { + filters.clear(); + vi.resetModules(); +}); + +async function loadCanvasStyles(): Promise< + typeof import('../canvas-styles') +> { + return await import('../canvas-styles'); +} + +function registerCanvasStylesFilter(callback: FilterCallback): void { + filters.set('ap.visual-editor.canvas-styles', [callback]); +} + +describe('ap.visual-editor.canvas-styles filter', () => { + it('receives the base ordered list as a `CanvasStyle[]` copy the callback may mutate', async () => { + let received: unknown = null; + + registerCanvasStylesFilter((value) => { + received = value; + return value; + }); + + const { canvasStyles } = await loadCanvasStyles(); + + expect(Array.isArray(received)).toBe(true); + const list = received as Array<{ css: unknown }>; + expect(list.length).toBeGreaterThan(0); + for (const entry of list) { + expect(typeof entry.css).toBe('string'); + } + // The IIFE spreads `baseCanvasStyles` into a new array before + // passing it through the filter, so mutating it here MUST NOT + // affect the final export. + list.push({ css: 'mutation-that-should-not-appear' }); + expect( + canvasStyles.some( + (entry) => entry.css === 'mutation-that-should-not-appear' + ) + ).toBe(false); + }); + + it('appends a callback-supplied stylesheet to canvasStyles', async () => { + registerCanvasStylesFilter((value) => { + const list = value as Array<{ css: string }>; + return [...list, { css: '.injected { color: red; }' }]; + }); + + const { canvasStyles } = await loadCanvasStyles(); + + expect(canvasStyles[canvasStyles.length - 1]?.css).toBe( + '.injected { color: red; }' + ); + }); + + it('falls back to the base list when a callback returns a non-array', async () => { + registerCanvasStylesFilter(() => 'not-an-array'); + + const { canvasStyles } = await loadCanvasStyles(); + + expect(canvasStyles.length).toBeGreaterThan(0); + for (const entry of canvasStyles) { + expect(typeof entry.css).toBe('string'); + } + }); + + it('drops entries whose `css` property is not a string', async () => { + // Marker string is deliberately unique so it can't collide with + // real CSS text that Vite's `?inline` transform would inject at + // build time (or that any of the module-defined constants + // happen to contain). + const marker = '__ap_filter_test_marker__'; + + registerCanvasStylesFilter((value) => { + const list = value as Array<{ css: string }>; + return [ + ...list, + { css: `.${marker}-first { color: green; }` }, + { css: 42 as unknown as string }, // wrong type — dropped + { notCss: 'foo' } as unknown as { css: string }, // no css — dropped + { css: `.${marker}-second { color: blue; }` }, + ]; + }); + + const { canvasStyles } = await loadCanvasStyles(); + + const injected = canvasStyles + .map((entry) => entry.css) + .filter((css) => css.includes(marker)); + + expect(injected).toEqual([ + `.${marker}-first { color: green; }`, + `.${marker}-second { color: blue; }`, + ]); + }); + + it('drops null / non-object entries returned in the callback array', async () => { + registerCanvasStylesFilter((value) => { + const list = value as Array<{ css: string }>; + return [ + ...list, + null as unknown as { css: string }, + undefined as unknown as { css: string }, + 'a raw string' as unknown as { css: string }, + 42 as unknown as { css: string }, + { css: '.survivor { color: purple; }' }, + ]; + }); + + const { canvasStyles } = await loadCanvasStyles(); + + // Every entry that survived the filter is a well-formed + // `{ css: string }` — the primitive garbage must not leak into + // the array `BlockCanvas` hands to `__unstableEditorStyles`. + for (const entry of canvasStyles) { + expect(entry).not.toBeNull(); + expect(typeof entry).toBe('object'); + expect(typeof entry.css).toBe('string'); + } + + expect( + canvasStyles.some( + (entry) => entry.css === '.survivor { color: purple; }' + ) + ).toBe(true); + }); +}); diff --git a/resources/js/visual-editor/editor/canvas-styles.ts b/resources/js/visual-editor/editor/canvas-styles.ts index 45ebd6e77..5d3cdf9c0 100644 --- a/resources/js/visual-editor/editor/canvas-styles.ts +++ b/resources/js/visual-editor/editor/canvas-styles.ts @@ -27,6 +27,7 @@ import blockEditorStyle from '@wordpress/block-editor/build-style/style.css?inli import blockLibraryEditor from '@wordpress/block-library/build-style/editor.css?inline'; import blockLibraryStyle from '@wordpress/block-library/build-style/style.css?inline'; import componentsStyle from '@wordpress/components/build-style/style.css?inline'; +import { applyFilters } from '@wordpress/hooks'; import { ALIGNMENT_OVERRIDE_STYLES, @@ -155,7 +156,9 @@ const EDITOR_BLOCK_TWEAKS = ` `; /** - * Ordered `{ css }` entries handed to `BlockCanvas`'s `styles` prop. + * Base ordered list — third parties extend this via + * `ap.visual-editor.canvas-styles` (see the export below). + * * The order is the cascade order inside the iframe: * * 1. the token bridge first, so every rule below it can read the @@ -172,11 +175,12 @@ const EDITOR_BLOCK_TWEAKS = ` * stylesheet will append after this entry and win in turn once * the theme.json bridge lands. * - * `BlockCanvas` passes this straight to `__unstableEditorStyles` with - * no wrapper selector, so the CSS is injected into the iframe verbatim - * (`transformStyles` is a no-op without a scope or `baseURL`). + * `BlockCanvas` passes the final (post-filter) list straight to + * `__unstableEditorStyles` with no wrapper selector, so the CSS is + * injected into the iframe verbatim (`transformStyles` is a no-op + * without a scope or `baseURL`). */ -export const canvasStyles: readonly CanvasStyle[] = [ +const baseCanvasStyles: CanvasStyle[] = [ { css: canvasThemeTokens }, { css: componentsStyle }, { css: blockEditorStyle }, @@ -213,3 +217,42 @@ export const canvasStyles: readonly CanvasStyle[] = [ // front-end (#47). { css: POST_EDITOR_FRAMING_STYLES }, ]; + +/** + * Iframe-injected stylesheet list, pipeable through the + * `ap.visual-editor.canvas-styles` filter so third-party packages can + * push their CSS into the canvas without editing this file. + * + * Filter callbacks receive the ordered `CanvasStyle[]` and must return + * a new `CanvasStyle[]`. Non-array returns are ignored (the base list + * is used as-is), non-object entries are dropped, and each entry's + * `css` property must be a string — the filter is a hard boundary + * because the array feeds directly into `BlockCanvas`'s + * `__unstableEditorStyles` prop. + * + * TIMING: `applyFilters` runs synchronously at module-load time inside + * the IIFE below and the resulting `canvasStyles` export is frozen for + * the rest of the session. Callbacks MUST be registered + * (`addFilter('ap.visual-editor.canvas-styles', …)`) before this + * module is first imported — typically from a top-level side-effect + * import that appears before the editor bundle in the app's entry + * graph. Filters registered after the first import silently no-op: + * their CSS will not appear in the iframe. + */ +export const canvasStyles: readonly CanvasStyle[] = ( (): CanvasStyle[] => { + const filtered = applyFilters( + 'ap.visual-editor.canvas-styles', + [...baseCanvasStyles], + ); + + if ( ! Array.isArray( filtered ) ) { + return baseCanvasStyles; + } + + return filtered.filter( + ( entry ): entry is CanvasStyle => + entry !== null && + typeof entry === 'object' && + typeof ( entry as { css?: unknown } ).css === 'string', + ); +} )();