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

Trac 59165: Add Font Face and its tests #5051

Closed
wants to merge 15 commits into from

Conversation

hellofromtonya
Copy link
Contributor

@hellofromtonya hellofromtonya commented Aug 22, 2023

Adds the new Font Face, a server-side @font-face styles generator and printer. Code is copied from Gutenberg.

Trac ticket: https://core.trac.wordpress.org/ticket/59165

How to Test

Scenario 1 uses the Twenty Twenty-Three (TT3) theme. Testing instructions are provided here in the Trac ticket.

Scenario 2 is a classic site with a plugin that passes an array of fonts when invoking wp_print_font_faces(). Testing instructions are provided here in the Trac ticket.

For Code Reviewers

Font Face was developed in Gutenberg. The vast majority of its code comes from well-established font processing developed in multiple iterations of the Webfonts, Web Fonts, and Fonts API. You can find:

Changes made in this PR will be copied / merged back into Gutenberg.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@hellofromtonya hellofromtonya marked this pull request as ready for review August 22, 2023 22:15
@hellofromtonya
Copy link
Contributor Author

hellofromtonya commented Aug 23, 2023

Need to merge WordPress/gutenberg#53805 to use wp_get_global_settings() instead of the private API. Commit will be pushed shortly.

Update: Done ✅ via e1da7ab.

Props @oandregal.

Changes the resolver to use wp_get_global_settings() instead of
the chained objects to get the merged data layer.

Reference:
WordPress/gutenberg#53805

Props @oandregal.
null === $wp_font_face ||

// Ignore cache when automated test suites are running.
( defined( 'WP_RUN_CORE_TESTS' ) && WP_RUN_CORE_TESTS )
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The intent:

  • Use a new instance of WP_Font_Face for each PHPUnit test. Why? To avoid test-to-test interactions by ensuring a new object is used for each test.

  • Else, it acts as a singleton whether in WP_DEBUG, prod, or development modes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Do the tests pass with a combination of @runInSeparateProcess and @preserveGlobalState disabled in the test docblocks?

If so, while it would slow down the tests, it removes the need for this test-related source code and separates implemention from the test suite. It may be worth considering if it hasn't been already.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All of the Font Face tests would require running in their own separate processes. What would the test performance impact be?

  • The code now: 00:05.447
  • Running in separate processes: 00:36.257

That's fairly significant for 20 tests. Ouch.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whoopsie, I was wrong. Only the global function tests (i.e. for wp_print_font_faces()) need to run in a separate process, not all of the tests.

Re-running the fonts group tests with the wp_print_font_faces() tests in separate processes: 00:16.807

Adds about 11 seconds with the current tests, i.e. more unhappy tests are coming.

What do you think @costdev?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Colin and I chatted in Slack. Commit #5051 runs the tests in separate processes.

Results: adds ~11 seconds to the run time of the test suites 😢 , but removes the test constant from the source code 😄 .

