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) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 16:10:13 +02:00
co-authored by Claude Opus 5
parent 70c983b7bb
commit 0a028b158f
3 changed files with 38 additions and 10 deletions
+13 -8
View File
@@ -137,24 +137,29 @@ class ErrorPage
/** /**
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's * `$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 * 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 * `@font-face` outside comments and strings, then remove the balanced `{ … }` that follows it,
* follows it, brace for brace, however the block itself is written. `tokens/font.css`'s is the * brace for brace, however the block itself is written. Both reads go through
* only one the bundle carries, and it is also the bundle's only relative `url()` (button.css, * `Stylesheets::mask()`, because the bundle's comments name the rule too (foundation/base.css's
* icon.css, shape.css and the rest reference nothing on disk), so this removal is also what * and error-page.css's headers): read as the rule, such a mention cut from inside its comment to
* leaves the fallback with no `url()` to a font the fallback cannot serve. The typeface itself * 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`, * 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 * `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. * it the browser skips straight to that system stack — nothing here has to name one.
*/ */
protected static function withoutFontFace(string $css): string protected static function withoutFontFace(string $css): string
{ {
$masked = Stylesheets::mask($css);
$result = ''; $result = '';
$offset = 0; $offset = 0;
$length = strlen($css); $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); $result .= substr($css, $offset, $start - $offset);
$open = strpos($css, '{', $start); $open = strpos($masked, '{', $start);
if ($open === false) { if ($open === false) {
$offset = $start + strlen('@font-face'); $offset = $start + strlen('@font-face');
@@ -166,7 +171,7 @@ class ErrorPage
$i = $open + 1; $i = $open + 1;
while ($i < $length && $depth > 0) { while ($i < $length && $depth > 0) {
$depth += match ($css[$i]) { $depth += match ($masked[$i]) {
'{' => 1, '{' => 1,
'}' => -1, '}' => -1,
default => 0, default => 0,
+3 -2
View File
@@ -230,9 +230,10 @@ final class Stylesheets
/** /**
* `$css` with every comment and quoted string blinded to spaces (newlines kept), same length — * `$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 * 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; $masked = $css;
$length = strlen($css); $length = strlen($css);
+22
View File
@@ -406,3 +406,25 @@ it('inlines beside a build the error layout\'s bundle, which holds no font face,
->not->toMatch('/\burl\(/i') ->not->toMatch('/\burl\(/i')
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;'); ->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] {');
});