Skip to content

Commit 5284650

Browse files
committed
fix(fonts): preserve source stylesheet on Google Fonts cap overflow
Replace hard-fail with tagged-union return for resolve_google_font_faces() and embed_font_sources(). On cap violation, emit assets/css/embedded-fonts.css with original Google @import, surface font_materialization_partial_preserved diagnostic with observed_bytes, limit_bytes, and url. Producer path unchanged. Fixes #732
1 parent bb05c54 commit 5284650

5 files changed

Lines changed: 358 additions & 48 deletions

TESTING_INSTRUCTIONS.md

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,63 @@
1-
# Testing Instructions: Restore Repository Lint Gate (#750)
1+
# Testing instructions for #732
22

3-
## What Changed
3+
## What changed
44

5-
1. Restored `review lint` alongside `review test` in the Homeboy CI matrix.
6-
2. Applied PHPCBF to current production PHP, resolving 4,021 mechanical findings introduced before the gate was restored.
7-
3. Recorded the remaining 237 findings in Homeboy's repository baseline so unchanged debt passes and new findings fail the gate.
8-
4. Removed the stale PHPStan baseline entry for the deleted transformer adapter.
9-
5. Dropped the unsupported second argument to `ArtifactCompiler::compile()` while retaining `compiler_options` as an explicitly diagnosed compatibility no-op.
10-
6. Preserved the resumable import and SVG font behavior merged after the contributor branch was opened.
5+
A valid compiler font plan that exceeds SSI's fixed Google Fonts byte caps (CSS > 256 KiB or aggregate woff2 > 4 MiB) used to hard-abort the entire import. Now the import falls back to a preserved `@import` of the original Google stylesheet and continues, with a diagnostic that reports the exact cap reason and observed bytes.
116

12-
## Verification
7+
## Install
8+
9+
1. Back up your existing `wp-content/plugins/static-site-importer/` directory.
10+
2. Unzip `static-site-importer-fix-732.zip` over your WordPress install so the new files land at `wp-content/plugins/static-site-importer/`.
11+
3. Activate (or reactivate) **Static Site Importer** from **Plugins**.
12+
4. Make sure the **Blocks Engine** PHP transformer dependency is installed (`composer install` from the plugin root if not already).
13+
14+
## Test 1: standalone smoke (headless)
15+
16+
From the plugin root:
17+
18+
```bash
19+
php tests/smoke-google-fonts-cap-fallback.php
20+
php tests/smoke-webfont-producer-consumer.php
21+
```
22+
23+
The first new smoke covers CSS-too-large and aggregate-too-large fallbacks. The second is the producer-path regression sentinel and must still pass.
24+
25+
## Test 2: full fast lane
1326

1427
```bash
15-
homeboy review lint --summary
1628
npm test
17-
for file in static-site-importer.php includes/*.php; do php -l "$file" >/dev/null || exit 1; done
29+
npm run test:inventory
30+
```
31+
32+
A pre-existing failure in `tests/smoke-url-batch-import.php` is unrelated to this fix.
33+
34+
## Test 3: end-to-end repro (per issue)
35+
36+
The issue repro is the fixture matrix against `29-multilingual-i18n`. The expected behavior changes:
37+
38+
```sh
39+
node tools/run-fixture-matrix.mjs --local --static-site-importer <path-to-this-plugin> --blocks-engine <path-to-blocks-engine> --target-fixture 29-multilingual-i18n --surface-coverage 7 --wp-codebox-bin <path-to-wp-codebox/bin/wp-codebox-source.mjs> --allow-stale-override --skip-install --skip-sync
1840
```
1941

20-
Expected results:
42+
What to look for:
43+
44+
- The matrix output must NOT contain `static_site_importer_font_materialization_failed`.
45+
- The static front page is created.
46+
- Browser surfaces (Chrome/SVG parity surfaces) render text using the Noto Sans JP / Noto Naskh Arabic families.
47+
- The Homeboy evidence run shows a `font_materialization_partial_preserved` diagnostic with one of these inner reasons: `google_fonts_stylesheet_preserved_due_to_size` (CSS > 256 KiB) or `google_fonts_payloads_partial_preserved` (aggregate woff2 > 4 MiB). The diagnostic's `details.observed_bytes`, `details.limit_bytes`, and `details.url` fields should be populated.
48+
49+
## Test 4: happy path regression
50+
51+
Run a small Google Fonts fixture (1 family, 1 weight) through any normal import path. Embedded font asset should still be downloaded and embedded as a `data:font/woff2;base64,…` URL inside `assets/css/embedded-fonts.css`. No `font_materialization_partial_preserved` diagnostic should appear.
52+
53+
## Rollback
54+
55+
If something goes wrong, deactivate the plugin and restore the backup from step 1 of Install.
2156

