The font library, and the licences nobody had checked

WordPress 6.5 shipped a font library in April, which moves font management from a developer concern into a screen an editor can reach. That is a straightforwardly good feature and it hands a licensing decision to people who have not been given the context to make it.

The symptom

$ curl -s https://example.test/ | grep -oP '@font-face[^}]+src:[^;]+' 
  | grep -oP "url(K[^)]+" | sort -u
  /wp-content/themes/td/fonts/inter-var.woff2
  /wp-content/themes/td/fonts/inter-italic.woff2
  /wp-content/themes/td/fonts/mono-regular.woff2
  /wp-content/plugins/some-plugin/fonts/opensans.woff2
  ... 7 more

$ ./bin/font-usage --from-computed-styles
  used:   inter-var, mono-regular
  loaded: 11
  bytes:  412 KB, of which 288 KB never rendered a glyph

Eleven font files loaded, two of them used, and four of the unused ones arriving from plugins. None of this was anybody’s decision — fonts accumulate the same way dependencies do, and nothing counts them.

Why it happens

Fonts were a developer concern, so nobody was managing them — a theme adds two, a plugin adds three, and the total is never anybody’s number. The font library makes them visible and makes them installable, which surfaces the existing mess and adds a new way to grow it.

The fix

What the font library actually is

  wp_font_family      a post type. one per family.
  wp_font_face        child posts, one per weight and
                      style, with the file reference.
  uploads/fonts/      where uploaded files land, with
                      a filter to change the path.
  a collection        a JSON endpoint listing installable
                      families. Google Fonts by default,
                      and replaceable.
  the screen          Appearance → Editor → Styles →
                      Typography → Manage fonts.

and what is NOT in it:
  licence checking, subsetting, or any indication of
  the payload being added.

Restricting the collection

add_action( 'init', function () {
    // remove the default collection entirely
    wp_unregister_font_collection( 'google-fonts' );

    wp_register_font_collection( array(
        'slug'        => 'turkerdev-approved',
        'name'        => __( 'Approved fonts', 'turkerdev' ),
        'description' => __( 'Cleared for use on this site.', 'turkerdev' ),
        'font_families' => get_theme_file_path( 'fonts/collection.json' ),
    ) );
} );
[
  {
    "font_family_settings": {
      "name": "Inter",
      "slug": "inter",
      "fontFamily": "Inter, sans-serif",
      "fontFace": [{
        "fontFamily": "Inter",
        "fontWeight": "100 900",
        "fontStyle": "normal",
        "src": "file:./fonts/inter-var-subset.woff2"
      }]
    },
    "categories": ["sans-serif"]
  }
]

Replacing the collection turns an open catalogue into a menu, which is the difference between a governance conversation and a choice. The four fonts in ours are the ones already licensed for the site, already subsetted, and already self-hosted — an editor picking one cannot pick wrongly.

The capability, which is coarser than it looks

// the screen is gated on edit_theme_options.
// on this site that had been granted to a custom role
// in 2019, for the customiser's menu screen.

add_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) {
    if ( ! in_array( 'edit_theme_options', $caps, true ) ) {
        return $allcaps;
    }

    // font management specifically requires more
    if ( str_starts_with( $args[0] ?? '', 'install_fonts' )
        && ! user_can( $user, 'manage_options' ) ) {
        $allcaps['edit_theme_options'] = false;
    }

    return $allcaps;
}, 10, 4 );

A capability granted for one screen in 2019 becomes a grant for every screen added afterwards that uses it, which is the general hazard of a coarse permission model. Auditing custom roles after any release that adds an admin screen is five minutes and is not on anybody’s upgrade checklist.

Self-hosting, and the privacy consequence

the default collection installs from a third party by
downloading the files — so an installed font is
self-hosted, not linked.

which is the right default and is the opposite of what
the old @import approach did:

  before  a request from every visitor's browser to a
          third-party domain, carrying their address
          and referrer
  after   a request to our own server

and it changes the privacy notice, which had a
paragraph about a third-party font service that is now
wrong in the other direction.

The performance side, which the library does not touch

# subsetting: not done by the library, and the largest
# single win available
$ pyftsubset Inter-Variable.ttf 
    --unicodes='U+0000-00FF,U+0100-017F,U+2000-206F' 
    --layout-features='kern,liga' 
    --flavor=woff2 --output-file=inter-var-subset.woff2

  412 KB → 88 KB

# and the two lines that stop it blocking
@font-face { font-display: swap; }
<link rel="preload" as="font" type="font/woff2" crossorigin>

The Turkish range needed Latin Extended-A, which is the sort of thing that is invisible until somebody’s name renders in a fallback. Checking the actual content rather than assuming a script is the part to be careful about, and it is a query against the database rather than a guess.

Verifying it worked

$ ./bin/font-usage --from-computed-styles
  used:   2
  loaded: 2
  bytes:  96 KB          # was 412 KB

$ wp eval 'print_r( array_map( fn( $c ) => $c->slug,
    WP_Font_Library::get_instance()->get_font_collections() ) );'
  [0] => turkerdev-approved

$ wp user list --role=content_manager --field=ID | 
    xargs -I{} wp cap list {} | grep -c edit_theme_options
0

# largest contentful paint, mobile, p75
  before  2,140ms
  after   1,180ms

The largest contentful paint nearly halving is almost entirely the subset and the preload rather than anything the font library did — which is the honest accounting. The library made the fonts visible, and being able to see them is what prompted the measurement.

What this costs

A governance question handed to people without the context to answer it, mitigated by removing the open catalogue — which means an editor who genuinely needs a new font now files a ticket. That is a worse experience than the feature offers and it is the correct trade for a site where fonts are licensed per project.

The curated collection also has to be maintained. Four fonts, subsetted by a build step, with a licence file each, is a small directory that nobody will update — and the first time somebody wants a fifth font the path of least resistance is re-enabling the default collection, which undoes all of it in one line.