* @return array Returns the font-families, each with their font-face variations.
*/
public static function get_fonts_from_theme_json() {
$settings = wp_get_global_settings();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@oandregal I merged in WordPress/gutenberg#53805 and updated to use Core's wp_get_global_settings().

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@hellofromtonya Mostly the PR looks solid to me. I have a few points of feedback below.

One broader nit-pick, I'm not sure about the file organization of the new files, I think all the nested folders are unnecessary and don't fit well with the way core typically organizes files. I would instead advise the following structure:

  • wp-includes/fonts directory for the new classes (no additional subdirectories)
  • wp-includes/fonts.php file for the global function(s)

src/wp-includes/fonts/font-face/class-wp-font-face.php Outdated Show resolved Hide resolved
src/wp-includes/fonts/font-face/class-wp-font-face.php Outdated Show resolved Hide resolved

// Check the font-family.
if ( empty( $font_face['font-family'] ) || ! is_string( $font_face['font-family'] ) ) {
trigger_error( 'Font font-family must be a non-empty string.' );
Copy link
Member

Choose a reason for hiding this comment

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

Why is this class using trigger_error() (here and below)? Core should always use the appropriate wrapper functions, such as _doing_it_wrong(), so that e.g. WP_DEBUG is respected.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change comes from @azaozz, who advise only using _doing_it_wrong() for the most severe cases. Since then, trigger_error() has been used for things like function/method parameter (input) validation. It exists in Core for things like PHP compat changes.

An architectural proposal for handling input validation (i.e. function/method parameter checks) is in the works which will also include new error handling.

Also noting the code being replaced also used trigger_error() for these same validation checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note: Here's a Trac ticket that proposes introducing wp_trigger_error() to compliment _doing_it_wrong() https://core.trac.wordpress.org/ticket/57686. @azaozz shares reasoning of why not use _doing_it_wrong() for these purposes:

The _doing_it_wrong() function was introduced 12 years ago .. for "telling people the code they have written won't work"...

However that convenience has resulted in shifting the initial purpose, which was to tell developers they are doing something wrong. Lately _doing_it_wrong() seems to be used in any context, not specifically to "tell off" the developers and get them to fix their code.

Copy link
Member

Choose a reason for hiding this comment

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

@hellofromtonya Thank you for the additional context. I may be on board with a new function like wp_trigger_error(), but since that is not in place yet, I think the usage here should rather rely on _doing_it_wrong(), since in those cases it literally is the developer "doing it wrong" rather than something else leading to the problem.

As you mention, trigger_error() is currently mostly used in Core for things that deal with PHP compat, which is not the case here, and calling trigger_error() ignores WP_DEBUG etc constants which is the main reason I object to this change.

I think we should use _doing_it_wrong(), and then later we can update it to use wp_trigger_error() once that function has been added to Core (potentially immediately as part of https://core.trac.wordpress.org/ticket/57686).

Copy link
Contributor

@azaozz azaozz Aug 25, 2023

Choose a reason for hiding this comment

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

calling trigger_error() ignores WP_DEBUG

Yea, lets just add wp_trigger_error(). I know there are ideas to expand/extend its use in the future, but seems is it needed now :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea, lets just add wp_trigger_error(). I know there are ideas to expand/extend its use in the future, but seems is it needed now :)

Sorry for my delay in responding (been sick).

Thanks @felixarntz and @azaozz. I think you both make good points.

  • I moved Trac 57686 back into the 6.4 milestone to add wp_trigger_error(). I agree with you @azaozz that it can be modified in the future when it's time to implement input (function/method parameter) validation.

  • For this PR as a compromise, I'll switch trigger_error() to _doing_it_wrong() with a comment to replace it with wp_trigger_error().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in commit e695f6f

src/wp-includes/fonts/fonts.php Outdated Show resolved Hide resolved
src/wp-includes/fonts/font-face/class-wp-font-face.php Outdated Show resolved Hide resolved
@hellofromtonya
Copy link
Contributor Author

One broader nit-pick, I'm not sure about the file organization of the new files, I think all the nested folders are unnecessary and don't fit well with the way core typically organizes files. I would instead advise the following structure:

wp-includes/fonts directory for the new classes (no additional subdirectories)
wp-includes/fonts.php file for the global function(s)

Hello @felixarntz 👋 Thank you for your review!

The thinking around the file organization is to group the different fonts management components in sub-directories, which as of today is Font Face and Font Library. There's a mix of approaches in Core, though predominately there is as you note a tendency to group all like files in the "group" folder, rather than sub directories.

I'm okay with either approach. When Font Library lands, it would also go into the wp-includes/fonts/ directory too.

@hellofromtonya
Copy link
Contributor Author

@felixarntz commit e2db981 relocates the wp-includes/fonts/font-face files into its root wp-includes/fonts/ per your suggestion ✅

@felixarntz
Copy link
Member

@hellofromtonya Thank you for the updates, also for updating the file/folder structure. What about moving the functions file fonts.php directly into wp-includes, i.e. wp-includes/fonts.php? I think that ties in better with how Core organizes files, it is common to have general global function files in there and then specific classes for a feature in a directory. For example, that is similarly handled for the REST API, which uses wp-includes/rest-api.php for functions and the wp-includes/rest-api directory for classes.

Copy link
Contributor

@costdev costdev left a comment

Choose a reason for hiding this comment

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

Nothing major, just a few thoughts/queries in this review.

I'll get this tested today. 🙂

Comment on lines 333 to 334
// Wrap font-family in quotes if it contains spaces
// and is not already wrapped in quotes.
Copy link
Contributor

@costdev costdev Aug 25, 2023

Choose a reason for hiding this comment

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

Suggested change
// Wrap font-family in quotes if it contains spaces
// and is not already wrapped in quotes.
/*
* Wrap font-family in quotes if it contains spaces
* and is not already wrapped in quotes.
*/

See the handbook entry for how Core formats multi-line comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @costdev! Done in 4a1d4c2

}

// Validate the 'src' property.
if ( ! empty( $font_face['src'] ) ) {
Copy link
Contributor

@costdev costdev Aug 25, 2023

Choose a reason for hiding this comment

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

The previous lines ensure that this can't be empty by now. Suggest removing this condition.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @costdev! Done in 4a1d4c2


// Check the font-display.
if ( ! in_array( $font_face['font-display'], $this->valid_font_display, true ) ) {
$font_face['font-display'] = $this->font_face_property_defaults['font-display'];
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be helpful to trigger an error or _doing_it_wrong() here to indicate an invalid font-display value.

Copy link
Contributor Author

@hellofromtonya hellofromtonya Aug 30, 2023

Choose a reason for hiding this comment

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

Hmm, it's not a doing it wrong, as an invalid value will use the default instead and the processing continues (unlike the other validation above where validation fails and it bails out).

Looking at Core, other loosely similar checks do not inform the developer either. That makes me wonder .. Should Core alert that it is using a default value if the given value fails its validation? Hmm, interesting 🤔. It might be helpful to developers to help them debug.

If this were to throw a notice, likely should wrap that notice in empty( trim () ) to avoid the notice on an empty string value.

As this isn't a _doing_it_wrong() and wp_trigger_error() does not yet exist in Core, this helper message could be added later once wp_trigger_error() is introduced. It's definitely worth a good think and discussion.

// Remove invalid properties.
foreach ( $font_face as $prop => $value ) {
if ( ! in_array( $prop, $this->valid_font_face_properties, true ) ) {
unset( $font_face[ $prop ] );
Copy link
Contributor

Choose a reason for hiding this comment

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

See above regarding indicating invalid properties/values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @costdev! Done in 4a1d4c2

Comment on lines 108 to 111
printf(
$this->get_style_element(),
$this->get_css( $fonts )
);
Copy link
Contributor

Choose a reason for hiding this comment

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

As $fonts can come from anywhere, we should escape here to avoid evils slipping through from the ::get_css() callstack - happy to discuss details in DM.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, as the CSS string is within the style element, the nefarious code would need to inject </style> before its evil code. Alrighty, then tags need to be stripped from the CSS string to ensure that doesn't happen. wp_strip_all_tags() should be sufficient.

BTW: This is the approach used in TT0 and TT1 for their CSS auto-generator.

For example, here's TT1:

function twenty_twenty_one_generate_css( $selector, $style, $value, $prefix = '', $suffix = '', $display = true ) {

	// Bail early if there is no $selector elements or properties and $value.
	if ( ! $value || ! $selector ) {
		return '';
	}

	$css = sprintf( '%s { %s: %s; }', $selector, $style, $prefix . $value . $suffix );

	if ( $display ) {
		/*
		 * Note to reviewers: $css contains auto-generated CSS.
		 * It is included inside <style> tags and can only be interpreted as CSS on the browser.
		 * Using wp_strip_all_tags() here is sufficient escaping to avoid
		 * malicious attempts to close </style> and open a <script>.
		 */
		echo wp_strip_all_tags( $css ); // phpcs:ignore WordPress.Security.EscapeOutput
	}
	return $css;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a test for it.

Using this data:

$fonts = array(
	'Source Serif Pro' => array(
		array(
			'src'          => array( 'http://example.com/assets/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ),
			'font-family'  => 'Source Serif Pro',
			'font-style'   => 'normal',
			'font-weight'  => '200 900',
			'font-stretch' => '</style><script>console.log("Hello")</script><style>',
		),
	),
);

Before wp_strip_all_tags( $css ):

<style id='wp-fonts-local' type='text/css'>
@font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-display:fallback;src:url('http://example.com/assets/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');font-stretch:</style><script>console.log("Hello")</script><style>;}
</style>

After wp_strip_all_tags( $css ):

<style id='wp-fonts-local' type='text/css'>
@font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-display:fallback;src:url('http://example.com/assets/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');font-stretch:;}
</style>

Excellent catch @costdev 👏

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added wp_strip_all_tags( $css ) in the source code via commit e90b21d

Added a test for it in commit b4bcd07

$settings = wp_get_global_settings();

// Bail out early if there are no font settings.
if ( empty( $settings['typography'] ) || empty( $settings['typography']['fontFamilies'] ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't the second condition cover the first?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, the second condition is all that's needed as it'll catch both conditions https://3v4l.org/b6UYn.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 4a1d4c2

* @since 6.4.0
*
* @param array $font_face Font face properties to validate.
* @return false|array Validated font-face on success. Else, false.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* @return false|array Validated font-face on success. Else, false.
* @return array|false Validated font-face on success. Otherwise false.
  • Swapped return types. You pessimist! 😂
  • "Otherwise false" is Core's usual phrasing.
  • Also, since this validates both properties and values, and returns the validated font face on success rather than valid properties, does it make more sense to call this validate_font_face()?

Copy link
Contributor Author

@hellofromtonya hellofromtonya Aug 30, 2023

Choose a reason for hiding this comment

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

Good catch on the swapped return types. Whoopsie.

"Otherwise false" is Core's usual phrasing.

Hmm, there's a mixture of phrasing in Core:

  • . Otherwise false
  • [the success], false on failure.
  • , otherwise false.'
  • , or false on failure.

I think I'll change it to the last one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 4a1d4c2

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, since this validates both properties and values, and returns the validated font face on success rather than valid properties, does it make more sense to call this validate_font_face()?

I think that method name would be confusing due to:

  • The font-face array contains a collection of @font-face property / value pairings that relate directly to the CSS.
  • The method above it called validate_fonts().

properties here is a verbose attempt to infer property / value pairs.

Other more accurate names could be:

@costdev Do you find properties to be confusing? Is either of the above more understandable?

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the clarification!

I think properties while verbose was also a bit vague as the code validates properties and values.

validate_font_face_declarations() makes more sense to me as it encompasses both, so there's no surprises when seeing the internal code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in commit c8e4c03

Also updated the DocBlock to ensure "declaration" is well understood.

}

// Remove invalid properties.
foreach ( $font_face as $prop => $value ) {
Copy link
Contributor

@costdev costdev Aug 25, 2023

Choose a reason for hiding this comment

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

Nit: IMO, there's no need to abbreviate to "prop" here. "property" and "properties" are used in documentation, as well as property and method names throughout.

Note the coding standards on naming conventions:

Don’t abbreviate variable names unnecessarily; let the code be unambiguous and self-documenting.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @costdev! Done in 4a1d4c2

}

/**
* Gets the `<style>` element for wrapping the `@font-face` CSS.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Gets the `<style>` element for wrapping the `@font-face` CSS.
* Gets the style element for wrapping the font-face CSS.

The documentation standards state:

No HTML markup or Markdown of any kind should be used in the summary. If the text refers to an HTML element or tag, then it should be written as “image tag” or “img” element, not “< img >“. (spaces added by me)
Reference

🔢 Applies elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @costdev! Done in 4a1d4c2

Copy link

@anton-vlasenko anton-vlasenko left a comment

Choose a reason for hiding this comment

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

Please find my notes below.
Thank you.

src/wp-includes/deprecated.php Show resolved Hide resolved
src/wp-includes/deprecated.php Show resolved Hide resolved
* @param array[][] $fonts Optional. The font-families and their font variations.
* See {@see wp_print_font_faces()} for the supported fields.
* Default empty array.
* }

Choose a reason for hiding this comment

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

Nitpick: this curly brace doesn't seem to be necessary here.

Suggested change
* }
*

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you @anton-vlasenko! Done in 4a1d4c2

Choose a reason for hiding this comment

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

Thank you!

1. Uses _doing_it_wrong() until wp_trigger_error() is available.
2. Adds comment to each with `@todo` annotation as a reminder to replace these later.
Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@hellofromtonya This looks solid to me now. Other than the oustanding minor quirks outlined by other reviewers, I don't have any blockers on this one. Excited to see it land!

It would be great to do a performance comparison for how this does compared to the old private code, just to ensure there's no unexpected regression.

Copy link
Contributor

@costdev costdev left a comment

Choose a reason for hiding this comment

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

Thanks for the updates @hellofromtonya! This looks good to me!

@hellofromtonya
Copy link
Contributor Author

Committed via https://core.trac.wordpress.org/changeset/53282. Thank you everyone for your contributions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Status: Done
Development

Successfully merging this pull request may close these issues.

5 participants