diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e63b662..7519202 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index 747c076..63694c4 100644 --- a/README.md +++ b/README.md @@ -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/`: diff --git a/app/Cms/StyleLayers.php b/app/Cms/StyleLayers.php new file mode 100644 index 0000000..a4d554d --- /dev/null +++ b/app/Cms/StyleLayers.php @@ -0,0 +1,98 @@ +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 . + */ + #[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( + "\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; + } +} diff --git a/bin/tests/design-system.php b/bin/tests/design-system.php new file mode 100644 index 0000000..11393de --- /dev/null +++ b/bin/tests/design-system.php @@ -0,0 +1,168 @@ +#!/usr/bin/env php +> 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 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); diff --git a/resources/assets/css/app.css b/resources/assets/css/app.css index 6904cd0..bcdbfbc 100644 --- a/resources/assets/css/app.css +++ b/resources/assets/css/app.css @@ -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"; diff --git a/resources/views/page.blade.php b/resources/views/page.blade.php index f0c7c24..7acc668 100644 --- a/resources/views/page.blade.php +++ b/resources/views/page.blade.php @@ -32,7 +32,7 @@ @if ($show_title)

@title

@endif -
+
@content
diff --git a/resources/views/parts/content-page.blade.php b/resources/views/parts/content-page.blade.php index 343f7eb..9fc70f9 100644 --- a/resources/views/parts/content-page.blade.php +++ b/resources/views/parts/content-page.blade.php @@ -10,7 +10,7 @@ {!! post_thumbnail() !!} -
+
@content {!! wp_link_pages([ 'before' => '