WordPress.org

Make WordPress Core

Opened 15 months ago

Last modified 6 weeks ago

#46370 new feature request

A proposal for creating an API to register and enqueue web fonts

Reported by: jonoaldersonwp Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version:
Component: General Keywords:
Focuses: performance, privacy Cc:

Description (last modified by jonoaldersonwp)

The problem

Loading custom fonts in a sub-optimal manner can cause significant performance and privacy issues. Anecdotally, inefficient font loading is frequently one of the biggest performance bottlenecks in many WordPress themes/sites.

This is, in part, because there hasn’t been a ‘best practice’ approach; there’s no ‘standard’ way of adding a font to a theme and ensuring that it’s loaded in a performance- and privacy-friendly manner.

In almost all cases, theme/plugin developers either enqueue a third-party stylesheet (e.g., Google Fonts), or, bundle and enqueue the CSS for a local font. Both approaches cause problems. In particular:

  • Loading third-party resources can raise privacy concerns.
  • Loading fonts from external sources means that WordPress is often unable to optimize those fonts (no DNS prefetching, no intelligent browser prioritisation, no de-duplication, etc).
  • Bundling/enqueuing local fonts puts a strong reliance on the theme/plugin author understanding the complex nuances of efficient font-loading.

Even our own sites and setups (wordpress.org, the WordPress admin area, Gutenberg, and some of our core themes) fall afoul of these issues. They're slow, and they have privacy problems.

If we’re serious about WordPress becoming a fast, privacy-friendly platform, we can’t rely on theme developers to add and manage fonts without providing a framework to support them.

Why now?

It’s only recently that CSS and performance technologies/methodologies have matured sufficiently to define a ‘right way’ to load fonts.

In particular:

  • Best practices for defining and loading fonts via CSS are now well-established, stable, and see broad usage elsewhere.
  • The increasing adoption of HTTP/2 means that localising assets can (in many cases) be faster than loading them from remote sources.
  • There is increasing discomfort with WordPress 'passively endorsing' Google Fonts, which 'leaks' private information (IP addresses).

Now, standards like Google's Web Fundamentals documentation (which much of this spec is directly lifted and adapted from) describe a stable, compatible, and plug-and-play approach to loading fonts.

There are well-defied, standardised approaches to loading fonts, which we can implement into WordPress core. This will allow theme and plugin developers to bypass all of these pitfalls.

The vision

Theme developers shouldn’t have to manage their own font loading and optimisation. The registering and loading of fonts should be managed in the same way that we manage CSS and JavaScript - via abstraction, through an enqueue system.

Whilst fonts have more moving parts than script and style files, I believe that wp_enqueue_font() could become a robust, standardised way of adding custom fonts to themes without radical effort or change - with all of the advantages we’re used to from abstracting JS/CSS (conditional logic, dependency management, versioning/cache-busting, consolidation of duplicates, etc).

A move to an enqueue-based approach may also provide plugin developers with hooks to intercept the default behaviours, and to modify the output. E.g., to utilise the font loading API rather than outputting CSS. This provides a huge opportunity for caching and performance-optimising plugins to speed up WordPress sites/themes.

In its simplest form, wp_enqueue_font() provides a thin abstraction over existing WP mechanisms (wp_enqueue_style()), wp_add_inline_style(), etc), and simply enforces a performant, privacy-friendly implementation.

The ‘Web Fundamentals’ approach

The Web Fundamentals documentation provides an example of an optimal CSS structure for loading fonts. E.g.,

@font-face {
  font-family: 'Awesome Font';
  font-style: normal;
  font-weight: 400;
  font-display: fallback;
  src: local('Awesome Font'),
       url('/fonts/awesome-l.woff2') format('woff2'),
       url('/fonts/awesome-l.woff') format('woff'),
       url('/fonts/awesome-l.ttf') format('truetype'),
       url('/fonts/awesome-l.eot') format('embedded-opentype');
  unicode-range: U+000-5FF;
}