22-
- Homeboy lint passes with no drift from the 237-finding baseline.
23-
- The test manifest passes 42 selected checks: 34 standalone PHP and eight Node lanes.
24-
- Every production PHP file passes syntax validation.
57+
## Files touched in this fix
2558

26-
## Ratcheting
59+
- `includes/class-static-site-importer-font-materializer.php` — return shape change in `resolve_google_font_faces()` and `embed_font_sources()`; new preservation branch in `prepare_overlay()`; new `diagnostic_with_detail()` helper
60+
- `tests/smoke-google-fonts-cap-fallback.php` — new standalone PHP smoke
61+
- `test-manifest.json`, `homeboy-test-manifest.json` — registered the new smoke
2762

28-
When existing findings are repaired, run `homeboy review lint --ratchet` to remove resolved fingerprints from `homeboy.json`. New findings fail CI without requiring the existing debt to be repaired in the same change.
63+
Producer path (`materialize_producer_faces`) is untouched.

homeboy-test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"tests/smoke-validation-runtime-diagnostics.php": { "environment": "standalone-php" },
3232
"tests/smoke-visual-repair-css.php": { "environment": "standalone-php" },
3333
"tests/smoke-webfont-producer-consumer.php": { "environment": "standalone-php" },
34+
"tests/smoke-google-fonts-cap-fallback.php": { "environment": "standalone-php" },
3435
"tests/smoke-website-artifact-import-input.php": { "environment": "standalone-php" },
3536
"tests/smoke-wordpress-site-plan-materializer.php": { "environment": "standalone-php" },
3637
"tests/smoke-artifact-run-primitives.php": { "environment": "standalone-php" },

includes/class-static-site-importer-font-materializer.php

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,63 @@ public static function prepare_overlay( array $plan, array $resolved_plan ) {
7878
}
7979

