From 0a028b158fb8b1afdb871ddde291184c67d83122 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 16:10:13 +0200 Subject: [PATCH] Skip a font face named in a comment when trimming the fallback Found while doing plan step 46: withoutFontFace() searched the raw bundle, so the `@font-face` that foundation/base.css's and error-page.css's header comments mention read as the rule. Each cut from inside its comment to the end of the next block and left the comment open, so the no-build fallback lost base.css's html rule and icon.css's rules. The search and the brace count now read Stylesheets::mask(), and only tokens/font.css's real block leaves. Co-Authored-By: Claude Opus 5 (1M context) --- src/Support/ErrorPage.php | 21 +++++++++++++-------- src/Support/Stylesheets.php | 5 +++-- tests/Feature/ErrorPagesTest.php | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Support/ErrorPage.php b/src/Support/ErrorPage.php index 5f03baef..7e2963f7 100644 --- a/src/Support/ErrorPage.php +++ b/src/Support/ErrorPage.php @@ -137,24 +137,29 @@ class ErrorPage /** * `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's * text (fragile the moment a comment or a value is reworded) but a structural read — find - * `@font-face` outside nothing else looks at here, then remove the balanced `{ … }` that - * follows it, brace for brace, however the block itself is written. `tokens/font.css`'s is the - * only one the bundle carries, and it is also the bundle's only relative `url()` (button.css, - * icon.css, shape.css and the rest reference nothing on disk), so this removal is also what - * leaves the fallback with no `url()` to a font the fallback cannot serve. The typeface itself + * `@font-face` outside comments and strings, then remove the balanced `{ … }` that follows it, + * brace for brace, however the block itself is written. Both reads go through + * `Stylesheets::mask()`, because the bundle's comments name the rule too (foundation/base.css's + * and error-page.css's headers): read as the rule, such a mention cut from inside its comment to + * the end of the next balanced block, leaving the comment open over the rules after it — + * base.css's `html` rule and icon.css's rules left the fallback that way. `tokens/font.css`'s + * block is the only one the bundle carries, and it is also the bundle's only relative `url()` + * (button.css, icon.css, shape.css and the rest reference nothing on disk), so this removal is + * also what leaves the fallback with no `url()` to a font it cannot serve. The typeface itself * degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`, * `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve * it the browser skips straight to that system stack — nothing here has to name one. */ protected static function withoutFontFace(string $css): string { + $masked = Stylesheets::mask($css); $result = ''; $offset = 0; $length = strlen($css); - while (($start = stripos($css, '@font-face', $offset)) !== false) { + while (($start = stripos($masked, '@font-face', $offset)) !== false) { $result .= substr($css, $offset, $start - $offset); - $open = strpos($css, '{', $start); + $open = strpos($masked, '{', $start); if ($open === false) { $offset = $start + strlen('@font-face'); @@ -166,7 +171,7 @@ class ErrorPage $i = $open + 1; while ($i < $length && $depth > 0) { - $depth += match ($css[$i]) { + $depth += match ($masked[$i]) { '{' => 1, '}' => -1, default => 0, diff --git a/src/Support/Stylesheets.php b/src/Support/Stylesheets.php index f4ebbda7..3779d943 100644 --- a/src/Support/Stylesheets.php +++ b/src/Support/Stylesheets.php @@ -230,9 +230,10 @@ final class Stylesheets /** * `$css` with every comment and quoted string blinded to spaces (newlines kept), same length — * so a search on it for `@import` or `url(` never matches one written inside a comment or a - * string, while every offset still lines up with `$css` itself. + * string, while every offset still lines up with `$css` itself. Public for + * `ErrorPage::withoutFontFace()`, which searches a bundle for `@font-face` the same way. */ - private static function mask(string $css): string + public static function mask(string $css): string { $masked = $css; $length = strlen($css); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php index 875a3b44..22e5b849 100644 --- a/tests/Feature/ErrorPagesTest.php +++ b/tests/Feature/ErrorPagesTest.php @@ -406,3 +406,25 @@ it('inlines beside a build the error layout\'s bundle, which holds no font face, ->not->toMatch('/\burl\(/i') ->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;'); }); + +/** + * The bundle's comments name `@font-face` too (foundation/base.css's and error-page.css's headers). + * Read as the rule, each mention cut from inside its comment to the end of the next balanced block + * and left the comment open, so base.css's `html` rule and icon.css's rules went missing from the + * fallback. Only the real block may leave: what remains is the bundle, rule for rule, without it. + */ +it('drops only the real font face from the fallback, not the rules after a comment that names one', function () { + $files = [ + realpath(__DIR__.'/../../resources/css/foundation.css'), + realpath(__DIR__.'/../../resources/css/components/error-page.css'), + ]; + $withoutComments = fn (string $css): string => (string) preg_replace('~/\*.*?\*/~s', '', $css); + $bundle = Stylesheets::bundle($files); + + expect(substr_count($withoutComments($bundle), '@font-face'))->toBe(1) + ->and(substr_count($bundle, '@font-face'))->toBeGreaterThan(1) + ->and($withoutComments((string) ErrorPage::fallbackStyles())) + ->toStartWith($withoutComments((string) preg_replace('/@font-face\s*\{[^{}]*\}/', '', $bundle))) + ->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);/') + ->toContain('[data-md-icon] {'); +});