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
7 changes: 7 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ jobs:
fi
echo "requirements.json names packages the current skeleton can resolve"

# theme.json styles every core block. WordPress drops a style whose
# variable does not exist without a word: a preset named by its raw slug
# (5xl where WordPress prints 5-xl), a Tailwind variable the editor never
# loads, a block css selector list it breaks apart.
- name: The design system points at what exists
run: php bin/tests/design-system.php

sweep:
name: Sweep (framework ${{ matrix.framework }})
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ 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 design tokens only, so a paragraph, a quote, a table or a button look
the same in the editor and on the page. Content is no longer styled by
Tailwind Typography (`prose`); product descriptions still are.

- Reference presets by the name WordPress prints: `2xl` becomes
`var(--wp--preset--font-size--2-xl)`. Never a Tailwind variable: it does not
exist in the editor. A block's `css` takes one selector per rule.
- Links are styled inside content blocks (paragraph, list, table, verse), not
globally, so WooCommerce and the templates keep their own link styles.
- `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 always beats them.
- `php bin/tests/design-system.php` (run in CI) checks that every preset and
custom variable the styles use exists.

## Configuration

All theme behavior is driven by config files in `config/`:
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;
}
}
168 changes: 168 additions & 0 deletions bin/tests/design-system.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
* The Gutenberg design system in theme.json points at what exists.
*
* Usage: php bin/tests/design-system.php
*
* WordPress drops a style whose value is an undefined variable, silently: a
* preset referenced by its raw slug (`5xl` where WordPress prints `5-xl`), a
* Tailwind variable (`var(--text-xl)`, undefined in the editor), or a block
* `css` rule written as a selector list, which WordPress breaks apart. None of
* it errors; the block just looks wrong in the editor or on the page.
*/

$root = dirname(__DIR__, 2);
$failures = 0;

function check(string $name, bool|string $result): void
{
global $failures;

if ($result === true) {
echo " \033[32m✓\033[0m {$name}\n";

return;
}

$failures++;
echo " \033[31m✗\033[0m {$name} — {$result}\n";
}
/**
* 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($GLOBALS['root'].'/resources/assets/css/app.css');
$vite = (string) file_get_contents($GLOBALS['root'].'/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
{

$themeJson = json_decode((string) file_get_contents($GLOBALS['root'].'/theme.json'), true);

check('theme.json parses', 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);

check('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));
})());

check('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);
})());

check('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]));
})());

check('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);
})());
}

echo "\n\033[1m=== Design system — theme.json styles point at what exists ===\033[0m\n";
checkDesignSystem();
echo $failures === 0 ? "\n\033[32mAll passed.\033[0m\n" : "\n\033[31m{$failures} failed.\033[0m\n";
exit($failures === 0 ? 0 : 1);
7 changes: 7 additions & 0 deletions resources/assets/css/app.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/*
* WordPress's block library and theme.json styles sit between Tailwind's reset
* and its utilities (app/Cms/StyleLayers.php): theme.json styles every block,
* a utility class still wins in the Blade templates.
*/
@layer theme, base, wp-core, wp, components, utilities;

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Expand Down
2 changes: 1 addition & 1 deletion resources/views/page.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
@if ($show_title)
<h1 class="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">@title</h1>
@endif
<div class="{{ $show_title ? 'mt-6' : '' }} prose max-w-none">
<div class="{{ $show_title ? 'mt-6' : '' }} entry-content is-layout-flow">
@content
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/parts/content-page.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

{!! post_thumbnail() !!}

<div class="entry-content">
<div class="entry-content is-layout-flow">
@content
{!! wp_link_pages([
'before' => '<div class="page-links">'.esc_html__('Pages:', '%theme_name%'),
Expand Down
2 changes: 1 addition & 1 deletion resources/views/parts/content.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
@endif
</header>
{!! post_thumbnail() !!}
<div class="entry-content prose max-w-none">
<div class="entry-content is-layout-flow">
{{-- the_content(), not get_the_content(): blocks are rendered by the
the_content filter, so without it a dynamic block renders nothing. --}}
@php(the_content(sprintf(
Expand Down
Loading
Loading