Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve page experience for fonts in core themes #6674

Merged
merged 22 commits into from
Nov 30, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0be7466
The 'font-display' value replaced from 'swap' to 'optional'
bartoszgadomski Nov 2, 2021
dfbf616
Preload link added for all font files
bartoszgadomski Nov 2, 2021
df8fbc2
Test case for font files preloading added
bartoszgadomski Nov 3, 2021
5d282ce
Fix theme case to not execute assertions if a given theme is not inst…
bartoszgadomski Nov 3, 2021
1ab2a12
Reuse already parsed URL value and only preload first externalized da…
westonruter Nov 3, 2021
a5e6f6d
Harden collection of font sources
westonruter Nov 3, 2021
c5de528
Use data provider for test_font_files_preloading
westonruter Nov 3, 2021
4e0403f
Fonts preloading rules improved
bartoszgadomski Nov 9, 2021
503aa47
Fix failing tests due to font-display:optional
bartoszgadomski Nov 9, 2021
3eda2bc
Logic for 'font-display' property and fonts preloading improved
bartoszgadomski Nov 10, 2021
49c1103
Use LinkManager instead of AMP_DOM_Utils::create_node
bartoszgadomski Nov 10, 2021
009333b
Update request destination parameter
bartoszgadomski Nov 10, 2021
74ef48b
Merge branch 'develop' of github.com:ampproject/amp-wp into fix/6036-…
westonruter Nov 16, 2021
4c5b586
Use font-display:block for font icon instead of font-display:auto
westonruter Nov 16, 2021
97f8497
Improve PHP CSS Parser API usage
westonruter Nov 16, 2021
7c6afe7
Add failing test case for no preload link being added when the first …
westonruter Nov 16, 2021
e02b96a
Font preload logic improved
bartoszgadomski Nov 17, 2021
95c7da2
Add another failing test to ensure preload links are added when parse…
westonruter Nov 17, 2021
6c16113
Eliminate redundant code
westonruter Nov 17, 2021
773395e
Only catch exceptions specific to php-css-parser when parsing
westonruter Nov 17, 2021
b3b6579
Ensure font preloads are added for cached stylesheets
westonruter Nov 17, 2021
96b748b
Bust stylesheet cache group
westonruter Nov 17, 2021
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
97 changes: 73 additions & 24 deletions includes/sanitizers/class-amp-style-sanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use AmpProject\Dom\Element;
use AmpProject\Exception\FailedToGetFromRemoteUrl;
use AmpProject\RemoteGetRequest;
use AmpProject\RequestDestination;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\CSSList\CSSList;
use Sabberworm\CSS\Property\Selector;
Expand All @@ -29,6 +30,7 @@
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\Import;
use Sabberworm\CSS\CSSList\AtRuleBlockList;
use Sabberworm\CSS\Value\CSSFunction;
use Sabberworm\CSS\Value\RuleValueList;
use Sabberworm\CSS\Value\URL;
use Sabberworm\CSS\Value\Value;
Expand Down Expand Up @@ -139,6 +141,7 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer {
* @type bool $skip_tree_shaking Whether tree shaking should be skipped.
* @type bool $allow_excessive_css Whether to allow CSS to exceed the allowed max bytes (without raising validation errors).
* @type bool $transform_important_qualifiers Whether !important rules should be transformed. This also necessarily transform inline style attributes.
* @type string[] $font_face_display_overrides Array of the font family names and the font-display value they should each have.
* }
*/
protected $args;
Expand Down Expand Up @@ -166,6 +169,11 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer {
'skip_tree_shaking' => false,
'allow_excessive_css' => false,
'transform_important_qualifiers' => true,
'font_face_display_overrides' => [
'NonBreakingSpaceOverride' => 'optional',
'Inter var' => 'optional',
'Genericons' => 'auto',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After considering this more, I think block is the better value to explicitly say we don't want the font to render anything until it loads.

Suggested change
'Genericons' => 'auto',
'Genericons' => 'block',

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I read, browsers treat auto as “often similar” to block, but there seems some uncertainty.

],
];

