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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ always offer the same palette and sizes.
- Change a token in `app.css`, not in `theme.json`: the root `theme.json` is
only the base (layout, fonts, block styles), and a slug defined there wins
over `@theme`.
- The font is the exception: Inter needs a `fontFace` declaration, which only
- The font is the exception: Inter (the variable font, `InterVariable.woff2`) needs a `fontFace` declaration, which only
`theme.json` can hold, so fonts are declared there and `vite.config.js` turns
their generation off (`disableTailwindFonts`). The same option exists for
colours, font sizes and radii.
Expand All @@ -32,6 +32,27 @@ always offer the same palette and sizes.
See [Theme.json and Vite Build Integration](https://pollora.dev/theming/theme-structure/)
for the details.

## Gutenberg design system

Every core block is styled in `theme.json` (`styles`: root, elements, blocks),
from the presets above only, so a paragraph, a quote, a table or a button look
the same in the editor and on the page. Block-specific touches that
`theme.json` has no property for (the quote's gradient border, the table's
rules) sit in each block's `css` field, which the editor loads too.

- Reference presets by the name WordPress prints: `5xl` becomes
`var(--wp--preset--font-size--5-xl)`. Never a Tailwind variable
(`var(--text-xl)`): it does not exist in the editor.
- A block's `css` takes one selector per rule; WordPress wraps it in
`:root :where(...)` and breaks on `a, b`.
- `app/Cms/StyleLayers.php` puts WordPress's CSS in cascade layers, declared at
the top of `app.css`: `theme, base, wp-core, wp, components, utilities`. The
block library and the global styles beat Tailwind's reset, and a Tailwind
class in a Blade template always beats them. A link in the templates that
should not look like a content link says so with `no-underline`.
- `php bin/tests/run.php` checks that every preset and custom variable the
styles use exists.

## Contributing

### Development Setup
Expand Down
98 changes: 98 additions & 0 deletions app/Cms/StyleLayers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace %theme_namespace%\Cms;

use Pollora\Attributes\Action;
use Pollora\Attributes\Filter;

/**
* Puts WordPress's own CSS in cascade layers, between Tailwind's reset and its utilities.
*
* WordPress prints its block library and the global styles built from
* theme.json outside any layer, and Tailwind keeps its reset and its utilities
* in layers. Unlayered CSS always wins, so `h2 { font-size }` from theme.json
* would beat `text-sm` on a heading of the Blade header, and the block
* library's `:where(.wp-block-button__link) { border-radius: 9999px }` would
* beat the theme.json button, whatever the specificity.
*
* The order becomes theme, base, wp-core (block library), wp (global styles),
* components, utilities: theme.json styles every Gutenberg block above the
* reset and above the block library, and a Tailwind class always wins over
* both. A layer's rank does not depend on where its CSS is printed, which
* matters since WordPress 7 prints block styles and global styles in the
* footer of a Blade page.
*
* Front end only: the editor has no Tailwind, and theme.json applies there as is.
*/
class StyleLayers
{
private const string ORDER = '@layer theme, base, wp-core, wp, components, utilities;';

/**
* Wrap the inline CSS of WordPress's style handles in their layer.
*/
#[Action('wp_print_styles', priority: 0)]
#[Action('wp_print_footer_scripts', priority: 0)]
public function layerInlineStyles(): void
{
if (is_admin()) {
return;
}

foreach (wp_styles()->registered as $handle => $style) {
$layer = $this->layerFor((string) $handle);

if ($layer === null || empty($style->extra['after']) || ! empty($style->extra['theme_layered'])) {
continue;
}

$style->extra['after'] = [self::ORDER."\n@layer {$layer} {\n".implode("\n", (array) $style->extra['after'])."\n}"];
$style->extra['theme_layered'] = true;
}
}

/**
* Load a linked WordPress stylesheet into its layer instead of as a bare <link>.
*/
#[Filter('style_loader_tag')]
public function layerLinkedStyle(string $tag, string $handle, string $href, string $media): string
{
$layer = $this->layerFor($handle);

if ($layer === null || is_admin()) {
return $tag;
}

$mediaQuery = in_array($media, ['', 'all'], true) ? '' : ' '.$media;

return sprintf(
"<style id=\"%s-css\">%s\n@import url(\"%s\") layer(%s)%s;</style>\n",
esc_attr($handle),
self::ORDER,
esc_url($href),
$layer,
$mediaQuery
);
}

/**
* The layer a WordPress style handle belongs to, or null to leave it alone.
*
* `core-block-supports` (the per-block layout and colours chosen in the
* editor) stays unlayered on purpose: it must win over the global styles.
*/
private function layerFor(string $handle): ?string
{
if ($handle === 'global-styles') {
return 'wp';
}

if ($handle === 'classic-theme-styles' || str_starts_with($handle, 'wp-block-')) {
return 'wp-core';
}

return null;
}
}
132 changes: 132 additions & 0 deletions bin/tests/contract.php
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,135 @@ function checkLoginConfig(): void
: $source.' is '.number_format($size).' bytes; the framework will not inline it';
});
}

