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 @@ -119,6 +119,7 @@
"php tests/unit/stylesheet-selector-budget.php",
"php tests/unit/admin-bar-accommodation.php",
"php tests/unit/author-cascade-layer-order.php",
"php tests/unit/wordpress-body-class-collision.php",
"php tests/unit/css-rule-analyzer.php",
"php tests/unit/css-selector-matcher.php",
"php tests/unit/static-css-cascade.php",
Expand Down
70 changes: 70 additions & 0 deletions php-transformer/src/ArtifactCompiler/WordPressCompatCss.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
*/
final class WordPressCompatCss
{
/**
* Template classes WordPress adds to `<body>` via `body_class()`. A source
* stylesheet that styles an element of the same name collides with them.
*
* @var array<int, string>
*/
private const WORDPRESS_BODY_CLASSES = array(
'archive', 'attachment', 'author', 'blog', 'category', 'date', 'error404',
'home', 'page', 'paged', 'privacy-policy', 'search', 'single', 'tag',
);

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

Expand All @@ -26,9 +37,68 @@ public function css(string $authoredCss, array $files, array $scriptContents): s
. $this->navigationAnchorCompatCss($authoredCss)
. $this->rootStartupClassCompatCss($authoredCss, $scriptContents)
. $this->responsiveRootCompatCss($authoredCss)
. $this->bodyClassCollisionCompatCss($authoredCss)
. $this->coreRuntimeCompatCss($authoredCss, $files);
}

/**
* WordPress stamps template classes such as `page`, `home`, and `search`
* onto `<body>`. A source class rule of the same name then applies to the
* document body as well as its own element, so a centered page frame pays
* its max-width and gutters twice and every line box narrows.
*
* Neutralize only the frame properties, only on `body`, and only for the
* reserved names WordPress owns.
*/
private function bodyClassCollisionCompatCss(string $css): string
{
$classes = array();
foreach ( $this->bodyClassCollisionRules($css) as $class ) {
$classes[$class] = true;
}
if ( array() === $classes ) {
return '';
}

$selectors = array();
foreach ( array_keys($classes) as $class ) {
$selectors[] = 'body.' . $class;
}

return "\n\n/* wp-compat: WordPress body template classes must not inherit source frame rules. */\n"
. implode(",\n", $selectors)
. ' { max-width:none!important;width:auto!important;padding-inline:0!important;margin-inline:0!important }';
}

/** @return array<int, string> */
private function bodyClassCollisionRules(string $css): array
{
$classes = array();
foreach ( $this->topLevelCssRules($css, true) as $rule ) {
if ( str_starts_with(trim($rule['selector']), '@') ) {
foreach ( $this->bodyClassCollisionRules($rule['body']) as $nested ) {
$classes[$nested] = true;
}
continue;
}
if ( ! preg_match('/(?:^|;)\s*(?:max-width|width|padding|padding-inline|padding-left|padding-right|margin|margin-inline)\s*:/i', $rule['body']) ) {
continue;
}
foreach ( $this->splitSelectorList($rule['selector']) as $selector ) {
// The rule scanner keeps preceding comments on the selector.
$selector = trim(preg_replace('#/\*.*?\*/#s', '', $selector) ?? $selector);
if ( ! preg_match('/^\.([A-Za-z_][A-Za-z0-9_-]*)$/', $selector, $match) ) {
continue;
}
if ( in_array(strtolower($match[1]), self::WORDPRESS_BODY_CLASSES, true) ) {
$classes[$match[1]] = true;
}
}
}

return array_keys($classes);
}

/**
* WordPress owns the body element, so a source body's responsive class is
* not available to disable a captured desktop-only root minimum width.
Expand Down
57 changes: 57 additions & 0 deletions php-transformer/tests/unit/wordpress-body-class-collision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);

/**
* WordPress stamps template classes onto <body> via body_class(). A source
* stylesheet that centers `.page` then applies its frame to the body as well
* as its own element, paying max-width and gutters twice.
*/

require dirname(__DIR__, 2) . '/vendor/autoload.php';

use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\WordPressCompatCss;

$failures = 0;
$passes = 0;
$assert = static function (bool $condition, string $message) use (&$failures, &$passes): void {
if ( $condition ) {
++$passes;
return;
}
++$failures;
fwrite(STDERR, 'FAIL: ' . $message . PHP_EOL);
};

$compat = static fn (string $css): string => ( new WordPressCompatCss() )->css($css, array(), array());
$neutralizes = static fn (string $css, string $class): bool => str_contains($compat($css), 'body.' . $class . ' {');

$frame = $compat('.page {max-width:44rem;margin:0 auto;padding:5rem 2rem 6rem}');
$assert(str_contains($frame, 'body.page {'), 'a centered page frame neutralizes the WordPress body template class');
$assert(
str_contains($frame, 'max-width:none!important')
&& str_contains($frame, 'padding-inline:0!important')
&& str_contains($frame, 'margin-inline:0!important')
&& str_contains($frame, 'width:auto!important'),
'the body reset covers the frame properties that double the source gutters'
);
$assert(! str_contains($frame, 'color'), 'the body reset carries no paint, only frame geometry');

$assert($neutralizes('@media (min-width:60rem){.page{padding:0 3rem}}', 'page'), 'a responsive frame rule still reaches the body reset');
$assert($neutralizes('.home{max-width:70rem;padding-inline:2rem}', 'home'), 'other reserved template classes are covered');
$assert($neutralizes('.search{width:60rem}', 'search'), 'a reserved class sizing itself is covered');

$assert(! $neutralizes('.page{color:red;font-size:1rem}', 'page'), 'a reserved class without frame geometry is left alone');
$assert(! $neutralizes('.card{max-width:40rem;padding:0 2rem}', 'card'), 'an unreserved source class is left alone');
$assert(! $neutralizes('main.page{max-width:60rem;padding:0 2rem}', 'page'), 'an element-qualified frame cannot match body and is left alone');
$assert(! $neutralizes('.page .inner{max-width:60rem}', 'page'), 'a descendant frame rule is left alone');
$assert('' === trim($compat('')), 'an empty stylesheet emits no compatibility CSS');

$multiple = $compat('.page{max-width:44rem;padding:0 2rem}.home{margin-inline:auto;max-width:70rem}');
$assert(str_contains($multiple, 'body.page') && str_contains($multiple, 'body.home'), 'every colliding template class is listed once in the reset');
$assert(1 === substr_count($multiple, 'WordPress body template classes'), 'colliding classes share a single reset rule');

if ( 0 < $failures ) {
fwrite(STDERR, "WordPress body-class collision tests: {$passes} passed, {$failures} FAILED" . PHP_EOL);
exit(1);
}
fwrite(STDOUT, "WordPress body-class collision tests: {$passes} passed" . PHP_EOL);
Loading