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
18 changes: 18 additions & 0 deletions php-transformer/src/ArtifactCompiler/WordPressCompatCss.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler;

use Automattic\BlocksEngine\PhpTransformer\Css\ClassAttributeSelectorProjection;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CoreSelectorScoping;
use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker;

use Automattic\BlocksEngine\PhpTransformer\Css\CssStylesheetTransformer;
Expand Down Expand Up @@ -115,10 +116,27 @@ private function siteMarker(string $kind, string $css, array $htmlTexts): string
public function asset(array $files, string $staticCss, array $scriptContents): ?array
{
$css = trim($this->css($staticCss, $files, $scriptContents));
// ID-only source navigation has no copied author class to scope its
// runtime replay. Both native navigation routes already stamp these
// importer classes; a weightless gate preserves the source cascade.
$css = (new CssStylesheetTransformer())->transform($css, static function (string $prelude): string {
$selectors = CssStylesheetTransformer::splitSelectorList($prelude);
if ( null === $selectors ) {
return $prelude;
}
foreach ( $selectors as &$selector ) {
if ( ! (new CoreSelectorScoping())->selectorIsScoped($selector) ) {
$selector = preg_replace('/\.wp-block-navigation(?![\w-])/', '.wp-block-navigation:where(.blocks-engine-list-navigation,.blocks-engine-direct-navigation)', $selector) ?? $selector;
}
}
return implode(',', $selectors);
});
if ( '' === $css ) {
return null;
}

(new CoreSelectorScoping())->assertScoped($css, 'wordpress-compat');

$hash = hash('sha256', $css);
$path = 'assets/css/wordpress-compat-' . substr($hash, 0, 16) . '.css';
return array(
Expand Down
25 changes: 23 additions & 2 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,12 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
if ( 'core/code' === $name ) {
// `content` is the escaped RichText value (see codeContent()); never
// guess from its bytes, or code text that mentions a tag becomes one.
return '<pre' . $this->blockSupportAttrs($attrs, 'wp-block-code') . '><code>' . (string) ($attrs['content'] ?? '') . '</code></pre>';
// The engine class keys the code-shape support rule in
// EngineSupportCss so the selector does not stand on the bare core
// class alone.
$codeAttrs = $attrs;
$codeAttrs['className'] = SourceDom::mergeClassNames('blocks-engine-source-code', (string) ($attrs['className'] ?? ''));
return '<pre' . $this->blockSupportAttrs($codeAttrs, 'wp-block-code') . '><code>' . (string) ($attrs['content'] ?? '') . '</code></pre>';
}

if ( 'core/math' === $name ) {
Expand Down Expand Up @@ -377,6 +382,10 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
}

if ( 'core/accordion-panel' === $name ) {
// The importer class keys the `[hidden]` support rule in
// EngineSupportCss so it does not stand on the bare generated core
// class alone.
$attrs['className'] = SourceDom::mergeClassNames('blocks-engine-accordion-panel', (string) ($attrs['className'] ?? ''));
return $this->roleWrapperHtml('region', $attrs, 'wp-block-accordion-panel');
}

Expand Down Expand Up @@ -476,7 +485,10 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
$justificationClass = in_array($justification, array( 'left', 'center', 'right', 'space-between' ), true) ? 'is-content-justification-' . $justification : '';
$iconColorClass = '' !== trim((string) ($attrs['iconColorValue'] ?? '')) ? 'has-icon-color' : '';
$iconBackgroundClass = '' !== trim((string) ($attrs['iconBackgroundColorValue'] ?? '')) ? 'has-icon-background-color' : '';
return array( 'opening' => '<ul' . $this->blockSupportAttrs($attrs, trim('wp-block-social-links ' . $labelsClass . ' ' . $sizeClass . ' ' . $justificationClass . ' ' . $iconColorClass . ' ' . $iconBackgroundClass)) . '>', 'closing' => '</ul>' );
// The importer class keys the social-links support rules in
// EngineSupportCss so they do not stand on the bare core list class
// alone; every social-links list the importer emits carries it.
return array( 'opening' => '<ul' . $this->blockSupportAttrs($attrs, trim('wp-block-social-links blocks-engine-social-source-cluster ' . $labelsClass . ' ' . $sizeClass . ' ' . $justificationClass . ' ' . $iconColorClass . ' ' . $iconBackgroundClass)) . '>', 'closing' => '</ul>' );
}

if ( 'core/social-link' === $name ) {
Expand Down Expand Up @@ -779,6 +791,9 @@ private function tableHtml(array $attrs): string
private function imageHtml(array $attrs): string
{
$figureAttrs = $attrs;
// A scope key only: unlike semantic markers, this must not change
// which source box owns an author's image declarations.
$figureAttrs['className'] = SourceDom::mergeClassNames((string) ($figureAttrs['className'] ?? ''), 'blocks-engine-image');
$baseClass = 'wp-block-image';
$border = is_array($attrs['style']['border'] ?? null) ? $attrs['style']['border'] : array();
$borderSupport = $this->styleSupport(array( 'border' => $border ));
Expand Down Expand Up @@ -806,6 +821,12 @@ private function imageHtml(array $attrs): string

$img = '<img' . $this->htmlAttrs($imageAttrs, array( 'alt' )) . '/>';
if ( ! empty($attrs['href']) ) {
// The linked-image relay in EngineSupportCss replicates core's
// `:where(.wp-block-image)>a` geometry inheritance for this
// importer's markup. Keying it on this importer-emitted class
// instead of the bare core class keeps the rule off blocks a
// host adds to the served page later.
$figureAttrs['className'] = SourceDom::mergeClassNames((string) ($figureAttrs['className'] ?? ''), 'blocks-engine-linked-image-figure');
$linkAttrs = array(
'href' => (string) $attrs['href'],
'target' => (string) ($attrs['linkTarget'] ?? ''),
Expand Down
1 change: 1 addition & 0 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,7 @@ public function transform(string $html, array $options = array()): TransformerRe
'responsive_geometry_ambiguities' => $this->transformationEvidence()->responsiveGeometryAmbiguities(),
'responsive_height_ambiguities' => $this->transformationEvidence()->responsiveHeightAmbiguities(),
'grid_placement_carrier_findings' => $this->transformationEvidence()->gridPlacementCarrierFindings(),
'unscoped_core_selector_drops' => $this->transformationEvidence()->unscopedCoreSelectorDrops(),
'has_description_list_block' => $this->generatedBlocks()->has(DescriptionListBlockGenerator::class),
), $blockCompilationOutput);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ final class TransformationEvidenceState
/** @var array<string, array{selector: string, reason: string}> */
private array $gridPlacementCarrierFindings = array();

/** @var list<array{selector: string, reason: string}> */
private array $unscopedCoreSelectorDrops = array();

/** @var array<string, true> */
private array $unscopedCoreSelectorDropKeys = array();

public function recordFormControlEcho(string $text): void
{
$text = trim($text);
Expand All @@ -55,6 +61,30 @@ public function recordFrozenHiddenState(array $finding): void
$this->frozenHiddenStateFindings[] = $finding;
}

/**
* Records an author-selector projection arm dropped because it targeted a
* core block class with no importer-emitted class anywhere in its chain
* and no owner class was available to scope it with.
*
* @param string $selector The authored selector whose arm was dropped.
* @param string $reason Stable machine reason for the drop.
*/
public function recordUnscopedCoreSelectorDrop(string $selector, string $reason): void
{
$key = $reason . "\0" . $selector;
if ( isset($this->unscopedCoreSelectorDropKeys[$key]) ) {
return;
}
$this->unscopedCoreSelectorDropKeys[$key] = true;
$this->unscopedCoreSelectorDrops[] = array('selector' => $selector, 'reason' => $reason);
}

/** @return list<array{selector: string, reason: string}> */
public function unscopedCoreSelectorDrops(): array
{
return $this->unscopedCoreSelectorDrops;
}

/** @return list<array<string, mixed>> */
public function frozenHiddenStateFindings(): array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,11 @@ private function projectImageFigureSubjectSelector(string $selector, array $pars
}
}

if ( ! (new CoreSelectorScoping())->selectorIsScoped($projected) ) {
$context->evidence->recordUnscopedCoreSelectorDrop($selector, 'no_importer_class_for_core_image_projection');
return null;
}

return $projected;
}

Expand Down Expand Up @@ -2613,7 +2618,7 @@ private function projectAuthorImageSelectorPrelude(string $prelude, AuthorStyles
// Only the existing fill/flex paths need a synthetic wrapper box.
// Ordinary SVG presentation belongs on the replaced image itself.
if ( 'svg' === $tagName && ($flexBox || $this->isProjectableFillSvg($imageMatches[0], $declarations)) ) {
$projected[] = $this->projectImageSelector($selector, $parsed, $context, true);
$this->pushScopedImageArm($projected, $selector, $this->projectImageSelector($selector, $parsed, $context, true), $context);
}
$mediaTextImages = array_values(array_filter(
$imageMatches,
Expand Down Expand Up @@ -2648,12 +2653,33 @@ private function projectAuthorImageSelectorPrelude(string $prelude, AuthorStyles
$projected[] = NativeImageLeafPresentation::selectors(':where(.' . $marker . ').wp-block-image', $this->selectorSpecificityShims($parsed, $context) . $suffix);
}
if ( $hasUnmarkedImage ) {
$projected[] = $this->projectImageSelector($selector, $parsed, $context);
$this->pushScopedImageArm($projected, $selector, $this->projectImageSelector($selector, $parsed, $context), $context);
}
}
return implode(',', array_values(array_unique($projected)));
}

/**
* Emits a projected image-rule arm only when every core block class it
* names is reached through an importer-emitted class as well. An arm that
* would stand on the bare core class is dropped and recorded instead of
* leaking onto blocks a host adds to the served page later. Ordinary
* image arms carry a zero-specificity factory scope, not a semantic marker.
*
* @param list<string> $projected
*/
private function pushScopedImageArm(array &$projected, string $authoredSelector, string $arm, AuthorStylesheetProjectionContext $context): void
{
if ( '' === $arm ) {
return;
}
if ( ! (new CoreSelectorScoping())->selectorIsScoped($arm) ) {
$context->evidence->recordUnscopedCoreSelectorDrop($authoredSelector, 'no_importer_class_for_core_image_projection');
return;
}
$projected[] = $arm;
}

private function projectMediaTextImagePrelude(string $prelude, AuthorStylesheetProjectionContext $context): string
{
$selectors = CssStylesheetTransformer::splitSelectorList($prelude);
Expand Down Expand Up @@ -3409,7 +3435,17 @@ private function projectImageSelector(string $selector, array $parsed, AuthorSty
);
}
}
$projected[] = $this->replaceSelectorSpans($selector, $replacements);
$arm = $this->replaceSelectorSpans($selector, $replacements);
if ( ! (new CoreSelectorScoping())->selectorIsScoped($arm) ) {
// The factory class is only a scope key, never a semantic
// subject. Replacing * with :where() keeps its zero weight
// and leaves the declaration on the same native image leaf.
$arm = str_replace('*.wp-block-image', ':where(.blocks-engine-image).wp-block-image', $arm);
if ( ! (new CoreSelectorScoping())->selectorIsScoped($arm) ) {
$arm = str_replace('.wp-block-image', ':where(.blocks-engine-image).wp-block-image', $arm);
}
}
$projected[] = $arm;
}
return implode(',', $projected);
}
Expand Down
141 changes: 141 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/CoreSelectorScoping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style;

