From 961e0c41bd076c53d7389c8e61d7ccae3c341893 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 08:04:28 -0400 Subject: [PATCH 1/6] Generate interactive theme toggle blocks --- php-transformer/composer.json | 1 + .../Generators/ThemeToggleBlockGenerator.php | 111 ++++++++++++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 83 +++++++++++++ php-transformer/tests/contract/run.php | 17 +++ .../tests/unit/theme-toggle-block.php | 51 ++++++++ 5 files changed, 263 insertions(+) create mode 100644 php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php create mode 100644 php-transformer/tests/unit/theme-toggle-block.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 66e4a168c..ca3e6884f 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -75,6 +75,7 @@ "php tests/unit/visual-iframe-block.php", "php tests/unit/authored-carousel-block.php", "php tests/unit/authored-marquee-block.php", + "php tests/unit/theme-toggle-block.php", "php tests/unit/responsive-document-variants.php", "php tests/unit/captured-dialog-projector.php", "php tests/unit/editability-report.php", diff --git a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php new file mode 100644 index 000000000..cb02d7fe0 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php @@ -0,0 +1,111 @@ + */ + public function definition(string $blockName): array + { + $attributes = array( + 'ariaLabel' => array('type' => 'string', 'default' => 'Toggle theme'), + 'className' => array('type' => 'string', 'default' => ''), + 'icon' => array('type' => 'string', 'default' => ''), + 'lightLabel' => array('type' => 'string', 'default' => 'Light Mode'), + 'darkLabel' => array('type' => 'string', 'default' => 'Dark Mode'), + 'labelClassName' => array('type' => 'string', 'default' => ''), + 'rootClass' => array('type' => 'string', 'default' => 'dark'), + 'defaultTheme' => array('type' => 'string', 'default' => 'dark'), + ); + $editor = <<<'JS' +( function( blocks, blockEditor, element ) { + var createElement = element.createElement; + var RawHTML = element.RawHTML; + var RichText = blockEditor.RichText; + function buttonProps( attrs ) { return { type: 'button', className: attrs.className || undefined, 'aria-label': attrs.ariaLabel || 'Toggle theme' }; } + blocks.registerBlockType( '__BLOCK_NAME__', { + attributes: __ATTRIBUTES__, + supports: { html: false, customClassName: false, interactivity: true }, + edit: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; var label = light ? attrs.darkLabel : attrs.lightLabel; return createElement( 'button', buttonProps( attrs ), attrs.icon ? createElement( RawHTML, null, attrs.icon ) : null, createElement( RichText, { tagName: 'span', className: attrs.labelClassName || undefined, value: label || '', onChange: function( value ) { props.setAttributes( light ? { darkLabel: value } : { lightLabel: value } ); }, allowedFormats: [] } ) ); }, + save: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; return createElement( 'button', Object.assign( buttonProps( attrs ), { 'data-wp-interactive': 'blocks-engine/theme-toggle', 'data-wp-context': JSON.stringify( { rootClass: attrs.rootClass || 'dark', defaultTheme: attrs.defaultTheme || 'dark', dark: ! light, lightLabel: attrs.lightLabel || 'Light Mode', darkLabel: attrs.darkLabel || 'Dark Mode' } ), 'data-wp-init': 'callbacks.init', 'data-wp-on--click': 'actions.toggle' } ), attrs.icon ? createElement( RawHTML, null, attrs.icon ) : null, createElement( RichText.Content, { tagName: 'span', className: attrs.labelClassName || undefined, value: light ? ( attrs.darkLabel || 'Dark Mode' ) : ( attrs.lightLabel || 'Light Mode' ), 'data-wp-text': 'state.label' } ) ); } + } ); +} )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); +JS; + $view = <<<'JS' +import { getContext, store } from '@wordpress/interactivity'; + +const preferenceKey = 'blocks-engine-theme'; +const applyTheme = ( rootClass, dark ) => { + const root = document.documentElement; + root.classList.toggle( rootClass, dark ); + root.classList.toggle( 'light', ! dark ); +}; + +store( 'blocks-engine/theme-toggle', { + actions: { + toggle() { + const context = getContext(); + context.dark = ! context.dark; + applyTheme( context.rootClass || 'dark', context.dark ); + try { window.localStorage.setItem( preferenceKey, context.dark ? 'dark' : 'light' ); } catch ( error ) {} + }, + }, + state: { + get label() { + const context = getContext(); + return context.dark ? context.lightLabel : context.darkLabel; + }, + }, + callbacks: { + init() { + const context = getContext(); + const rootClass = context.rootClass || 'dark'; + try { + const preference = window.localStorage.getItem( preferenceKey ); + context.dark = 'dark' === preference || ( 'light' !== preference && 'light' !== context.defaultTheme ); + applyTheme( rootClass, context.dark ); + } catch ( error ) {} + }, + }, +} ); +JS; + + return array( + 'name' => self::LOCAL_NAME, + 'block_json' => array( + 'apiVersion' => 3, + 'name' => $blockName, + 'title' => 'Theme Toggle', + 'category' => 'widgets', + 'description' => 'Editable control for a captured light and dark theme contract.', + 'editorScript' => 'file:./index.js', + 'viewScriptModule' => 'file:./view.js', + 'attributes' => $attributes, + 'supports' => array('html' => false, 'customClassName' => false, 'interactivity' => true), + ), + 'assets' => array('index.js' => str_replace(array('__BLOCK_NAME__', '__ATTRIBUTES__'), array($blockName, json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), $editor)), + 'view_js' => $view, + 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element'), 'view.js' => array('@wordpress/interactivity')), + ); + } + + /** @param array $attributes */ + public function markup(array $attributes): string + { + $escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $defaultTheme = 'light' === ($attributes['defaultTheme'] ?? '') ? 'light' : 'dark'; + $lightLabel = (string) ($attributes['lightLabel'] ?? 'Light Mode'); + $darkLabel = (string) ($attributes['darkLabel'] ?? 'Dark Mode'); + $context = $escape((string) json_encode(array('rootClass' => (string) ($attributes['rootClass'] ?? 'dark'), 'defaultTheme' => $defaultTheme, 'dark' => 'dark' === $defaultTheme, 'lightLabel' => $lightLabel, 'darkLabel' => $darkLabel), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + return ''; + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index b7a537f50..21ec552ff 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -30,6 +30,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ResponsiveLayoutBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ResponsiveMediaBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\SvgArtworkBlockGenerator; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ThemeToggleBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\VisualIframeBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolutionContext; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver; @@ -172,6 +173,7 @@ final class HtmlCompilation implements SourceBlockCreator, RichTextInlinePolicy, private const MAX_INTERACTION_CANDIDATES = 100; private const MAX_CAPTURED_LAYOUT_SOURCE_NESTING = 20; + private string $capturedRootTheme = ''; /** * Core blocks this transformer can produce, keyed by the contract that @@ -1178,6 +1180,7 @@ public function transform(string $html, array $options = array()): TransformerRe is_array($options['runtime_projection_script_assets'] ?? null) ? $options['runtime_projection_script_assets'] : array() ); $staticCss = (string) ($options['static_css'] ?? ''); + $this->capturedRootTheme = $this->documentRootTheme($html); $styleAnalysis = $this->stylesheetAnalysisComposer->composedStyleAnalysis( $this->stylesheetAnalysisComposer->stylesheetPayloads($html, $staticCss, $options) ); @@ -2422,6 +2425,18 @@ private function documentBodyHtml(string $html): string return $this->innerHtml($body); } + private function documentRootTheme(string $html): string + { + if ( ! preg_match('/]*\bclass\s*=\s*(["\'])(.*?)\1/is', $html, $match) ) { + return ''; + } + $classes = preg_split('/\s+/', trim(html_entity_decode($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))) ?: array(); + if ( in_array('dark', $classes, true) ) { + return 'dark'; + } + return in_array('light', $classes, true) ? 'light' : ''; + } + /** @return list */ private function documentBodyClassNames(string $html): array { @@ -3016,6 +3031,13 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $this->capturedDialogBlock($element, $fallbacks); } + if ( 'button' === $tagName ) { + $themeToggle = $this->themeToggleBlock($element); + if ( null !== $themeToggle ) { + return $themeToggle; + } + } + if ( $this->runtimeIslands->shouldPreserveDataAttributeRuntimeTarget($element) ) { return $this->htmlPreservationBlock($element); } @@ -10061,6 +10083,67 @@ private function authoredMarqueeBlock(DOMElement $element): ?array ); } + /** @return array|null */ + private function themeToggleBlock(DOMElement $element): ?array + { + $identity = strtolower(trim($this->attr($element, 'class') . ' ' . $this->attr($element, 'data-testid'))); + if ( 1 !== preg_match('/(?:^|[^a-z0-9])theme[-_ ]?toggle(?:[^a-z0-9]|$)/', $identity) + || 'toggle theme' !== strtolower(trim($this->attr($element, 'aria-label'))) + || ! preg_match('/\.dark(?![-_a-z0-9])/i', $this->authorStyles()->combinedCss()) + || ! preg_match('/:root\s*:\s*not\(\s*\.dark\s*\)/i', $this->authorStyles()->combinedCss()) + ) { + return null; + } + + $svg = null; + $label = null; + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + if ( 'svg' === strtolower($child->tagName) && null === $svg ) { + $svg = $child; + } elseif ( 'span' === strtolower($child->tagName) && null === $label && '' !== trim($child->textContent ?? '') ) { + $label = $child; + } + } + if ( ! $svg instanceof DOMElement || ! $label instanceof DOMElement || ! $this->svgHasDrawableContent($svg) ) { + return null; + } + if ( 1 !== preg_match('/(?:^|[^a-z0-9])theme[-_ ]?toggle[-_ ]?label(?:[^a-z0-9]|$)/', strtolower(trim($this->attr($label, 'class')))) ) { + return null; + } + + $icon = $this->svgMaterializer->restoreSvgCasing($this->sanitizeInlineSvgMarkup($svg)); + if ( '' === $icon || ! $this->isSafeSvgContent($icon) ) { + return null; + } + if ( ! in_array($this->capturedRootTheme, array( 'dark', 'light' ), true) ) { + return null; + } + $labelText = trim($label->textContent ?? ''); + if ( 0 !== $label->childElementCount || 1 !== preg_match('/^(Light|Dark)\s+Mode$/i', $labelText, $labelMatch) ) { + return null; + } + $sourceOffersLight = 'light' === strtolower($labelMatch[1]); + $lightLabel = $sourceOffersLight ? $labelText : 'Light' . substr($labelText, strlen($labelMatch[1])); + $darkLabel = $sourceOffersLight ? 'Dark' . substr($labelText, strlen($labelMatch[1])) : $labelText; + $generator = new ThemeToggleBlockGenerator(); + $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition($this->generatedBlocks()->namespace())); + $attributes = array( + 'ariaLabel' => trim($this->attr($element, 'aria-label')), + 'className' => trim($this->attr($element, 'class')), + 'icon' => $icon, + 'lightLabel' => $lightLabel, + 'darkLabel' => $darkLabel, + 'labelClassName' => trim($this->attr($label, 'class')), + 'rootClass' => 'dark', + 'defaultTheme' => $this->capturedRootTheme, + ); + $markup = $generator->markup($attributes); + return array('blockName' => $this->generatedBlocks()->blockName(ThemeToggleBlockGenerator::LOCAL_NAME), 'attrs' => $attributes, 'innerBlocks' => array(), 'innerHTML' => $markup, 'innerContent' => array($markup)); + } + /** @return array|null */ private function authoredCarouselBlock(DOMElement $element): ?array { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 6305e5e02..b7bb57b6b 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -254,6 +254,23 @@ function serialize_blocks(array $blocks): string 'custom/authored-carousel' !== ($staticGalleryResult['blocks'][0]['blockName'] ?? null), 'an ordered image collection without previous and next controls is not promoted to an interactive carousel' ); +$themeToggleSource = ''; +$themeToggleResult = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'))->toArray(); +$themeToggleDefinition = $themeToggleResult['source_reports']['generated_blocks'][0] ?? array(); +$assert( + 'custom/theme-toggle' === ($themeToggleResult['blocks'][0]['blockName'] ?? null) + && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), 'theme-toggle-btn') + && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), '') + && 'file:./view.js' === ($themeToggleDefinition['block_json']['viewScriptModule'] ?? null) + && array('@wordpress/interactivity') === ($themeToggleDefinition['script_dependencies']['view.js'] ?? null), + 'corroborated dark-root theme controls lower to an editable companion with preserved authored markup and an Interactivity API runtime asset' +); +$themeToggleWithoutLightState = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); +$assert( + 'custom/theme-toggle' !== ($themeToggleWithoutLightState['blocks'][0]['blockName'] ?? null), + 'theme-looking buttons without both root CSS states remain ordinary buttons' +); $referenceAnalyzer = new ReferenceAnalyzer(); $htmlCandidates = $referenceAnalyzer->htmlReferenceCandidates('AboutLogo', 'index.html'); diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php new file mode 100644 index 000000000..fc49fe854 --- /dev/null +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -0,0 +1,51 @@ +'; +$css = '.theme-toggle-btn{display:flex}.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'; +$result = (new HtmlTransformer())->transform($source, array('static_css' => $css))->toArray(); +$block = $result['blocks'][0] ?? array(); +$markup = (string) ($result['serialized_blocks'] ?? ''); +$assert('custom/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the theme companion'); +$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && str_contains((string) ($block['attrs']['icon'] ?? ''), '') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null), 'the authored button selector, icon, and bounded light/dark label pair remain editable and selector-addressable'); +$assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"'), 'saved markup declares deterministic Interactivity API initialization and action directives'); +$assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'theme-toggle serialization is editor-valid'); +$assert('custom/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme companion persists through parse and serialize'); + +$definition = $result['source_reports']['generated_blocks'][0] ?? array(); +$view = (string) ($definition['view_js'] ?? ''); +$editor = (string) ($definition['assets']['index.js'] ?? ''); +$assert('file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion registers its Interactivity API runtime asset and keeps save/init parity with PHP markup'); +$assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, 'localStorage.setItem') && str_contains($view, 'get label()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class, persists it, and exposes the opposing theme label through Interactivity state'); +$payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); +$assert('theme-toggle' === ($payload['blocks'][0]['name'] ?? null) && array('@wordpress/interactivity') === ($payload['blocks'][0]['script_dependencies']['view.js'] ?? null), 'the companion plugin payload preserves runtime asset registration'); + +$missingCss = (new HtmlTransformer())->transform($source, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); +$assert('custom/theme-toggle' !== ($missingCss['blocks'][0]['blockName'] ?? null), 'one theme CSS state is insufficient evidence for promotion'); +$ambiguous = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); +$assert('custom/theme-toggle' !== ($ambiguous['blocks'][0]['blockName'] ?? null), 'a selector-identical button without the authored label carrier remains a core button'); +$ordinary = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); +$assert('custom/theme-toggle' !== ($ordinary['blocks'][0]['blockName'] ?? null), 'ordinary accessible buttons remain on core/button lowering'); +$lightRootSource = str_replace(array('transform($lightRootSource, array('static_css' => $css))->toArray(); +$assert('light' === ($lightRoot['blocks'][0]['attrs']['defaultTheme'] ?? null) && 'Light Mode' === ($lightRoot['blocks'][0]['attrs']['lightLabel'] ?? null) && str_contains((string) ($lightRoot['serialized_blocks'] ?? ''), '>Dark Mode'), 'the default theme and its current action label are read from the captured root class rather than forced dark'); +$sanitizedSvg = (new HtmlTransformer())->transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); +$sanitizedIcon = (string) ($sanitizedSvg['blocks'][0]['attrs']['icon'] ?? ''); +$assert('custom/theme-toggle' === ($sanitizedSvg['blocks'][0]['blockName'] ?? null) && !str_contains($sanitizedIcon, 'onload=') && !str_contains($sanitizedIcon, 'transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); +$assert('custom/theme-toggle' !== ($unsafeOnly['blocks'][0]['blockName'] ?? null), 'a non-drawable hostile SVG fails closed instead of creating a theme companion'); + +fwrite(STDOUT, "Theme toggle companion tests passed\n"); From 2bfdf90d4874f8dbf02f49e689b08e3e8342707b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 08:23:32 -0400 Subject: [PATCH 2/6] Declare complete theme toggle block name --- .../src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php | 3 ++- php-transformer/tests/unit/theme-toggle-block.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php index cb02d7fe0..036975447 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php @@ -9,8 +9,9 @@ final class ThemeToggleBlockGenerator public const LOCAL_NAME = 'theme-toggle'; /** @return array */ - public function definition(string $blockName): array + public function definition(string $namespace): array { + $blockName = $namespace . '/' . self::LOCAL_NAME; $attributes = array( 'ariaLabel' => array('type' => 'string', 'default' => 'Toggle theme'), 'className' => array('type' => 'string', 'default' => ''), diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php index fc49fe854..33830cc85 100644 --- a/php-transformer/tests/unit/theme-toggle-block.php +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -28,7 +28,7 @@ $definition = $result['source_reports']['generated_blocks'][0] ?? array(); $view = (string) ($definition['view_js'] ?? ''); $editor = (string) ($definition['assets']['index.js'] ?? ''); -$assert('file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion registers its Interactivity API runtime asset and keeps save/init parity with PHP markup'); +$assert('custom/theme-toggle' === ($definition['block_json']['name'] ?? null) && 'file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion declares its complete WordPress block name, registers its Interactivity API runtime asset, and keeps save/init parity with PHP markup'); $assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, 'localStorage.setItem') && str_contains($view, 'get label()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class, persists it, and exposes the opposing theme label through Interactivity state'); $payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); $assert('theme-toggle' === ($payload['blocks'][0]['name'] ?? null) && array('@wordpress/interactivity') === ($payload['blocks'][0]['script_dependencies']['view.js'] ?? null), 'the companion plugin payload preserves runtime asset registration'); From e760fcdbfbfd651759f6cbb1e6f5fcb6cf062380 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 08:39:15 -0400 Subject: [PATCH 3/6] Use canonical theme toggle block --- .../Generators/ThemeToggleBlockGenerator.php | 17 ++++++++++------- .../src/HtmlToBlocks/HtmlCompilation.php | 4 ++-- php-transformer/tests/contract/run.php | 4 ++-- .../tests/unit/theme-toggle-block.php | 18 +++++++++--------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php index 036975447..6cbc03fb2 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php @@ -7,11 +7,11 @@ final class ThemeToggleBlockGenerator { public const LOCAL_NAME = 'theme-toggle'; + public const NAME = 'blocks-engine/theme-toggle'; /** @return array */ - public function definition(string $namespace): array + public function definition(): array { - $blockName = $namespace . '/' . self::LOCAL_NAME; $attributes = array( 'ariaLabel' => array('type' => 'string', 'default' => 'Toggle theme'), 'className' => array('type' => 'string', 'default' => ''), @@ -43,7 +43,8 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN const applyTheme = ( rootClass, dark ) => { const root = document.documentElement; root.classList.toggle( rootClass, dark ); - root.classList.toggle( 'light', ! dark ); + root.classList.toggle( 'light', ! dark ); + root.style.colorScheme = dark ? 'dark' : 'light'; }; store( 'blocks-engine/theme-toggle', { @@ -65,11 +66,13 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN init() { const context = getContext(); const rootClass = context.rootClass || 'dark'; + let dark = 'light' !== context.defaultTheme; try { const preference = window.localStorage.getItem( preferenceKey ); - context.dark = 'dark' === preference || ( 'light' !== preference && 'light' !== context.defaultTheme ); - applyTheme( rootClass, context.dark ); + dark = 'dark' === preference || ( 'light' !== preference && dark ); } catch ( error ) {} + context.dark = dark; + applyTheme( rootClass, dark ); }, }, } ); @@ -79,7 +82,7 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN 'name' => self::LOCAL_NAME, 'block_json' => array( 'apiVersion' => 3, - 'name' => $blockName, + 'name' => self::NAME, 'title' => 'Theme Toggle', 'category' => 'widgets', 'description' => 'Editable control for a captured light and dark theme contract.', @@ -88,7 +91,7 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN 'attributes' => $attributes, 'supports' => array('html' => false, 'customClassName' => false, 'interactivity' => true), ), - 'assets' => array('index.js' => str_replace(array('__BLOCK_NAME__', '__ATTRIBUTES__'), array($blockName, json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), $editor)), + 'assets' => array('index.js' => str_replace(array('__BLOCK_NAME__', '__ATTRIBUTES__'), array(self::NAME, json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), $editor)), 'view_js' => $view, 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element'), 'view.js' => array('@wordpress/interactivity')), ); diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 21ec552ff..bcab77da5 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -10129,7 +10129,7 @@ private function themeToggleBlock(DOMElement $element): ?array $lightLabel = $sourceOffersLight ? $labelText : 'Light' . substr($labelText, strlen($labelMatch[1])); $darkLabel = $sourceOffersLight ? 'Dark' . substr($labelText, strlen($labelMatch[1])) : $labelText; $generator = new ThemeToggleBlockGenerator(); - $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition($this->generatedBlocks()->namespace())); + $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition()); $attributes = array( 'ariaLabel' => trim($this->attr($element, 'aria-label')), 'className' => trim($this->attr($element, 'class')), @@ -10141,7 +10141,7 @@ private function themeToggleBlock(DOMElement $element): ?array 'defaultTheme' => $this->capturedRootTheme, ); $markup = $generator->markup($attributes); - return array('blockName' => $this->generatedBlocks()->blockName(ThemeToggleBlockGenerator::LOCAL_NAME), 'attrs' => $attributes, 'innerBlocks' => array(), 'innerHTML' => $markup, 'innerContent' => array($markup)); + return array('blockName' => ThemeToggleBlockGenerator::NAME, 'attrs' => $attributes, 'innerBlocks' => array(), 'innerHTML' => $markup, 'innerContent' => array($markup)); } /** @return array|null */ diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index b7bb57b6b..421e219c9 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -258,7 +258,7 @@ function serialize_blocks(array $blocks): string $themeToggleResult = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'))->toArray(); $themeToggleDefinition = $themeToggleResult['source_reports']['generated_blocks'][0] ?? array(); $assert( - 'custom/theme-toggle' === ($themeToggleResult['blocks'][0]['blockName'] ?? null) + 'blocks-engine/theme-toggle' === ($themeToggleResult['blocks'][0]['blockName'] ?? null) && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), 'theme-toggle-btn') && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), '') @@ -268,7 +268,7 @@ function serialize_blocks(array $blocks): string ); $themeToggleWithoutLightState = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); $assert( - 'custom/theme-toggle' !== ($themeToggleWithoutLightState['blocks'][0]['blockName'] ?? null), + 'blocks-engine/theme-toggle' !== ($themeToggleWithoutLightState['blocks'][0]['blockName'] ?? null), 'theme-looking buttons without both root CSS states remain ordinary buttons' ); diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php index 33830cc85..be7749249 100644 --- a/php-transformer/tests/unit/theme-toggle-block.php +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -19,33 +19,33 @@ $result = (new HtmlTransformer())->transform($source, array('static_css' => $css))->toArray(); $block = $result['blocks'][0] ?? array(); $markup = (string) ($result['serialized_blocks'] ?? ''); -$assert('custom/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the theme companion'); +$assert('blocks-engine/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the canonical Blocks Engine theme toggle'); $assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && str_contains((string) ($block['attrs']['icon'] ?? ''), '') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null), 'the authored button selector, icon, and bounded light/dark label pair remain editable and selector-addressable'); $assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"'), 'saved markup declares deterministic Interactivity API initialization and action directives'); $assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'theme-toggle serialization is editor-valid'); -$assert('custom/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme companion persists through parse and serialize'); +$assert('blocks-engine/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme toggle persists through parse and serialize'); $definition = $result['source_reports']['generated_blocks'][0] ?? array(); $view = (string) ($definition['view_js'] ?? ''); $editor = (string) ($definition['assets']['index.js'] ?? ''); -$assert('custom/theme-toggle' === ($definition['block_json']['name'] ?? null) && 'file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion declares its complete WordPress block name, registers its Interactivity API runtime asset, and keeps save/init parity with PHP markup'); -$assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, 'localStorage.setItem') && str_contains($view, 'get label()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class, persists it, and exposes the opposing theme label through Interactivity state'); +$assert('blocks-engine/theme-toggle' === ($definition['block_json']['name'] ?? null) && str_contains($editor, "registerBlockType( 'blocks-engine/theme-toggle'") && 'file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion declares the canonical Blocks Engine block name, registers its Interactivity API runtime asset, and keeps save/init parity with PHP markup'); +$assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, "root.style.colorScheme = dark ? 'dark' : 'light'") && str_contains($view, 'localStorage.setItem') && str_contains($view, 'get label()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class and color scheme, persists it, and exposes the opposing theme label through Interactivity state'); $payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); $assert('theme-toggle' === ($payload['blocks'][0]['name'] ?? null) && array('@wordpress/interactivity') === ($payload['blocks'][0]['script_dependencies']['view.js'] ?? null), 'the companion plugin payload preserves runtime asset registration'); $missingCss = (new HtmlTransformer())->transform($source, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); -$assert('custom/theme-toggle' !== ($missingCss['blocks'][0]['blockName'] ?? null), 'one theme CSS state is insufficient evidence for promotion'); +$assert('blocks-engine/theme-toggle' !== ($missingCss['blocks'][0]['blockName'] ?? null), 'one theme CSS state is insufficient evidence for promotion'); $ambiguous = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); -$assert('custom/theme-toggle' !== ($ambiguous['blocks'][0]['blockName'] ?? null), 'a selector-identical button without the authored label carrier remains a core button'); +$assert('blocks-engine/theme-toggle' !== ($ambiguous['blocks'][0]['blockName'] ?? null), 'a selector-identical button without the authored label carrier remains a core button'); $ordinary = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); -$assert('custom/theme-toggle' !== ($ordinary['blocks'][0]['blockName'] ?? null), 'ordinary accessible buttons remain on core/button lowering'); +$assert('blocks-engine/theme-toggle' !== ($ordinary['blocks'][0]['blockName'] ?? null), 'ordinary accessible buttons remain on core/button lowering'); $lightRootSource = str_replace(array('transform($lightRootSource, array('static_css' => $css))->toArray(); $assert('light' === ($lightRoot['blocks'][0]['attrs']['defaultTheme'] ?? null) && 'Light Mode' === ($lightRoot['blocks'][0]['attrs']['lightLabel'] ?? null) && str_contains((string) ($lightRoot['serialized_blocks'] ?? ''), '>Dark Mode'), 'the default theme and its current action label are read from the captured root class rather than forced dark'); $sanitizedSvg = (new HtmlTransformer())->transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); $sanitizedIcon = (string) ($sanitizedSvg['blocks'][0]['attrs']['icon'] ?? ''); -$assert('custom/theme-toggle' === ($sanitizedSvg['blocks'][0]['blockName'] ?? null) && !str_contains($sanitizedIcon, 'onload=') && !str_contains($sanitizedIcon, 'transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); -$assert('custom/theme-toggle' !== ($unsafeOnly['blocks'][0]['blockName'] ?? null), 'a non-drawable hostile SVG fails closed instead of creating a theme companion'); +$assert('blocks-engine/theme-toggle' !== ($unsafeOnly['blocks'][0]['blockName'] ?? null), 'a non-drawable hostile SVG fails closed instead of creating a theme companion'); fwrite(STDOUT, "Theme toggle companion tests passed\n"); From 095cce4026db4d75a162eba1fac39681f9f8f81a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 10:14:28 -0400 Subject: [PATCH 4/6] Complete theme toggle contract --- .../Generators/ThemeToggleBlockGenerator.php | 43 +++++++++++++++---- .../src/HtmlToBlocks/HtmlCompilation.php | 13 +++++- php-transformer/tests/contract/run.php | 3 +- .../tests/unit/theme-toggle-block.php | 27 +++++++----- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php index 6cbc03fb2..712865165 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php @@ -3,6 +3,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; + /** Builds an editable theme control for a statically corroborated root theme contract. */ final class ThemeToggleBlockGenerator { @@ -15,12 +17,15 @@ public function definition(): array $attributes = array( 'ariaLabel' => array('type' => 'string', 'default' => 'Toggle theme'), 'className' => array('type' => 'string', 'default' => ''), - 'icon' => array('type' => 'string', 'default' => ''), + 'lightIcon' => array('type' => 'string', 'default' => ''), + 'darkIcon' => array('type' => 'string', 'default' => ''), 'lightLabel' => array('type' => 'string', 'default' => 'Light Mode'), 'darkLabel' => array('type' => 'string', 'default' => 'Dark Mode'), 'labelClassName' => array('type' => 'string', 'default' => ''), + 'labelMarker' => array('type' => 'string', 'default' => ''), 'rootClass' => array('type' => 'string', 'default' => 'dark'), 'defaultTheme' => array('type' => 'string', 'default' => 'dark'), + 'storageKey' => array('type' => 'string', 'default' => 'theme'), ); $editor = <<<'JS' ( function( blocks, blockEditor, element ) { @@ -28,18 +33,20 @@ public function definition(): array var RawHTML = element.RawHTML; var RichText = blockEditor.RichText; function buttonProps( attrs ) { return { type: 'button', className: attrs.className || undefined, 'aria-label': attrs.ariaLabel || 'Toggle theme' }; } + function safeIcon( icon ) { return /^)/i.test( icon || '' ) && !/(?:<\/?(?:script|style|foreignobject|iframe|object|embed|link)\b|\son[a-z]+\s*=|javascript\s*:)/i.test( icon ) ? icon : ''; } + function icon( value, hidden ) { return createElement( 'span', hidden ? { 'data-wp-bind--hidden': hidden } : undefined, safeIcon( value ) ? createElement( RawHTML, null, safeIcon( value ) ) : null ); } + function labelProps( attrs, value, onChange ) { var props = { tagName: 'span', className: attrs.labelClassName || undefined, value: value || '', allowedFormats: [] }; if ( attrs.labelMarker ) { props[ 'data-blocks-engine-richtext-marker' ] = attrs.labelMarker; } if ( onChange ) { props.onChange = onChange; } return props; } blocks.registerBlockType( '__BLOCK_NAME__', { attributes: __ATTRIBUTES__, supports: { html: false, customClassName: false, interactivity: true }, - edit: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; var label = light ? attrs.darkLabel : attrs.lightLabel; return createElement( 'button', buttonProps( attrs ), attrs.icon ? createElement( RawHTML, null, attrs.icon ) : null, createElement( RichText, { tagName: 'span', className: attrs.labelClassName || undefined, value: label || '', onChange: function( value ) { props.setAttributes( light ? { darkLabel: value } : { lightLabel: value } ); }, allowedFormats: [] } ) ); }, - save: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; return createElement( 'button', Object.assign( buttonProps( attrs ), { 'data-wp-interactive': 'blocks-engine/theme-toggle', 'data-wp-context': JSON.stringify( { rootClass: attrs.rootClass || 'dark', defaultTheme: attrs.defaultTheme || 'dark', dark: ! light, lightLabel: attrs.lightLabel || 'Light Mode', darkLabel: attrs.darkLabel || 'Dark Mode' } ), 'data-wp-init': 'callbacks.init', 'data-wp-on--click': 'actions.toggle' } ), attrs.icon ? createElement( RawHTML, null, attrs.icon ) : null, createElement( RichText.Content, { tagName: 'span', className: attrs.labelClassName || undefined, value: light ? ( attrs.darkLabel || 'Dark Mode' ) : ( attrs.lightLabel || 'Light Mode' ), 'data-wp-text': 'state.label' } ) ); } + edit: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; var label = light ? attrs.darkLabel : attrs.lightLabel; return createElement( 'button', buttonProps( attrs ), icon( light ? attrs.darkIcon : attrs.lightIcon ), createElement( RichText, labelProps( attrs, label, function( value ) { props.setAttributes( light ? { darkLabel: value } : { lightLabel: value } ); } ) ) ); }, + save: function( props ) { var attrs = props.attributes; var light = 'light' === attrs.defaultTheme; return createElement( 'button', Object.assign( buttonProps( attrs ), { 'data-wp-interactive': 'blocks-engine/theme-toggle', 'data-wp-context': JSON.stringify( { rootClass: attrs.rootClass || 'dark', defaultTheme: attrs.defaultTheme || 'dark', dark: ! light, lightLabel: attrs.lightLabel || 'Light Mode', darkLabel: attrs.darkLabel || 'Dark Mode', storageKey: attrs.storageKey || 'theme' } ), 'data-wp-init': 'callbacks.init', 'data-wp-on--click': 'actions.toggle' } ), icon( attrs.lightIcon, 'state.hideLightIcon' ), icon( attrs.darkIcon, 'state.hideDarkIcon' ), createElement( RichText.Content, Object.assign( labelProps( attrs, light ? ( attrs.darkLabel || 'Dark Mode' ) : ( attrs.lightLabel || 'Light Mode' ) ), { 'data-wp-text': 'state.label' } ) ) ); } } ); } )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); JS; $view = <<<'JS' import { getContext, store } from '@wordpress/interactivity'; -const preferenceKey = 'blocks-engine-theme'; const applyTheme = ( rootClass, dark ) => { const root = document.documentElement; root.classList.toggle( rootClass, dark ); @@ -53,7 +60,7 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN const context = getContext(); context.dark = ! context.dark; applyTheme( context.rootClass || 'dark', context.dark ); - try { window.localStorage.setItem( preferenceKey, context.dark ? 'dark' : 'light' ); } catch ( error ) {} + try { window.localStorage.setItem( context.storageKey || 'theme', context.dark ? 'dark' : 'light' ); } catch ( error ) {} }, }, state: { @@ -61,6 +68,12 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN const context = getContext(); return context.dark ? context.lightLabel : context.darkLabel; }, + get hideLightIcon() { + return ! getContext().dark; + }, + get hideDarkIcon() { + return getContext().dark; + }, }, callbacks: { init() { @@ -68,7 +81,7 @@ function buttonProps( attrs ) { return { type: 'button', className: attrs.classN const rootClass = context.rootClass || 'dark'; let dark = 'light' !== context.defaultTheme; try { - const preference = window.localStorage.getItem( preferenceKey ); + const preference = window.localStorage.getItem( context.storageKey || 'theme' ); dark = 'dark' === preference || ( 'light' !== preference && dark ); } catch ( error ) {} context.dark = dark; @@ -104,12 +117,24 @@ public function markup(array $attributes): string $defaultTheme = 'light' === ($attributes['defaultTheme'] ?? '') ? 'light' : 'dark'; $lightLabel = (string) ($attributes['lightLabel'] ?? 'Light Mode'); $darkLabel = (string) ($attributes['darkLabel'] ?? 'Dark Mode'); - $context = $escape((string) json_encode(array('rootClass' => (string) ($attributes['rootClass'] ?? 'dark'), 'defaultTheme' => $defaultTheme, 'dark' => 'dark' === $defaultTheme, 'lightLabel' => $lightLabel, 'darkLabel' => $darkLabel), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + $marker = $this->safeToken((string) ($attributes['labelMarker'] ?? '')); + $context = $escape((string) json_encode(array('rootClass' => (string) ($attributes['rootClass'] ?? 'dark'), 'defaultTheme' => $defaultTheme, 'dark' => 'dark' === $defaultTheme, 'lightLabel' => $lightLabel, 'darkLabel' => $darkLabel, 'storageKey' => (string) ($attributes['storageKey'] ?? 'theme')), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); return ''; + . '' . $this->safeIcon((string) ($attributes['lightIcon'] ?? '')) . '' + . '' . $this->safeIcon((string) ($attributes['darkIcon'] ?? '')) . '' + . '' . $escape('dark' === $defaultTheme ? $lightLabel : $darkLabel) . ''; + } + + private function safeIcon(string $icon): string + { + return SourceDom::isSafeSvgContent($icon) && ! preg_match('/<\/?(?:script|style|foreignobject|iframe|object|embed|link)\b|\son[a-z]+\s*=|javascript\s*:/i', $icon) ? $icon : ''; + } + + private function safeToken(string $value): string + { + return 1 === preg_match('/^[A-Za-z0-9_-]+$/', $value) ? $value : ''; } } diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index bcab77da5..f674353d0 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -10128,17 +10128,28 @@ private function themeToggleBlock(DOMElement $element): ?array $sourceOffersLight = 'light' === strtolower($labelMatch[1]); $lightLabel = $sourceOffersLight ? $labelText : 'Light' . substr($labelText, strlen($labelMatch[1])); $darkLabel = $sourceOffersLight ? 'Dark' . substr($labelText, strlen($labelMatch[1])) : $labelText; + $iconIdentity = strtolower($this->attr($svg, 'class') . ' ' . $this->attr($svg, 'data-lucide')); + $isSun = 1 === preg_match('/(?:^|[^a-z0-9])(?:lucide[-_ ])?sun(?:[^a-z0-9]|$)/', $iconIdentity); + $isMoon = 1 === preg_match('/(?:^|[^a-z0-9])(?:lucide[-_ ])?moon(?:[^a-z0-9]|$)/', $iconIdentity); + if ( ! $isSun && ! $isMoon ) { + return null; + } + $sunIcon = ''; + $moonIcon = ''; $generator = new ThemeToggleBlockGenerator(); $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition()); $attributes = array( 'ariaLabel' => trim($this->attr($element, 'aria-label')), 'className' => trim($this->attr($element, 'class')), - 'icon' => $icon, + 'lightIcon' => $isSun ? $icon : $sunIcon, + 'darkIcon' => $isMoon ? $icon : $moonIcon, 'lightLabel' => $lightLabel, 'darkLabel' => $darkLabel, 'labelClassName' => trim($this->attr($label, 'class')), + 'labelMarker' => trim($this->attr($label, 'data-blocks-engine-richtext-marker')), 'rootClass' => 'dark', 'defaultTheme' => $this->capturedRootTheme, + 'storageKey' => 'theme', ); $markup = $generator->markup($attributes); return array('blockName' => ThemeToggleBlockGenerator::NAME, 'attrs' => $attributes, 'innerBlocks' => array(), 'innerHTML' => $markup, 'innerContent' => array($markup)); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 421e219c9..3a4dc7316 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -254,7 +254,7 @@ function serialize_blocks(array $blocks): string 'custom/authored-carousel' !== ($staticGalleryResult['blocks'][0]['blockName'] ?? null), 'an ordered image collection without previous and next controls is not promoted to an interactive carousel' ); -$themeToggleSource = ''; +$themeToggleSource = ''; $themeToggleResult = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'))->toArray(); $themeToggleDefinition = $themeToggleResult['source_reports']['generated_blocks'][0] ?? array(); $assert( @@ -262,6 +262,7 @@ function serialize_blocks(array $blocks): string && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), 'theme-toggle-btn') && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), '') + && str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), 'lucide-moon') && 'file:./view.js' === ($themeToggleDefinition['block_json']['viewScriptModule'] ?? null) && array('@wordpress/interactivity') === ($themeToggleDefinition['script_dependencies']['view.js'] ?? null), 'corroborated dark-root theme controls lower to an editable companion with preserved authored markup and an Interactivity API runtime asset' diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php index be7749249..fd99ea4e9 100644 --- a/php-transformer/tests/unit/theme-toggle-block.php +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\CompanionPluginPayload; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ThemeToggleBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\WordPress\Runtime; $assert = static function (bool $condition, string $message): void { @@ -14,14 +15,15 @@ } }; -$source = ''; -$css = '.theme-toggle-btn{display:flex}.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'; +$source = ''; +$css = '.theme-toggle-btn{display:flex}.theme-toggle-label{display:none}.dark .theme-toggle-btn{color:white}:root:not(.dark) .theme-toggle-btn{color:black}'; $result = (new HtmlTransformer())->transform($source, array('static_css' => $css))->toArray(); $block = $result['blocks'][0] ?? array(); $markup = (string) ($result['serialized_blocks'] ?? ''); $assert('blocks-engine/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the canonical Blocks Engine theme toggle'); -$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && str_contains((string) ($block['attrs']['icon'] ?? ''), '') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null), 'the authored button selector, icon, and bounded light/dark label pair remain editable and selector-addressable'); -$assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"'), 'saved markup declares deterministic Interactivity API initialization and action directives'); +$labelMarker = (string) ($block['attrs']['labelMarker'] ?? ''); +$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && '' !== $labelMarker && str_contains((string) ($block['attrs']['lightIcon'] ?? ''), 'lucide-sun') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'lucide-moon') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null) && 'theme' === ($block['attrs']['storageKey'] ?? null), 'the authored button selector, projected label marker, safe action icons, bounded label pair, and source storage contract remain editable'); +$assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"') && str_contains($markup, 'data-blocks-engine-richtext-marker="' . $labelMarker . '"') && str_contains($markup, 'data-wp-bind--hidden="state.hideLightIcon"') && str_contains($markup, 'data-wp-bind--hidden="state.hideDarkIcon"'), 'saved markup declares deterministic Interactivity, preserves the projected hidden-label carrier, and renders both reactive icon states'); $assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'theme-toggle serialization is editor-valid'); $assert('blocks-engine/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme toggle persists through parse and serialize'); @@ -29,23 +31,26 @@ $view = (string) ($definition['view_js'] ?? ''); $editor = (string) ($definition['assets']['index.js'] ?? ''); $assert('blocks-engine/theme-toggle' === ($definition['block_json']['name'] ?? null) && str_contains($editor, "registerBlockType( 'blocks-engine/theme-toggle'") && 'file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && array('@wordpress/interactivity') === ($definition['script_dependencies']['view.js'] ?? null) && str_contains($editor, "'data-wp-init': 'callbacks.init'"), 'the generated companion declares the canonical Blocks Engine block name, registers its Interactivity API runtime asset, and keeps save/init parity with PHP markup'); -$assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, "root.style.colorScheme = dark ? 'dark' : 'light'") && str_contains($view, 'localStorage.setItem') && str_contains($view, 'get label()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class and color scheme, persists it, and exposes the opposing theme label through Interactivity state'); +$assert(str_contains($view, "store( 'blocks-engine/theme-toggle'") && str_contains($view, "classList.toggle( rootClass, dark )") && str_contains($view, "root.style.colorScheme = dark ? 'dark' : 'light'") && str_contains($view, "context.storageKey || 'theme'") && str_contains($view, 'get label()') && str_contains($view, 'get hideLightIcon()') && str_contains($view, 'get hideDarkIcon()') && str_contains($view, 'context.defaultTheme'), 'the runtime toggles the captured root class and color scheme, persists through the configurable source-compatible key, and reactively swaps the label and icons'); $payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); $assert('theme-toggle' === ($payload['blocks'][0]['name'] ?? null) && array('@wordpress/interactivity') === ($payload['blocks'][0]['script_dependencies']['view.js'] ?? null), 'the companion plugin payload preserves runtime asset registration'); $missingCss = (new HtmlTransformer())->transform($source, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); $assert('blocks-engine/theme-toggle' !== ($missingCss['blocks'][0]['blockName'] ?? null), 'one theme CSS state is insufficient evidence for promotion'); -$ambiguous = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); -$assert('blocks-engine/theme-toggle' !== ($ambiguous['blocks'][0]['blockName'] ?? null), 'a selector-identical button without the authored label carrier remains a core button'); +$ambiguous = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); +$assert('blocks-engine/theme-toggle' !== ($ambiguous['blocks'][0]['blockName'] ?? null), 'a selector-identical button with an unrecognized single icon remains a core button rather than inventing a theme action'); $ordinary = (new HtmlTransformer())->transform('', array('static_css' => $css))->toArray(); $assert('blocks-engine/theme-toggle' !== ($ordinary['blocks'][0]['blockName'] ?? null), 'ordinary accessible buttons remain on core/button lowering'); $lightRootSource = str_replace(array('transform($lightRootSource, array('static_css' => $css))->toArray(); $assert('light' === ($lightRoot['blocks'][0]['attrs']['defaultTheme'] ?? null) && 'Light Mode' === ($lightRoot['blocks'][0]['attrs']['lightLabel'] ?? null) && str_contains((string) ($lightRoot['serialized_blocks'] ?? ''), '>Dark Mode'), 'the default theme and its current action label are read from the captured root class rather than forced dark'); -$sanitizedSvg = (new HtmlTransformer())->transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); -$sanitizedIcon = (string) ($sanitizedSvg['blocks'][0]['attrs']['icon'] ?? ''); -$assert('blocks-engine/theme-toggle' === ($sanitizedSvg['blocks'][0]['blockName'] ?? null) && !str_contains($sanitizedIcon, 'onload=') && !str_contains($sanitizedIcon, 'transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); +$sanitizedSvg = (new HtmlTransformer())->transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); +$sanitizedIcon = (string) ($sanitizedSvg['blocks'][0]['attrs']['lightIcon'] ?? ''); +$assert('blocks-engine/theme-toggle' === ($sanitizedSvg['blocks'][0]['blockName'] ?? null) && !str_contains($sanitizedIcon, 'onload=') && !str_contains($sanitizedIcon, 'transform(str_replace('', '', $source), array('static_css' => $css))->toArray(); $assert('blocks-engine/theme-toggle' !== ($unsafeOnly['blocks'][0]['blockName'] ?? null), 'a non-drawable hostile SVG fails closed instead of creating a theme companion'); +$unsafeMarkup = (new ThemeToggleBlockGenerator())->markup(array('lightIcon' => '', 'darkIcon' => '', 'labelMarker' => 'bad\" marker')); +$assert(!str_contains($unsafeMarkup, ' Date: Thu, 3 Sep 2026 10:20:39 -0400 Subject: [PATCH 5/6] Match captured theme toggle icon --- php-transformer/src/HtmlToBlocks/HtmlCompilation.php | 2 +- php-transformer/tests/unit/theme-toggle-block.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index f674353d0..aa804ffbe 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -10135,7 +10135,7 @@ private function themeToggleBlock(DOMElement $element): ?array return null; } $sunIcon = ''; - $moonIcon = ''; + $moonIcon = ''; $generator = new ThemeToggleBlockGenerator(); $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition()); $attributes = array( diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php index fd99ea4e9..71cc7db70 100644 --- a/php-transformer/tests/unit/theme-toggle-block.php +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -22,7 +22,7 @@ $markup = (string) ($result['serialized_blocks'] ?? ''); $assert('blocks-engine/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the canonical Blocks Engine theme toggle'); $labelMarker = (string) ($block['attrs']['labelMarker'] ?? ''); -$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && '' !== $labelMarker && str_contains((string) ($block['attrs']['lightIcon'] ?? ''), 'lucide-sun') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'lucide-moon') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null) && 'theme' === ($block['attrs']['storageKey'] ?? null), 'the authored button selector, projected label marker, safe action icons, bounded label pair, and source storage contract remain editable'); +$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && '' !== $labelMarker && str_contains((string) ($block['attrs']['lightIcon'] ?? ''), 'lucide-sun') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'M20.985 12.486') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null) && 'theme' === ($block['attrs']['storageKey'] ?? null), 'the authored button selector, projected label marker, safe action icons, bounded label pair, and source storage contract remain editable'); $assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"') && str_contains($markup, 'data-blocks-engine-richtext-marker="' . $labelMarker . '"') && str_contains($markup, 'data-wp-bind--hidden="state.hideLightIcon"') && str_contains($markup, 'data-wp-bind--hidden="state.hideDarkIcon"'), 'saved markup declares deterministic Interactivity, preserves the projected hidden-label carrier, and renders both reactive icon states'); $assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'theme-toggle serialization is editor-valid'); $assert('blocks-engine/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme toggle persists through parse and serialize'); From 46ccb2b1397c5174f992bed62006ab478a1f7744 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 13:17:52 -0400 Subject: [PATCH 6/6] Preserve theme icon geometry --- php-transformer/src/HtmlToBlocks/HtmlCompilation.php | 4 ++-- php-transformer/tests/unit/theme-toggle-block.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index aa804ffbe..4661f9041 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -10134,8 +10134,8 @@ private function themeToggleBlock(DOMElement $element): ?array if ( ! $isSun && ! $isMoon ) { return null; } - $sunIcon = ''; - $moonIcon = ''; + $sunIcon = ''; + $moonIcon = ''; $generator = new ThemeToggleBlockGenerator(); $this->generatedBlocks()->register(ThemeToggleBlockGenerator::class, $generator->definition()); $attributes = array( diff --git a/php-transformer/tests/unit/theme-toggle-block.php b/php-transformer/tests/unit/theme-toggle-block.php index 71cc7db70..73133e826 100644 --- a/php-transformer/tests/unit/theme-toggle-block.php +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -22,7 +22,7 @@ $markup = (string) ($result['serialized_blocks'] ?? ''); $assert('blocks-engine/theme-toggle' === ($block['blockName'] ?? null), 'a dark-root toggle with identity, accessible name, and both CSS states uses the canonical Blocks Engine theme toggle'); $labelMarker = (string) ($block['attrs']['labelMarker'] ?? ''); -$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && '' !== $labelMarker && str_contains((string) ($block['attrs']['lightIcon'] ?? ''), 'lucide-sun') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'M20.985 12.486') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null) && 'theme' === ($block['attrs']['storageKey'] ?? null), 'the authored button selector, projected label marker, safe action icons, bounded label pair, and source storage contract remain editable'); +$assert('theme-toggle-btn' === ($block['attrs']['className'] ?? null) && 'theme-toggle-label' === ($block['attrs']['labelClassName'] ?? null) && '' !== $labelMarker && str_contains((string) ($block['attrs']['lightIcon'] ?? ''), 'lucide-sun') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'M20.985 12.486') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'width="18" height="18"') && str_contains((string) ($block['attrs']['darkIcon'] ?? ''), 'aria-hidden="true"') && 'Light Mode' === ($block['attrs']['lightLabel'] ?? null) && 'Dark Mode' === ($block['attrs']['darkLabel'] ?? null) && 'theme' === ($block['attrs']['storageKey'] ?? null), 'the authored button selector, projected label marker, equal-sized safe action icons, bounded label pair, and source storage contract remain editable'); $assert(str_contains($markup, 'data-wp-interactive="blocks-engine/theme-toggle"') && str_contains($markup, 'data-wp-init="callbacks.init"') && str_contains($markup, 'data-wp-on--click="actions.toggle"') && str_contains($markup, 'data-blocks-engine-richtext-marker="' . $labelMarker . '"') && str_contains($markup, 'data-wp-bind--hidden="state.hideLightIcon"') && str_contains($markup, 'data-wp-bind--hidden="state.hideDarkIcon"'), 'saved markup declares deterministic Interactivity, preserves the projected hidden-label carrier, and renders both reactive icon states'); $assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'theme-toggle serialization is editor-valid'); $assert('blocks-engine/theme-toggle' === ((new Runtime())->parseBlocks((new Runtime())->serializeBlocks(array($block)))[0]['blockName'] ?? null), 'the theme toggle persists through parse and serialize');