@font-face {
  font-family: 'Awesome Font';
  font-style: normal;
  font-weight: 700;
  font-display: fallback;
  src: local('Awesome Font'),
       url('/fonts/awesome-l-700.woff2') format('woff2'),
       url('/fonts/awesome-l-700.woff') format('woff'),
       url('/fonts/awesome-l-700.ttf') format('truetype'),
       url('/fonts/awesome-l-700.eot') format('embedded-opentype');
  unicode-range: U+000-5FF;
}

In this example, a single font (‘Awesome Font’) is being loaded, in two different weights (400 and 700). For each of those weights, multiple formats are provided, prioritised in such a way that considers and is optimized for browser support. The approach for different styles (e.g., italic), and all other variations, follow this pattern.

In addition to using an optimal CSS approach, the documentation recommends using a <link rel="preload"> directive (or equivalents JavaScript-based approaches). This prevents the browser from having to wait until the render tree is complete before downloading font resources.

Our aim is to enable all themes/plugins to achieve this type of approach and to output this approach (the optimized CSS, paired with a preload directive) without requiring developers to explicitly craft and maintain this code.

A spec

The process of enqueuing a font should allow a developer to specify the name of the font, and to provide an optional level of detail/control over specific aspects (versions, weights, file locations). Where details/specifics aren’t provided, sensible fallbacks and defaults should be assumed.

I suggest the following structure function definition:

<?php
/**
 * Enqueue font.
 *
 * @param string            $family The name of the font family.
 * @param string|array|bool $src    The source(s) of the font files.
 * @param array $params {
 *      Params.
 *
 *      @type string        $style     The style of the font.
 *      @type int           $weight    The weight of the font.
 *      @type string        $display   Display/swap behaviour.
 *      @type string|array  $variation Variation settings.
 *      @type string        $range     A unicode range value.
 *      @type string|bool   $external  Externalise the CSS file/code.
 *      @type bool          $preload   Should the resource be preloaded.
 *      @type bool          $in_footer Output the CSS/file in the footer.
 *      @type string|bool   $media     Apply a media query to the output.
 * }
 */