/**
Expand Down Expand Up @@ -1655,6 +1663,7 @@ private function get_parsed_stylesheet( $stylesheet, $options = [] ) {
'parsed_cache_variant',
'dynamic_element_selectors',
'transform_important_qualifiers',
'font_face_display_overrides',
]
),
[
Expand Down Expand Up @@ -2519,7 +2528,6 @@ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) {
}

// Obtain the font-family name to guess the filename.
$font_family = null;
$font_basename = null;
$properties = $ruleset->getRules( 'font-family' );
if ( isset( $properties[0] ) ) {
Expand All @@ -2540,8 +2548,10 @@ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) {
$stylesheet_base_url = trailingslashit( $stylesheet_base_url );
}

// Define array of font files.
$font_files = [];

// Attempt to transform data: URLs in src properties to be external file URLs.
$converted_count = 0;
foreach ( $src_properties as $src_property ) {
$value = $src_property->getValue();
if ( ! ( $value instanceof RuleValueList ) ) {
Expand Down Expand Up @@ -2599,24 +2609,44 @@ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) {
* @var URL[] $source_data_url_objects
*/
$source_data_url_objects = [];
foreach ( $sources as $i => $source ) {
if ( $source[0] instanceof URL ) {
$value = $source[0]->getURL()->getString();
if ( 'data:' === substr( $value, 0, 5 ) ) {
$source_data_url_objects[ $i ] = $source[0];
} else {
$source_file_urls[ $i ] = $value;
}
foreach ( $sources as $source ) {
if ( count( $source ) !== 2 ) {
continue;
}
list( $url, $format ) = $source;
if (
! $url instanceof URL
||
! $format instanceof CSSFunction
||
$format->getName() !== 'format'
||
count( $format->getArguments() ) !== 1
) {
continue;
}

list( $format_value ) = $format->getArguments();
$format_value = trim( $format_value, '"\'' );

$value = $url->getURL()->getString();
if ( 'data:' === substr( $value, 0, 5 ) ) {
$source_data_url_objects[ $format_value ] = $source[0];
} else {
$source_file_urls[] = $value;
$font_files[] = $value;
}
}

// Convert data: URLs into regular URLs, assuming there will be a file present (e.g. woff fonts in core themes).
foreach ( $source_data_url_objects as $i => $data_url ) {
foreach ( $source_data_url_objects as $format => $data_url ) {
$mime_type = strtok( substr( $data_url->getURL()->getString(), 5 ), ';' );
if ( ! $mime_type ) {
continue;
if ( $mime_type ) {
$extension = preg_replace( ':.+/(.+-)?:', '', $mime_type );
} else {
$extension = $format;
}
$extension = preg_replace( ':.+/(.+-)?:', '', $mime_type );
$extension = sanitize_key( $extension );

$guessed_urls = [];

Expand Down Expand Up @@ -2648,7 +2678,7 @@ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) {
$path = $this->get_validated_url_file_path( $guessed_url, [ 'woff', 'woff2', 'ttf', 'otf', 'svg' ] );
if ( ! is_wp_error( $path ) ) {
$data_url->getURL()->setString( $guessed_url );
$converted_count++;
$font_files[] = $guessed_url;
continue 2;
}
}
Expand All @@ -2661,23 +2691,42 @@ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) {
'genericons.woff',
];
if ( in_array( $font_filename, $bundled_fonts, true ) ) {
$data_url->getURL()->setString( plugin_dir_url( AMP__FILE__ ) . "assets/fonts/$font_filename" );
$converted_count++;
$font_file = plugin_dir_url( AMP__FILE__ ) . "assets/fonts/$font_filename";
$data_url->getURL()->setString( $font_file );
$font_files[] = $font_file;
}
} // End foreach $source_data_url_objects.
} // End foreach $src_properties.

/*
* If a data: URL has been replaced with an external file URL, then we add a font-display:swap to the @font-face
* rule if one isn't already present. This prevents FO
*
* If no font-display is already present, add font-display:swap since the font is now being loaded externally.
/**
westonruter marked this conversation as resolved.
Show resolved Hide resolved
* Override the 'font-display' property to improve font performance.
*/
if ( $converted_count && 0 === count( $ruleset->getRules( 'font-display' ) ) ) {
if ( isset( $font_family ) && in_array( $font_family, array_keys( $this->args['font_face_display_overrides'] ), true ) ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more condition is missing here it seems, and that is if the first font URL in the list is a data: URL, then we must not add a preload.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failing test case added in 7c6afe7.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with e02b96a

$properties = $ruleset->getRules( 'font-display' );
if ( isset( $properties[0] ) ) {
$ruleset->removeRule( $properties[0] );
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$properties = $ruleset->getRules( 'font-display' );
if ( isset( $properties[0] ) ) {
$ruleset->removeRule( $properties[0] );
}
foreach ( $ruleset->getRules( 'font-display' ) as $rule ) {
$ruleset->removeRule( $rule );
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or rather just:

Suggested change
$properties = $ruleset->getRules( 'font-display' );
if ( isset( $properties[0] ) ) {
$ruleset->removeRule( $properties[0] );
}
$ruleset->removeRule( 'font-display' );


$font_display_rule = new Rule( 'font-display' );
$font_display_rule->setValue( 'swap' );
$font_display_rule->setValue( $this->args['font_face_display_overrides'][ $font_family ] );
$ruleset->addRule( $font_display_rule );
}

/**
westonruter marked this conversation as resolved.
Show resolved Hide resolved
* If the font-display is auto, block, or swap then we should automatically add the preload link for the first font file.
*/
$properties = $ruleset->getRules( 'font-display' );
if (
(
( isset( $properties[0] ) && in_array( $properties[0]->getValue(), [ 'auto', 'block', 'swap' ], true ) )
||
( ! isset( $properties[0] ) ) // Defaults to 'auto', hence should be preloaded as well.
)
&&
1 <= count( $font_files )
) {
$this->dom->links->addPreload( $font_files[0], RequestDestination::FONT );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good!

}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just read Prevent layout shifting and flashes of invisible text (FOIT) by preloading optional fonts which recommends adding preload links to optional fonts as well, although it says:

Although it is not necessary to preload an optional font, it greatly improves the chance for it to load before the first render cycle, especially if it is not yet stored in the browser's cache.

Nevertheless, adding a preload will introduce network congestion as resources compete for bandwidth. So if the font truly is optional, IMO it seems preferable to not preload it.

}

/**
Expand Down
89 changes: 86 additions & 3 deletions tests/php/test-amp-style-sanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -1898,20 +1898,103 @@ public function test_font_data_url_handling_without_file_sources() {
$this->assertStringNotContainsString( 'data:', $actual_stylesheets[0] );
$this->assertStringContainsString( 'fonts/NonBreakingSpaceOverride.woff2', $actual_stylesheets[0] );
$this->assertStringContainsString( 'fonts/NonBreakingSpaceOverride.woff', $actual_stylesheets[0] );
$this->assertStringContainsString( 'font-display:swap', $actual_stylesheets[0] );
$this->assertStringContainsString( 'font-display:optional', $actual_stylesheets[0] );

// Check font not included in theme, but included in plugin.
$this->assertStringContainsString( '@font-face{font-family:"Genericons";', $actual_stylesheets[1] );
$this->assertStringContainsString( 'format("woff")', $actual_stylesheets[1] );
$this->assertStringNotContainsString( 'data:', $actual_stylesheets[1] );
$this->assertStringContainsString( 'assets/fonts/genericons.woff', $actual_stylesheets[1] );
$this->assertStringContainsString( 'font-display:swap', $actual_stylesheets[1] );
$this->assertStringContainsString( 'font-display:auto', $actual_stylesheets[1] );

// Check font not included anywhere, so must remain inline.
$this->assertStringContainsString( '@font-face{font-family:"Custom";', $actual_stylesheets[2] );
$this->assertStringContainsString( 'url("data:application/x-font-woff;charset=utf-8;base64,d09GRgABAAA")', $actual_stylesheets[2] );
$this->assertStringContainsString( 'format("woff")', $actual_stylesheets[2] );
$this->assertStringNotContainsString( 'font-display:swap', $actual_stylesheets[2] );
$this->assertStringNotContainsString( 'font-display:', $actual_stylesheets[2] );
}

/** @return array */
public function get_data_to_test_font_files_preloading() {
return [
'twentynineteen' => [
'theme_slug' => 'twentynineteen',
'expected_urls' => [], // Twenty Nineteen theme uses "NonBreakingSpaceOverride" font which should use 'font-display:optional' property, thus should not be preloaded.
],
'twentytwenty' => [
'theme_slug' => 'twentytwenty',
'expected_urls' => [], // Twenty Twenty theme uses "Inter var" and "NonBreakingSpaceOverride" font which should use 'font-display:optional' property, thus should not be preloaded.
],
'twentytwentyone' => [
'theme_slug' => 'twentytwentyone',
'expected_urls' => [], // Twenty Twenty-One theme uses system font stack, no extra fonts are enqueued.
],
'custom_swap' => [
'theme_slug' => '',
'expected_urls' => [
'/fonts/OpenSans-Regular-webfont.woff2',
],
'html' => '<html amp><head><meta charset="utf-8"><style>@font-face{font-family:"Open Sans";src:url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");font-display:swap}</style></head><body></body></html>',
],
'custom_optional' => [
'theme_slug' => '',
'expected_urls' => [],
'html' => '<html amp><head><meta charset="utf-8"><style>@font-face{font-family:"Open Sans";src:url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");font-display:optional}</style></head><body></body></html>',
],
'custom_combined' => [
'theme_slug' => '',
'expected_urls' => [
'/fonts/OpenSans-Regular-webfont.woff2',
'/fonts/Lato-Regular-webfont.woff2',
],
'html' => '<html amp><head><meta charset="utf-8"><style>@font-face{font-family:"Open Sans";src:url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");font-display:swap}@font-face{font-family:"Roboto";src:url("/fonts/Roboto-Regular-webfont.woff2") format("woff2");font-display:optional}@font-face{font-family:"Lato";src:url("/fonts/Lato-Regular-webfont.woff2") format("woff2"),url("/fonts/Lato-Regular-webfont.woff") format("woff")}</style></head><body></body></html>',
],
];
}

/**
* Test that font files are preloaded with <link> element.
*
* @dataProvider get_data_to_test_font_files_preloading
* @covers AMP_Style_Sanitizer::process_font_face_at_rule()
*/
public function test_font_files_preloading( $theme_slug, $expected_urls, $html = '' ) {
if ( ! empty( $theme_slug ) ) {
$theme = new WP_Theme( $theme_slug, ABSPATH . 'wp-content/themes' );
if ( $theme->errors() ) {
$this->markTestSkipped( $theme->errors()->get_error_message() );
}

$html = '<html amp><head><meta charset="utf-8">';
$html .= sprintf( '<link rel="stylesheet" href="%s">', esc_url( $theme->get_stylesheet_directory_uri() . '/style.css' ) ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
$html .= '</head><body></body></html>';
}

$dom = Document::fromHtml( $html, Options::DEFAULTS );
$error_codes = [];
$sanitizer = new AMP_Style_Sanitizer(
$dom,
[
'use_document_element' => true,
]
);
$sanitizer->sanitize();
$this->assertEquals( [], $error_codes );

$link_elements = $dom->getElementsByTagName( 'link' );
$link_elements_count = $link_elements->length;

$this->assertEquals( count( $expected_urls ), $link_elements_count );

for ( $i = 0; $i < $link_elements_count; $i++ ) {
$this->assertStringEndsWith(
$expected_urls[ $i ],
$link_elements->item( $i )->getAttribute( 'href' )
);
$this->assertEquals( $link_elements->item( $i )->getAttribute( 'rel' ), 'preload' );
$this->assertEquals( $link_elements->item( $i )->getAttribute( 'as' ), 'font' );
$this->assertEquals( $link_elements->item( $i )->getAttribute( 'crossorigin' ), '' );
}
}

/**
Expand Down