diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 66e4a168..ca3e6884 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 00000000..71286516 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Generators/ThemeToggleBlockGenerator.php @@ -0,0 +1,140 @@ + */ + public function definition(): array + { + $attributes = array( + 'ariaLabel' => array('type' => 'string', 'default' => 'Toggle theme'), + 'className' => 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 ) { + 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' }; } + 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 ), 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 applyTheme = ( rootClass, dark ) => { + const root = document.documentElement; + root.classList.toggle( rootClass, dark ); + root.classList.toggle( 'light', ! dark ); + root.style.colorScheme = dark ? 'dark' : 'light'; +}; + +store( 'blocks-engine/theme-toggle', { + actions: { + toggle() { + const context = getContext(); + context.dark = ! context.dark; + applyTheme( context.rootClass || 'dark', context.dark ); + try { window.localStorage.setItem( context.storageKey || 'theme', context.dark ? 'dark' : 'light' ); } catch ( error ) {} + }, + }, + state: { + get label() { + const context = getContext(); + return context.dark ? context.lightLabel : context.darkLabel; + }, + get hideLightIcon() { + return ! getContext().dark; + }, + get hideDarkIcon() { + return getContext().dark; + }, + }, + callbacks: { + init() { + const context = getContext(); + const rootClass = context.rootClass || 'dark'; + let dark = 'light' !== context.defaultTheme; + try { + const preference = window.localStorage.getItem( context.storageKey || 'theme' ); + dark = 'dark' === preference || ( 'light' !== preference && dark ); + } catch ( error ) {} + context.dark = dark; + applyTheme( rootClass, dark ); + }, + }, +} ); +JS; + + return array( + 'name' => self::LOCAL_NAME, + 'block_json' => array( + 'apiVersion' => 3, + 'name' => self::NAME, + '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(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')), + ); + } + + /** @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'); + $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 ''; + } + + 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 b7a537f5..4661f904 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,78 @@ 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; + $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')), + '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)); + } + /** @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 6305e5e0..3a4dc731 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -254,6 +254,24 @@ 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( + 'blocks-engine/theme-toggle' === ($themeToggleResult['blocks'][0]['blockName'] ?? null) + && 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' +); +$themeToggleWithoutLightState = (new HtmlTransformer())->transform($themeToggleSource, array('static_css' => '.dark .theme-toggle-btn{color:white}'))->toArray(); +$assert( + 'blocks-engine/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 00000000..73133e82 --- /dev/null +++ b/php-transformer/tests/unit/theme-toggle-block.php @@ -0,0 +1,56 @@ +'; +$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'); +$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') && 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'); + +$definition = $result['source_reports']['generated_blocks'][0] ?? array(); +$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, "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 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']['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, '