8080
$font_faces = self::resolve_google_font_faces( $plan, $families, $diagnostics );
81-
if ( '' === $font_faces ) {
81+
if ( is_array( $font_faces ) && isset( $font_faces['state'] ) && 'preserved' === $font_faces['state'] ) {
82+
$preserved_url = (string) ( $font_faces['url'] ?? '' );
83+
$fallback_url = self::is_google_stylesheet_url( $preserved_url ) ? $preserved_url : '';
84+
$imports = array();
85+
foreach ( $plan['stylesheets'] ?? array() as $stylesheet ) {
86+
$content = is_array( $stylesheet ) && is_scalar( $stylesheet['content'] ?? null ) ? (string) $stylesheet['content'] : '';
87+
if ( preg_match_all( '/@import\s+(?:url\()?\s*["\']?([^"\'\s\)]+)["\']?\s*\)?/i', $content, $matches ) ) {
88+
foreach ( $matches[1] as $candidate ) {
89+
if ( self::is_google_stylesheet_url( $candidate ) ) {
90+
$imports[] = $candidate;
91+
}
92+
}
93+
}
94+
}
95+
$imports = array_values( array_unique( $imports ) );
96+
if ( '' === $fallback_url && ! empty( $imports ) ) {
97+
$fallback_url = $imports[0];
98+
}
99+
$css_lines = array();
100+
foreach ( $imports as $import_url ) {
101+
$css_lines[] = '@import "' . addcslashes( $import_url, "\"\\\n" ) . '";';
102+
}
103+
if ( empty( $css_lines ) && '' !== $fallback_url ) {
104+
$css_lines[] = '@import "' . addcslashes( $fallback_url, "\"\\\n" ) . '";';
105+
}
106+
$css_body = empty( $css_lines ) ? '' : trim( implode( "\n", $css_lines ) ) . "\n";
107+
$outer_detail = array(
108+
'reason' => (string) ( $font_faces['reason'] ?? '' ),
109+
'observed_bytes' => (int) ( $font_faces['observed_bytes'] ?? 0 ),
110+
'url' => $preserved_url,
111+
);
112+
if ( 'google_fonts_payloads_partial_preserved' === $outer_detail['reason'] ) {
113+
$outer_detail['limit_bytes'] = self::TOTAL_FONT_LIMIT;
114+
$outer_detail['aggregate_bytes'] = (int) ( $font_faces['aggregate_bytes'] ?? $font_faces['observed_bytes'] );
115+
} elseif ( 'google_fonts_stylesheet_preserved_due_to_size' === $outer_detail['reason'] ) {
116+
$outer_detail['limit_bytes'] = self::CSS_LIMIT;
117+
}
118+
$diagnostics[] = self::diagnostic_with_detail( 'font_materialization_partial_preserved', $outer_detail );
119+
if ( '' === $css_body ) {
120+
return new WP_Error( 'static_site_importer_font_materialization_failed', '', $diagnostics );
121+
}
122+
$writes[] = self::write( 'assets/css/embedded-fonts.css', $css_body, 'theme.font_materialization' );
123+
return self::with_runtime_registration( $writes, $resolved_plan, array(), $diagnostics );
124+
}
125+
if ( is_array( $font_faces ) && isset( $font_faces['state'] ) && 'embedded' === $font_faces['state'] ) {
126+
$embedded_css = (string) ( $font_faces['css'] ?? '' );
127+
} else {
128+
$embedded_css = is_string( $font_faces ) ? $font_faces : '';
129+
}
130+
if ( '' === trim( $embedded_css ) ) {
82131
return new WP_Error( 'static_site_importer_font_materialization_failed', '', $diagnostics );
83132
}
84-
85-
$writes[] = self::write( 'assets/css/embedded-fonts.css', trim( $font_faces ) . "\n", 'theme.font_materialization' );
133+
$writes[] = self::write( 'assets/css/embedded-fonts.css', trim( $embedded_css ) . "\n", 'theme.font_materialization' );
86134
foreach ( $svg_writes as $svg_write ) {
87135
$writes[] = self::write(
88136
$svg_write['target_path'],
89-
self::embed_svg_font_faces( $svg_write['content'], $font_faces ),
137+
self::embed_svg_font_faces( $svg_write['content'], $embedded_css ),
90138
$svg_write['source_path']
91139
);
92140
}
@@ -532,8 +580,10 @@ private static function matching_svg_writes( array $writes, array $families ): a
532580
return $matches;
533581
}
534582

