Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);

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
{
public const LOCAL_NAME = 'theme-toggle';
public const NAME = 'blocks-engine/theme-toggle';

/** @return array<string, mixed> */
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 /^<svg(?:\s|>)/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<string, mixed> $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 '<button type="button"'
. ('' !== ($attributes['className'] ?? '') ? ' class="' . $escape((string) $attributes['className']) . '"' : '')
. ' aria-label="' . $escape((string) ($attributes['ariaLabel'] ?? 'Toggle theme')) . '"'
. ' data-wp-interactive="blocks-engine/theme-toggle" data-wp-context="' . $context . '" data-wp-init="callbacks.init" data-wp-on--click="actions.toggle">'
. '<span data-wp-bind--hidden="state.hideLightIcon">' . $this->safeIcon((string) ($attributes['lightIcon'] ?? '')) . '</span>'
. '<span data-wp-bind--hidden="state.hideDarkIcon">' . $this->safeIcon((string) ($attributes['darkIcon'] ?? '')) . '</span>'
. '<span' . ('' !== ($attributes['labelClassName'] ?? '') ? ' class="' . $escape((string) $attributes['labelClassName']) . '"' : '') . ('' !== $marker ? ' data-blocks-engine-richtext-marker="' . $marker . '"' : '') . ' data-wp-text="state.label">' . $escape('dark' === $defaultTheme ? $lightLabel : $darkLabel) . '</span></button>';
}

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 : '';
}
}
94 changes: 94 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -2422,6 +2425,18 @@ private function documentBodyHtml(string $html): string
return $this->innerHtml($body);
}

private function documentRootTheme(string $html): string
{
if ( ! preg_match('/<html\b[^>]*\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<string> */
private function documentBodyClassNames(string $html): array
{
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -10061,6 +10083,78 @@ private function authoredMarqueeBlock(DOMElement $element): ?array
);
}

/** @return array<string, mixed>|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 = '<svg class="lucide lucide-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg>';
$moonIcon = '<svg class="lucide lucide-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"></path></svg>';
$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<string, mixed>|null */
private function authoredCarouselBlock(DOMElement $element): ?array
{
Expand Down
18 changes: 18 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<html class="dark"><body><button class="theme-toggle-btn" aria-label="Toggle theme"><svg class="lucide lucide-sun" data-lucide="sun" viewBox="0 0 24 24"><path d="M12 1v2"></path></svg><span class="theme-toggle-label">Light Mode</span></button></body></html>';
$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'] ?? ''), '<svg')
&& str_contains((string) ($themeToggleResult['serialized_blocks'] ?? ''), '<path d="M12 1v2"></path>')
&& 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('<a href="about.html">About</a><img src="assets/logo.png" alt="Logo">', 'index.html');
Expand Down
Loading
Loading