function wp_enqueue_font( $family, $src = false, $params = array() ) {
        $params = wp_parse_args(
                $params,
                array(
                        'style'                   => 'normal',
                        'weight'                  => 400,
                        'display'                 => 'fallback',
                        'font-variation-settings' => normal,
                        'range'                   => false,
                        'external'                => false,
                        'preload'                 => true,
                        'in_footer'               => false,
                        'media'                   => false
                )
        );

        // ...

Note that the args after $src are in an associative array to avoid having to supply positional params with default values and to make it easier to easily identify the parameters. It's worth considering that something similar could be done for wp_register_script() and wp_register_style().

Starting from simplicity

A simple implementation of this, which omits much of the detail and utilises default behaviours, might look something like:

<?php
wp_enqueue_font(
  'Awesome Font',
  '/fonts/awesome-font-400.woff2'
);

In this example, we’ve specified the bare minimum information required to enqueue a font - a family and a location. But there’s enough here that we can generate the following CSS:

@font-face {
  font-family: 'Awesome Font';
  font-style: normal;
  font-weight: 400;
  src: local('Awesome Font'),
       url('/fonts/awesome-font-400.woff2') format('woff2'),
}

We can simplify even further, though. If no $src value is defined, then a local src can still be referenced via the $family. Note that this will only work if the user's system has the font installed locally. E.g.:

wp_enqueue_font('Awesome Font');

Produces the following:

@font-face {
  font-family: 'Awesome Font';
  font-style: normal;
  font-weight: 400;
  src: local('Awesome Font');
}

Registering and Enqueue'ing

As with scripts and styles, we should allow for registration and enqueuing as separate processes.

I.e., wp_register_font() should accept the same parameters as wp_enqueue_font(). Filters/hooks should be introduced to enable theme/plugin authors to manipulate the behaviour of fonts between these stages.

Behind the scenes, there's some additional complexity here. Scripts and styles use a $handle to identify them uniquely. Fonts don't have an equivalent concept (multiple font variations may share the same family namespace), so we need to synthesize one. This will allow us to pass gracefully wrap functions like wp_enqueue_style().

To achieve this, we should combine the (sanitized) $family, $style, $weight and $media strings to create a unique representation; e.g., create_font_handle($family.$style.$weight.$media).

If multiple fonts are registered with the same handle, the last-enqueued version should take priority (overwriting previous enqueues).

De-regestering and de-queue'ing

As we've highlighted, the $handle is sometimes insufficient to represent a unique font (for the purposes of identification, registration, and conflict management).

This means that wp_dequeue_font() and wp_deregister_font() should accept an optional array of values in addition to the handle. E.g.,

/**
 * Dequeue font.
 *
 * @param string            $family The family of the font.
 * @param array|false       $params {
 *      Params.
 *
 *      @type string        $style     The style of the font.
 *      @type string        $weight    The weight of the font.
 *      @type string        $media     The media query for the font.
 * }
*/

If only a string is passed, all fonts matching that $family name should be removed. If an array is passed, then only fonts matching the family and the passed params should be dequeued/deregistered.

Definitions

Some of the properties we're using here are lifted directly from wp_enqueue_style(), and should be treated identically and passed directly through to that function (e.g., $in_footer, $media). Some are fairly self-descriptive, and shouldn't need any special consideration (e.g., $range).

The unique and more complex remaining properties are explored below.

$src

Whilst our $src variable can accept a simple string, more advanced usage should specify multiple versions and their formats. That looks like this (maintaining the rest of our ‘simplest implementation’ approach):

<?php
wp_enqueue_font(
  'Awesome Font',
  array(
    'woff2'             => '/fonts/awesome-font-400.woff2',
    'woff'              => '/fonts/awesome-font-400.woff',
    'truetype'          => '/fonts/awesome-font-400.ttf',
    'embedded-opentype' => '/fonts/awesome-font-400.eot',
  )
);
  • NOTE: If a type is invalid, the declaration should be ignored.
  • NOTE: Data URLs (e.g., data:application/font-woff2;charset=utf-8;base64[...]) may be provided instead of file paths.

The source values may be either a local or absolute URL (including remote URLs). However, this spec assumes (and prefers) that plugins and themes should generally store their font files locally; e.g., /wp-content/themes/{{theme_name}}/fonts/ or /wp-content/plugins/{{plugin_name}}/fonts/.

$variation

There's a maturing standard around 'variable fonts', which adds a 'font-variation-settings' property. This accepts a string of key/value pairs, which define attributes like the font's weight, slant, or other variations.

The $variation property accepts either a string (normal), or, an array of key/value pairs (e.g., ["wght" => 637, "wdth" => 100]), and returns a string of these values (e.g., wght 637, wdth 100).

$external

When the $external flag is false (which is the default behaviour), the generated CSS should be output in the <head>, via wp_add_inline_style() (unless $in_footer is set to true, in which case, the code should be output in a generated <style> tag hooked into wp_footer).

When $external is set to true, we should use wp_register_style() and wp_enqueue_style() to reference a procedurally generated CSS file containing the relevant CSS at /wp-content/fonts/$handle.css.

Because we can't rely on having write permission to that folder (or know that it even exists), we should _not_ try to create a physical file, but rather, utilise a URL rewrite similar to the approach used in do_robots() to serve /robots.txt.

Note that this assumes the standardisation of /wp-content/fonts/ as a protected space. New WordPress installations should generate this (empty) folder.

When $external is set to a string, we should use the string value as the endpoint to reference (e.g., /wp-content/plugins/{{some-plugin}}/fonts/custom-endpoint.css).

$preload

When the $preload flag is true (which is the default behaviour), wp_resource_hints* should be filtered to add a <link rel="preload"> directive for the most modern format font file in the $src array (usually woff2).

If $external is set to true, the associated CSS file should also be preloaded.

  • NOTE: The preload tag is currently unsupported by wp_resource_hints, and it's expected that another (similar) function may be introduced to support this. Discussion here.

Other considerations

Invalid values & minimum requirements

Invalid or malformed values for parameters with constrained values (e.g., $style, $weight, $display) should fall back to their defaults.

Invalid or malformed values for parameters without constrained values (e.g., $range, $src) should be ignored.

If this validation results in there being no values for $family and $src (which represents the bare minimum requirements), no CSS should be generated/output.

Hooks & filters

Filters/hooks should be introduced to enable theme/plugin authors to manipulate the behaviour of fonts between each of the stages we've defined - registration, enqueuing, and output (in various formats/locations).

These will need to be defined.

Next steps

There are lots of moving parts on this one, but I’m hoping that most of it is fairly straightforward. I’d love some feedback on (any gaps in / issues with) the spec.

I’m anticipating that we'll need a bunch of discussion, iteration, exploration and definition before it makes sense to start authoring any code on this one, but that said, it’d be super to see some of this start to take shape.

Change History (26)

#1 @ocean90
15 months ago

  • Summary changed from A proposal for creating wp_enqueue_font() to A proposal for creating an API to register and enqueue web fonts

#2 @ocean90
15 months ago

#46020 was marked as a duplicate.

#3 in reply to: ↑ description @jonoaldersonwp
15 months ago

Clarification: $src values should accept a local OR absolute URL, which allows for loading (and filtering, etc) of remotely hosted fonts, CDNs, etc.

Addition: We're hinting strongly from a privacy and performance basis that Google Fonts isn't a privacy-friendly or performance-friendly (without consideration and management - the kinds of which are often lacking, and which precipitated this spec). However, there's nothing stopping people from ignoring this function, and, continuing to / additionally loading Google Fonts resources.

This ticket was mentioned in Slack in #core-privacy by garrett-eclipse. View the logs.


15 months ago

#5 @jonoaldersonwp
15 months ago

*Update*: Props to @westonruter, swapped the $src array ordering, to expect type => loc.

#6 @westonruter
15 months ago

  • Description modified (diff)

#7 @westonruter
15 months ago

  • Description modified (diff)
Version 0, edited 15 months ago by westonruter (next)

This ticket was mentioned in Slack in #core-editor by jonoaldersonwp. View the logs.


14 months ago

This ticket was mentioned in Slack in #core-privacy by jonoaldersonwp. View the logs.


14 months ago

#10 @jonoaldersonwp
14 months ago

Note that, this assumes that as a user, I may still download fonts from foundries (e.g., Google Fonts) - where that's permissible -and use them as local versions through this approach.

This ticket was mentioned in Slack in #themereview by williampatton. View the logs.


14 months ago

#12 @westonruter
13 months ago

I've opened another incremental ticket with patch to implement Google Fonts new font-display capability: #47282.

This ticket was mentioned in Slack in #core-privacy by jonoaldersonwp. View the logs.


7 months ago

This ticket was mentioned in Slack in #core-privacy by jonoaldersonwp. View the logs.


6 months ago

This ticket was mentioned in Slack in #themereview by poena. View the logs.


6 months ago

This ticket was mentioned in Slack in #core by joostdevalk. View the logs.


6 months ago

This ticket was mentioned in Slack in #meta by jonoaldersonwp. View the logs.


6 months ago

This ticket was mentioned in Slack in #core by jonoaldersonwp. View the logs.


6 months ago

#21 @jonoaldersonwp
6 months ago

Just updated with some significant refinements and clarifications.

#22 @jonoaldersonwp
6 months ago

  • Description modified (diff)
  • Focuses privacy added

#23 @jonoaldersonwp
6 months ago

  • Description modified (diff)

#24 @jonoaldersonwp
6 months ago

  • Description modified (diff)

Updated the default font-display value to fallback. See #47282 for rationale.

Last edited 6 months ago by SergeyBiryukov (previous) (diff)

This ticket was mentioned in Slack in #core-privacy by carike. View the logs.


4 months ago

This ticket was mentioned in Slack in #forums by carike. View the logs.


3 months ago

This ticket was mentioned in Slack in #core-privacy by jonoaldersonwp. View the logs.


6 weeks ago

Note: See TracTickets for help on using tickets.