/**
* WordPress's slug to CSS-variable conversion (_wp_to_kebab_case): `5xl` → `5-xl`.
*/
function wpKebab(string $slug): string
{
$slug = preg_replace('/([a-z])([A-Z])/', '$1-$2', $slug) ?? $slug;
$slug = preg_replace('/([0-9])([a-zA-Z])/', '$1-$2', $slug) ?? $slug;
$slug = preg_replace('/([a-zA-Z])([0-9])/', '$1-$2', $slug) ?? $slug;

return strtolower($slug);
}

/**
* @return array<string, list<string>> preset kind => CSS-variable slugs WordPress will print
*/
function designSystemPresets(array $themeJson): array
{
$css = (string) file_get_contents(themePath('resources/assets/css/app.css'));
$vite = (string) file_get_contents(themePath('vite.config.js'));
$presets = ['color' => [], 'font-size' => [], 'font-family' => [], 'border-radius' => [], 'spacing' => []];

if (preg_match('/@theme static\s*\{(.*?)\n\}/s', $css, $block)) {
preg_match_all('/--(color|text|radius|font)-([a-z0-9-]+?)\s*:/', $block[1], $tokens, PREG_SET_ORDER);

foreach ($tokens as [, $family, $slug]) {
if (str_contains($slug, '--')) {
continue;
}

$kind = ['color' => 'color', 'text' => 'font-size', 'radius' => 'border-radius', 'font' => 'font-family'][$family];

if ($kind === 'font-family' && str_contains($vite, 'disableTailwindFonts: true')) {
continue;
}

$presets[$kind][] = wpKebab($slug);
}
}

$settings = $themeJson['settings'] ?? [];
foreach ([
'color' => $settings['color']['palette'] ?? [],
'font-size' => $settings['typography']['fontSizes'] ?? [],
'font-family' => $settings['typography']['fontFamilies'] ?? [],
'border-radius' => $settings['border']['radiusSizes'] ?? [],
'spacing' => $settings['spacing']['spacingSizes'] ?? [],
] as $kind => $entries) {
foreach ($entries as $entry) {
$presets[$kind][] = wpKebab((string) ($entry['slug'] ?? ''));
}
}

return $presets;
}

/**
* @return list<string> the --wp--custom-- variables settings.custom defines
*/
function designSystemCustomVariables(array $custom, string $prefix = ''): array
{
$names = [];

foreach ($custom as $key => $value) {
$name = $prefix.'--'.wpKebab((string) $key);
$names = [...$names, ...(is_array($value) ? designSystemCustomVariables($value, $name) : [$name])];
}

return $names;
}

function checkDesignSystem(): void
{
section('Design system — theme.json styles point at what exists');

$themeJson = json_decode((string) file_get_contents(themePath('theme.json')), true);

test('theme.json parses', fn () => is_array($themeJson) ?: 'theme.json is not valid JSON');

if (! is_array($themeJson)) {
return;
}

$styles = json_encode($themeJson['styles'] ?? [], JSON_UNESCAPED_SLASHES);
$presets = designSystemPresets($themeJson);

test('Every preset the styles use is generated', function () use ($styles, $presets) {
preg_match_all('/var\(--wp--preset--(color|font-size|font-family|border-radius|spacing)--([a-z0-9-]+)\)/', $styles, $refs, PREG_SET_ORDER);
$missing = [];

foreach ($refs as [, $kind, $slug]) {
if (! in_array($slug, $presets[$kind], true)) {
$missing[] = "--wp--preset--{$kind}--{$slug}";
}
}

// WordPress prints `5xl` as `5-xl`: a reference to the raw slug is silently ignored.
return $missing === [] ? true : 'no such preset, the value is dropped: '.implode(', ', array_unique($missing));
});

test('Every custom variable the styles use is defined', function () use ($styles, $themeJson) {
$defined = designSystemCustomVariables($themeJson['settings']['custom'] ?? []);
preg_match_all('/var\(--wp--custom(--[a-z0-9-]+)\)/', $styles, $refs);
$missing = array_diff(array_unique($refs[1]), $defined);

return $missing === [] ? true : 'not in settings.custom: '.implode(', ', $missing);
});

test('The styles use no Tailwind-only variable', function () use ($styles) {
// The editor loads theme.json, not app.css: var(--text-xl) is undefined there.
preg_match_all('/var\(--(?!wp--)[a-z][a-z0-9-]*/', $styles, $refs);

return $refs[0] === [] ? true : 'undefined in the editor: '.implode(', ', array_unique($refs[0]));
});

test('No block css uses a selector list', function () use ($themeJson) {
$broken = [];

foreach ($themeJson['styles']['blocks'] ?? [] as $block => $style) {
foreach (explode('}', (string) ($style['css'] ?? '')) as $rule) {
$selector = explode('{', $rule)[0];

// WordPress wraps each selector in :root :where(…) and breaks on `a, b`.
if (str_contains(preg_replace('/\([^)]*\)/', '', $selector) ?? $selector, ',')) {
$broken[] = $block.': '.trim($selector);
}
}
}

return $broken === [] ? true : 'one rule per selector: '.implode('; ', $broken);
});
}
1 change: 1 addition & 0 deletions bin/tests/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
checkViewReferences();
checkNoLeakedCodeName();
checkLoginConfig();
checkDesignSystem();
checkSyntax();

exit(summary());
Loading
Loading