use Automattic\BlocksEngine\PhpTransformer\Css\CssStylesheetTransformer;

/**
* Compile-time invariant: every emitted selector that targets a core block
* class must also carry an importer-emitted class somewhere in its compound or
* ancestor chain.
*
* Core block classes (`.wp-block-*`, `.has-*`) name classes WordPress core
* renders onto its own blocks as well as the ones this importer emits. A rule
* keyed only on those tokens leaks past the imported content and reaches
* blocks an author added to the served page later, so the compiler must either
* scope such rules with an importer-emitted class (engine markers, role
* classes, or source classes copied onto the markup) or drop the rule.
*
* Scope of the check: compiler-emitted and compiler-rewritten CSS. Pass-through
* authored CSS is projected through the author-selector pipeline before it is
* emitted, so by the time it reaches an asset it is either rewritten or copied
* with the authored classes it targets. Bare element selectors are out of
* scope: the importer only answers for the core class names it emits.
*
* The invariant is deliberately class-based and names the two core class
* families that mark whole blocks (`.wp-block-*`, `.has-*`); other core
* rendered classes (partials such as `.wp-block-search__button`, state classes
* such as `.is-style-logos-only`) are not block identity on their own and are
* acceptable as long as one of these families appears alongside an
* importer-emitted class.
*/
final class CoreSelectorScoping
{
/**
* Class families that mark core block identity in a selector.
*
* @var string
*/
private const CORE_CLASS_PATTERN = '/^(?:wp-block-|has-)/';

/**
* Matches a CSS class token inside a selector, tolerating escapes.
*
* @var string
*/
private const CLASS_TOKEN_PATTERN = '/\\.((?:\\\\.|[^\x00-\x7f]|[^\s.#:>+~,\[\]()\'"])+)/';

/** @var array<string, true> Stylesheets already proven scoped, by content hash. */
private static array $cleanStylesheets = array();

/** @var array<string, bool> Scoping verdict per selector text. */
private static array $selectorVerdicts = array();

/**
* @throws \RuntimeException When a rule targets a core block class without
* an importer-emitted class in the same compound or ancestor chain.
*/
public function assertScoped(string $css, string $origin): void
{
$violations = $this->violations($css);
if ( array() !== $violations ) {
throw new \RuntimeException(sprintf(
"Emitted CSS targets a core block class without an importer-emitted class in the selector chain (origin: %s):\n - %s",
$origin,
implode("\n - ", $violations)
));
}
}

/** @return list<string> Unscoped selectors found in the stylesheet. */
public function violations(string $css): array
{
// Identical stylesheets recur across the routes of one site; each
// distinct stylesheet is parsed once per process.
$key = hash('xxh128', $css);
if ( isset(self::$cleanStylesheets[$key]) ) {
return array();
}
$violations = array();
( new CssStylesheetTransformer() )->visitStyleRules($css, function (string $selector, string $body) use (&$violations): void {
foreach ( CssStylesheetTransformer::splitSelectorList($selector) as $single ) {
if ( ! $this->selectorIsScoped($single) ) {
$violations[] = $single;
}
}
});
if ( array() === $violations ) {
self::$cleanStylesheets[$key] = true;
}
return array_values(array_unique($violations));
}

/**
* A selector is scoped when no core block class in it stands without an
* importer-emitted class anywhere in the same compound or ancestor chain.
*/
public function selectorIsScoped(string $selector): bool
{
return self::$selectorVerdicts[$selector] ??= $this->computeSelectorIsScoped($selector);
}

private function computeSelectorIsScoped(string $selector): bool
{
$selectors = CssStylesheetTransformer::splitSelectorList($selector);
if ( null === $selectors ) {
return true;
}
foreach ( $selectors as $single ) {
$hasCoreClass = false;
$hasImporterClass = false;
foreach ( $this->classTokens($single) as $class ) {
if ( 1 === preg_match(self::CORE_CLASS_PATTERN, $class) ) {
$hasCoreClass = true;
} else {
$hasImporterClass = true;
}
}
if ( $hasCoreClass && ! $hasImporterClass ) {
return false;
}
}
return true;
}

/** @return list<string> */
private function classTokens(string $selector): array
{
$classes = array();
if ( ! preg_match_all(self::CLASS_TOKEN_PATTERN, $selector, $matches) ) {
return array();
}
foreach ( $matches[1] as $class ) {
$class = str_replace('\\', '', $class);
if ( '' !== $class ) {
$classes[] = $class;
}
}
return $classes;
}
}
Loading
Loading