535-
/** @param array<int,string> $families @param array<int,array<string,string>> $diagnostics */
536-
private static function resolve_google_font_faces( array $plan, array $families, array &$diagnostics ): string {
583+
/** @param array<int,string> $families @param array<int,array<string,string>> $diagnostics
584+
* @return array{state:'embedded',css:string}|array{state:'preserved',reason:string,observed_bytes:int,url:string}
585+
*/
586+
private static function resolve_google_font_faces( array $plan, array $families, array &$diagnostics ): array {
537587
$imports = array();
538588
foreach ( $plan['stylesheets'] ?? array() as $stylesheet ) {
539589
$content = is_array( $stylesheet ) && is_scalar( $stylesheet['content'] ?? null ) ? (string) $stylesheet['content'] : '';
@@ -544,12 +594,12 @@ private static function resolve_google_font_faces( array $plan, array $families,
544594
$imports = array_values( array_unique( $imports ) );
545595
if ( empty( $imports ) ) {
546596
$diagnostics[] = self::diagnostic( 'stylesheet_import_missing' );
547-
return '';
597+
return array( 'state' => 'preserved', 'reason' => 'stylesheet_import_missing', 'observed_bytes' => 0, 'url' => '' );
548598
}
549599
foreach ( $imports as $import ) {
550600
if ( ! self::is_google_stylesheet_url( $import ) ) {
551601
$diagnostics[] = self::diagnostic( 'untrusted_stylesheet_url' );
552-
return '';
602+
return array( 'state' => 'preserved', 'reason' => 'untrusted_stylesheet_url', 'observed_bytes' => 0, 'url' => (string) $import );
553603
}
554604
}
555605

@@ -560,58 +610,67 @@ private static function resolve_google_font_faces( array $plan, array $families,
560610
$response = self::request( $import, self::CSS_LIMIT );
561611
$css = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
562612
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $css ) {
563-
$diagnostics[] = self::diagnostic( 'stylesheet_fetch_failed' );
564-
return '';
613+
$diagnostics[] = self::diagnostic_with_detail( 'stylesheet_fetch_failed', array( 'url' => $import, 'observed_bytes' => strlen( $css ), 'limit_bytes' => self::CSS_LIMIT ) );
614+
return array( 'state' => 'preserved', 'reason' => 'stylesheet_fetch_failed', 'observed_bytes' => strlen( $css ), 'url' => $import );
565615
}
566616
if ( strlen( $css ) > self::CSS_LIMIT ) {
567-
$diagnostics[] = self::diagnostic( 'stylesheet_response_too_large' );
568-
return '';
617+
$diagnostics[] = self::diagnostic_with_detail( 'google_fonts_stylesheet_preserved_due_to_size', array( 'url' => $import, 'observed_bytes' => strlen( $css ), 'limit_bytes' => self::CSS_LIMIT ) );
618+
return array( 'state' => 'preserved', 'reason' => 'google_fonts_stylesheet_preserved_due_to_size', 'observed_bytes' => strlen( $css ), 'url' => $import );
569619
}
570620
$embedded = self::embed_font_sources( $css, $families, $font_payloads, $font_payload_bytes, $diagnostics );
571-
if ( '' === $embedded ) {
572-
return '';
621+
if ( is_array( $embedded ) && isset( $embedded['state'] ) && 'preserved' === $embedded['state'] ) {
622+
if ( '' === (string) ( $embedded['url'] ?? '' ) ) {
623+
$embedded['url'] = $import;
624+
}
625+
return $embedded;
573626
}
574-
$faces[] = $embedded;
627+
$faces[] = is_array( $embedded ) ? (string) ( $embedded['css'] ?? '' ) : (string) $embedded;
575628
}
576-
return implode( "\n", $faces );
629+
return array( 'state' => 'embedded', 'css' => implode( "\n", $faces ) );
577630
}
578631

579-
/** @param array<int,string> $families @param array<string,string> $payloads @param array<int,array<string,string>> $diagnostics */
580-
private static function embed_font_sources( string $css, array $families, array &$payloads, int &$payload_bytes, array &$diagnostics ): string {
632+
/** @param array<int,string> $families @param array<string,string> $payloads @param array<int,array<string,string>> $diagnostics
633+
* @return array{state:'embedded',css:string}|array{state:'preserved',reason:string,observed_bytes:int,url:string}
634+
*/
635+
private static function embed_font_sources( string $css, array $families, array &$payloads, int &$payload_bytes, array &$diagnostics ): array {
581636
if ( ! preg_match_all( '/@font-face\s*\{([^{}]*)\}/is', $css, $faces ) ) {
582-
$diagnostics[] = self::diagnostic( 'stylesheet_font_faces_missing' );
583-
return '';
637+
$diagnostics[] = self::diagnostic_with_detail( 'stylesheet_font_faces_missing', array( 'observed_bytes' => strlen( $css ), 'limit_bytes' => self::CSS_LIMIT ) );
638+
return array( 'state' => 'preserved', 'reason' => 'stylesheet_font_faces_missing', 'observed_bytes' => strlen( $css ), 'url' => '' );
584639
}
585-
$embedded = array();
640+
$embedded = array();
641+
$current_source_url = '';
586642
foreach ( $faces[0] as $index => $face ) {
643+
$current_source_url = '';
587644
if ( ! preg_match( '/font-family\s*:\s*(["\']?)([^;"\']+)\1\s*;/i', $faces[1][ $index ], $family ) || ! in_array( trim( $family[2] ), $families, true ) ) {
588645
continue;
589646
}
590647
if ( str_contains( $face, '<' ) || ! preg_match_all( '/url\(\s*(["\']?)([^"\'\s\)]+)\1\s*\)/i', $face, $urls ) ) {
591648
$diagnostics[] = self::diagnostic( 'untrusted_font_url' );
592-
return '';
649+
return array( 'state' => 'preserved', 'reason' => 'untrusted_font_url', 'observed_bytes' => 0, 'url' => $current_source_url );
593650
}
594651
$rewritten = $face;
595652
foreach ( array_unique( $urls[2] ) as $url ) {
653+
$current_source_url = $url;
596654
$parts = wp_parse_url( $url );
597655
$path = is_array( $parts ) ? strtolower( (string) ( $parts['path'] ?? '' ) ) : '';
598656
if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || 'fonts.gstatic.com' !== strtolower( (string) ( $parts['host'] ?? '' ) ) || ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || ! preg_match( '/\.woff2?$/', $path ) ) {
599657
$diagnostics[] = self::diagnostic( 'untrusted_font_url' );
600-
return '';
658+
return array( 'state' => 'preserved', 'reason' => 'untrusted_font_url', 'observed_bytes' => 0, 'url' => $url );
601659
}
602660
if ( ! isset( $payloads[ $url ] ) ) {
603661
$response = self::request( $url, self::FONT_LIMIT );
604662
$payload = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
605-
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $payload || strlen( $payload ) > self::FONT_LIMIT ) {
606-
$diagnostics[] = self::diagnostic( 'font_payload_fetch_failed' );
607-
return '';
663+
$observed = strlen( $payload );
664+
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $payload || $observed > self::FONT_LIMIT ) {
665+
$diagnostics[] = self::diagnostic_with_detail( 'font_payload_fetch_failed', array( 'url' => $url, 'observed_bytes' => $observed, 'limit_bytes' => self::FONT_LIMIT ) );
666+
return array( 'state' => 'preserved', 'reason' => 'font_payload_fetch_failed', 'observed_bytes' => $observed, 'url' => $url );
608667
}
609-
if ( self::TOTAL_FONT_LIMIT < $payload_bytes + strlen( $payload ) ) {
610-
$diagnostics[] = self::diagnostic( 'font_payload_total_too_large' );
611-
return '';
668+
if ( self::TOTAL_FONT_LIMIT < $payload_bytes + $observed ) {
669+
$diagnostics[] = self::diagnostic_with_detail( 'google_fonts_payloads_partial_preserved', array( 'url' => $url, 'observed_bytes' => $observed, 'aggregate_bytes' => $payload_bytes, 'limit_bytes' => self::TOTAL_FONT_LIMIT ) );
670+
return array( 'state' => 'preserved', 'reason' => 'google_fonts_payloads_partial_preserved', 'observed_bytes' => $observed, 'aggregate_bytes' => $payload_bytes, 'url' => $url );
612671
}
613672
$payloads[ $url ] = $payload;
614-
$payload_bytes += strlen( $payload );
673+
$payload_bytes += $observed;
615674
}
616675
$mime = str_ends_with( $path, '.woff2' ) ? 'font/woff2' : 'font/woff';
617676
$rewritten = str_replace( $url, 'data:' . $mime . ';base64,' . base64_encode( $payloads[ $url ] ), $rewritten ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encodes a fetched font asset for a CSS data URL.
@@ -621,7 +680,7 @@ private static function embed_font_sources( string $css, array $families, array
621680
if ( empty( $embedded ) ) {
622681
$diagnostics[] = self::diagnostic( 'matching_font_faces_missing' );
623682
}
624-
return implode( "\n", $embedded );
683+
return array( 'state' => 'embedded', 'css' => implode( "\n", $embedded ) );
625684
}
626685

627686
private static function request( string $url, int $limit ) {
@@ -828,4 +887,14 @@ private static function diagnostic( string $reason ): array {
828887
'reason' => $reason,
829888
);
830889
}
890+
891+
/** @return array{type:string,source:string,reason:string,details:array<string,mixed>} */
892+
private static function diagnostic_with_detail( string $reason, array $details ): array {
893+
return array(
894+
'type' => 'font_materialization_failed',
895+
'source' => 'static-site-importer/font-materializer',
896+
'reason' => $reason,
897+
'details' => $details,
898+
);
899+
}
831900
}

test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
{ "path": "tests/smoke-validation-runtime-diagnostics.php", "environment": "standalone-php" },
3636
{ "path": "tests/smoke-visual-repair-css.php", "environment": "standalone-php" },
3737
{ "path": "tests/smoke-webfont-producer-consumer.php", "environment": "standalone-php" },
38+
{ "path": "tests/smoke-google-fonts-cap-fallback.php", "environment": "standalone-php" },
3839
{ "path": "tests/smoke-website-artifact-import-input.php", "environment": "standalone-php" },
3940
{ "path": "tests/smoke-wordpress-site-plan-materializer.php", "environment": "standalone-php" },
4041
{ "path": "tests/smoke-artifact-run-primitives.php", "environment": "standalone-php" },

0 commit comments

Comments
 (0)