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 glyphEleven 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,180msThe 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.