create-block-theme/includes/create-theme/theme-fonts.php
Sarah Norris da693bf64a
Validate downloaded theme assets against extension and MIME allowlists (#852)
* tests: add tiny.png and evil.php.txt fixtures for media validation

* docs(agents): update test:php commands to test:unit:php

* Add CBT_Theme_Media::is_allowed_media_url() extension allowlist

* Add CBT_Theme_Media::is_allowed_media_file() MIME allowlist

* Apply media URL + file allowlist in add_media_to_local

* Add CBT_Theme_Fonts::is_allowed_font_url() extension allowlist

* Add CBT_Theme_Fonts::is_allowed_font_file() MIME allowlist

* Apply font URL + file allowlist in copy_font_assets_to_theme

* Allow font/sfnt and application/vnd.ms-opentype for TTF/OTF MIME detection

* Reject multi-extension polyglots in URL allowlist

* Apply media+font allowlist to zip export sinks in theme-zip

* Add positive end-to-end tests for media and font sinks

* Verify downloaded fonts by magic bytes instead of libmagic MIME

* Pass $font_src string to download_url instead of $font_face src array

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Stream downloaded media into zip and clean up tmp file

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Strip query string before deriving folder path from media URL

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Restore braces and is_wp_error check dropped by Copilot suggestions

Two recent Copilot suggestion commits accidentally removed structural
code while making single-line changes:
- c045da7 ("Pass $font_src to download_url") dropped the `} else {`
  and the `is_wp_error( $tmp_file )` guard.
- 617f6ec ("Stream downloaded media into zip") dropped the closing
  brace of the `foreach` in add_media_to_zip().

Both broke PHP parse on every CI matrix. Restoring the missing
statements so the file is valid again.

* lint: align assignment operators in get_media_folder_path_from_url

* Strip query string from filename before renaming downloaded media

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Strip query string from font URL before deriving filename

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Strip query string from font URL before deriving filename in zip

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Restore loop brace and sanitize_title dropped by Copilot suggestions

Two more Copilot suggestion commits dropped surrounding context lines:
- ad31fe4 ("Strip query string from filename before renaming downloaded
  media") removed the closing brace of the foreach loop in
  add_media_to_local(), causing fatal lint errors.
- 860f1bc ("Strip query string from font URL before deriving filename
  in zip") removed the `$font_family_dir_name = sanitize_title(...)`
  assignment, leaving an undefined variable used immediately below.

* Remove dead pre-loop assignments in add_activated_fonts_to_zip

`$font_filename = basename( $font_face['src'] )` threw TypeError on
PHP 8+ when src was already an array (valid per Theme JSON spec) and
was redefined inside the inner src loop anyway. The adjacent
`$font_dir = wp_get_font_dir()` was likewise redundant — also
redefined inside the inner loop. Removing both.

* Verify downloaded media by magic bytes instead of libmagic MIME

wp_check_filetype_and_ext() was inconsistent with the URL allowlist:
SVG isn't in WP Core's default mime registry at all, and WP maps wmv
to video/x-ms-wmv / avi to video/avi while the post-check allowlist
had only video/x-msvideo. Legitimate SVG/WMV/AVI URLs passed preflight
and then silently failed post-download.

Switching media to magic-byte verification (matching the font sink)
removes the dependency on WP Core's mime registry and libmagic
versions. Recognised formats: JPEG, PNG, GIF, WebP, SVG, MP4/M4V/MOV/
3GP/3G2 (via ftyp), WebM, OGV, WMV (ASF), AVI (RIFF), MPEG.

* Add regression tests for media asset validation

* Fix media validation bugs found by scruffian

Three fixes for issues found via regression tests in 8da66ff:

1. is_allowed_media_file() now requires the downloaded bytes to match
   the URL extension specifically — a .jpg URL with SVG/MP4/anything-else
   bytes is rejected. Previously any allowed magic was accepted, so a
   browser MIME-sniffing the on-disk .jpg containing SVG could render
   it as SVG (with all the script-execution surface that brings).

2. make_relative_media_url() now derives the filename from the parsed
   URL path instead of raw basename(). A URL like cat.jpg?/evil.php
   previously produced `/assets/images/evil.php` in exported template
   markup because basename() treats query-string slashes as path
   separators.

3. add_media_to_zip() reads the downloaded bytes into memory and
   unlinks the tmp file BEFORE adding to the archive via
   addFromStringToTheme(). The previous `addFileToTheme() + unlink`
   sequence broke because ZipArchive defers reading files added via
   addFile() until close() — by which time the tmp file was gone.

* lint: suppress NoSilencedErrors warning on ZipArchive::close in test

* Skip zip tests gracefully when ZipArchive extension is unavailable

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Guard localhost retry against missing host/port keys

parse_url() omits the 'port' key when the URL has no explicit port
(e.g. `http://localhost/foo`). The retry-on-localhost block in both
add_media_to_zip() and add_media_to_local() read $parsed_url['port']
unconditionally, raising an undefined-index notice when the initial
download failed for such a URL. Guard with isset() on both keys.

* Match font magic bytes against URL extension specifically

Previously any recognised font magic was accepted, so a .woff2 URL
returning TTF/WOFF/OTF/EOT bytes would pass and be saved under the
.woff2 filename. This defeated the extension+MIME allowlist intent
and would produce broken assets in the exported theme/zip (browsers
would fail to load a WOFF2-named file containing TTF content).

The check now switches on the URL extension and validates the magic
matches THAT specific format. Mirrors the same fix already applied
to is_allowed_media_file().

* Align .mpeg handling across URL, folder, and file-content checks

is_allowed_media_file() accepted both .mpg and .mpeg, but
is_allowed_media_url() and get_media_folder_path_from_url() only
listed .mpg. A template with a .mpeg video would get rewritten to a
local asset URL, then skipped before download — leaving the exported
theme pointing at a missing file.

Add .mpeg to the URL allowlist and the folder mapping so all three
lists agree (matching WP Core's wp_get_mime_types which maps
mpeg|mpg|mpe to video/mpeg).

Also drops a pre-existing duplicate 'ogv' entry in the folder mapping
that was noticed while editing.

* Stream font bytes into zip before unlinking tmp file

Same fix as add_media_to_zip: ZipArchive::addFile() defers reading
the file until close() is called, so unlinking the download_url tmp
file immediately after addFileToTheme() left an empty entry in the
final archive when the zip was finalised.

Read the bytes into memory, unlink, then addFromStringToTheme() —
mirrors the pattern in add_media_to_zip(). Only the remote-download
branch is affected; the local-copy branch points at a permanent
file in the font library so its addFileToTheme() call stays.

* Tweak comment

* Allow AVIF images alongside JPEG/PNG/GIF/WebP/SVG

AVIF is a WordPress-supported image format but was missing from the
URL allowlist, folder mapping, and magic-byte check. The result:
make_template_images_local() rewrote .avif URLs to local references,
but the post-rewrite download was rejected — leaving exported themes
pointing at missing assets.

AVIF uses the ISO BMFF container (like MP4) with 'avif' or 'avis' as
the major brand at offset 8. Added the brand-specific magic check
alongside the URL/folder allowlist entries, plus tests for both AVIF
still-image ('avif') and image-sequence ('avis') brands.

* Drop rejected font sources from exported families

Previously a font source that failed the URL allowlist or post-download
MIME check was skipped via continue, but the original $font_src stayed
in $font_face['src']. copy_activated_fonts_to_theme() then merged that
family into theme.json — leaving the user's activated font pointing at
an uncopied (and possibly disallowed) source while the user custom
settings were cleared.

Both copy_font_assets_to_theme() and add_activated_fonts_to_zip() now
build a fresh srcs list and only retain entries that successfully
copied or were already file:./ asset paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Only rewrite media URLs after successful asset validation

* Accept AVIF compatible-brands in addition to the major brand

Some AVIF files (typically those emitted by HEIF-derived tooling) set
the major brand to mif1/miaf and only list avif/avis in the compatible
brands. Parsing only the major brand at offset 8 would false-reject
them.

Scan the full FileTypeBox brand list (major brand plus compatible
brands at offsets 16+) for any AVIF brand. HEIC files (no avif/avis
anywhere) are still rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Magic-byte check the local-copy font branch too

Both copy_font_assets_to_theme() and add_activated_fonts_to_zip()
treated a font src under the WP user-fonts directory as trusted —
copying / zipping the bytes without ever inspecting them. Anything
that ended up in that directory through a separate flow (a polyglot
upload via another plugin, manual write, etc.) would be promoted to
the exported theme as-is just because the URL extension was on the
allowlist.

Run the same is_allowed_font_file() check on the local source before
copying / zipping. If the bytes don't match the URL extension's font
format, drop the source from the returned families.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Localize media URLs via the block parser, not str_replace

Plain str_replace on the whole template would substring-match a
shorter validated URL inside a longer rejected URL when one is a
prefix of the other (e.g. `photo.png` inside `photo.png.php`).
That silently rewrote the rejected URL to a missing local asset and
bypassed the validated-media guard.

Walk the parsed block tree instead: WP_HTML_Tag_Processor handles img
/ video src+poster and inline `background-image:url(...)`, while
direct array access handles block-comment JSON attrs. Replacements
only touch values that exactly match a validated URL.

The rewrites embed literal PHP open/close tags that must end up
verbatim in the export, but both set_attribute() and wp_json_encode()
(inside serialize_blocks) would escape `<` / `>` / quotes. Route the
rewrites through opaque, URL-shaped placeholders so they survive
both passes, then strtr() them back to the raw PHP at the end. The
placeholder is a root-relative path because set_attribute prefixes
schemeless values with `http://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Dwyer <ben@scruffian.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 15:25:43 +01:00

443 lines
15 KiB
PHP

<?php
/**
* Theme Fonts
*
* @package Create_Block_Theme
*/
class CBT_Theme_Fonts {
/**
* Make the font face theme src urls absolute.
*
* It replaces the 'file:./' prefix with the theme directory uri.
*
* Example: 'file:./assets/fonts/my-font.ttf' -> 'http://example.com/wp-content/themes/my-theme/assets/fonts/my-font.ttf'
* Example: [ 'https://example.com/assets/fonts/my-font.ttf' ] -> [ 'https://example.com/assets/fonts/my-font.ttf' ]
*
* @param array|string $src
* @return array|string
*/
private static function make_theme_font_src_absolute( $src ) {
$make_absolute = function( $url ) {
if ( str_starts_with( $url, 'file:./' ) ) {
return str_replace( 'file:./', get_stylesheet_directory_uri() . '/', $url );
}
return $url;
};
if ( is_array( $src ) ) {
return array_map( $make_absolute, $src );
}
return $make_absolute( $src );
}
/**
* Get all fonts from the theme.json data + all the style variations.
*
* @return array
*/
public static function get_all_fonts() {
$font_families = array();
$theme = CBT_Theme_JSON_Resolver::get_merged_data();
$settings = $theme->get_settings();
if ( isset( $settings['typography']['fontFamilies']['theme'] ) ) {
$font_families = array_merge( $font_families, $settings['typography']['fontFamilies']['theme'] );
}
if ( isset( $settings['typography']['fontFamilies']['custom'] ) ) {
$font_families = array_merge( $font_families, $settings['typography']['fontFamilies']['custom'] );
}
$variations = CBT_Theme_JSON_Resolver::get_style_variations();
foreach ( $variations as $variation ) {
if ( isset( $variation['settings']['typography']['fontFamilies']['theme'] ) ) {
$font_families = array_merge( $font_families, $variation['settings']['typography']['fontFamilies']['theme'] );
}
}
// Iterates through the font families and makes the urls absolute to use in the frontend code.
foreach ( $font_families as &$font_family ) {
if ( isset( $font_family['fontFace'] ) ) {
foreach ( $font_family['fontFace'] as &$font_face ) {
$font_face['src'] = self::make_theme_font_src_absolute( $font_face['src'] );
}
}
}
return $font_families;
}
/**
* Copy any ACTIVATED fonts from USER configuration to THEME configuration including any font face assets.
* Remove any DEACTIVATED fonts from the THEME configuration.
*/
public static function persist_font_settings() {
self::remove_deactivated_fonts_from_theme();
self::copy_activated_fonts_to_theme();
}
public static function get_user_activated_fonts() {
$user_settings = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
return $user_settings['typography']['fontFamilies']['custom'] ?? null;
}
/**
* Make a pretty filename from a font face.
*
* The filename is based on the font family name, weight, style, unicode range and the source index.
* Example:
* $font_face = [ 'fontFamily' => 'Open Sans', 'fontWeight' => '400', 'fontStyle' => 'normal' ]
* $src = 'https://example.com/assets/fonts/open-sans-regular.ttf'
* $src_index = 0
* Returns: 'open-sans-400-normal.ttf'
*
* @param array $font_face
* @param string $src
* @param int $src_index
* @return string
*/
public static function make_filename_from_fontface( $font_face, $src, $src_index = 0 ) {
$font_extension = pathinfo( $src, PATHINFO_EXTENSION );
$font_filename = sanitize_title( $font_face['fontFamily'] )
. ( isset( $font_face['fontWeight'] ) ? '-' . sanitize_title( $font_face['fontWeight'] ) : '' )
. ( isset( $font_face['fontStyle'] ) ? '-' . sanitize_title( $font_face['fontStyle'] ) : '' )
. ( isset( $font_face['unicodeRange'] ) ? '-' . sanitize_title( $font_face['unicodeRange'] ) : '' )
. ( 0 !== $src_index ? '-' . $src_index : '' )
. '.'
. $font_extension;
return $font_filename;
}
/**
* Allowlist check on the URL's path extension before we attempt to download a font.
*
* Defends against multi-extension polyglots (`evil.php.woff2`) by rejecting
* any URL whose basename contains a dangerous extension segment anywhere,
* not just at the end.
*
* @param string $url Absolute URL pointing at a font face source.
* @return bool True if the basename is safe and the final extension is in the font allowlist.
*/
public static function is_allowed_font_url( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
$path = wp_parse_url( $url, PHP_URL_PATH );
$basename = strtolower( basename( (string) $path ) );
// Reject if ANY dot-separated segment is a dangerous extension.
// Mirrors the denylist in CBT_Theme_Media::is_allowed_media_url().
$dangerous = array(
'php',
'phtml',
'phar',
'php3',
'php4',
'php5',
'php7',
'php8',
'phps',
'html',
'htm',
'xhtml',
'htaccess',
'htpasswd',
'cgi',
'pl',
'py',
'rb',
'sh',
'asp',
'aspx',
'jsp',
'js',
'mjs',
);
foreach ( explode( '.', $basename ) as $segment ) {
if ( in_array( $segment, $dangerous, true ) ) {
return false;
}
}
$extension = pathinfo( $basename, PATHINFO_EXTENSION );
$allowed = array( 'ttf', 'otf', 'woff', 'woff2', 'eot' );
return in_array( $extension, $allowed, true );
}
/**
* Magic-byte verification of a downloaded font file against its URL extension.
*
* The downloaded bytes must match the format claimed by the URL extension —
* a `.woff2` URL whose body is TTF (or anything else) is rejected so we
* never persist content that doesn't match its filename on disk.
*
* libmagic-based MIME detection (via finfo or wp_check_filetype_and_ext)
* is unreliable for font formats: WordPress Core has no font MIMEs in its
* registry, and PHP base images ship with varying libmagic versions.
* Verifying magic bytes directly is version-independent and gives a
* stronger guarantee.
*
* Recognised magic per extension:
* - woff2 → `wOF2` at offset 0
* - woff → `wOFF` at offset 0
* - otf → `OTTO` at offset 0
* - ttf → `\x00\x01\x00\x00` at offset 0 (or `true` for legacy Mac)
* - eot → Version field (offset 8) is 0x00010000 / 0x00020001 / 0x00020002
*
* @param string $tmp_file Local path to the downloaded file.
* @param string $url The originating URL — its extension determines
* which magic-byte family the body must match.
* @return bool True if the file's leading bytes match the magic signature
* expected for the URL's extension.
*/
public static function is_allowed_font_file( $tmp_file, $url ) {
if ( ! is_string( $tmp_file ) || ! file_exists( $tmp_file ) ) {
return false;
}
// Read 12 bytes — covers magic-at-offset-0 formats (4 bytes) and the
// EOT Version field at offset 8 (4 bytes).
$fp = fopen( $tmp_file, 'rb' );
if ( false === $fp ) {
return false;
}
$head = fread( $fp, 12 );
fclose( $fp );
if ( false === $head || strlen( $head ) < 4 ) {
return false;
}
// Derive the extension from the URL's path (ignore query/fragment) so
// the bytes must match the format claimed by the URL — saving a TTF
// body under a .woff2 filename would otherwise defeat the allowlist
// intent and produce broken assets in the exported theme/zip.
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
$extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
$first_four = substr( $head, 0, 4 );
switch ( $extension ) {
case 'woff2':
return 'wOF2' === $first_four;
case 'woff':
return 'wOFF' === $first_four;
case 'otf':
return 'OTTO' === $first_four;
case 'ttf':
return "\x00\x01\x00\x00" === $first_four || 'true' === $first_four;
case 'eot':
return 12 === strlen( $head )
&& in_array(
substr( $head, 8, 4 ),
array( "\x00\x00\x01\x00", "\x01\x00\x02\x00", "\x02\x00\x02\x00" ),
true
);
default:
return false;
}
}
/*
* Copy the font assets to the theme.
*
* @param array $font_families The font families to copy.
* @return array $font_families The font families with the font face src updated to the theme font asset location.
*/
public static function copy_font_assets_to_theme( $font_families ) {
$theme_font_asset_location = path_join( get_stylesheet_directory(), 'assets/fonts/' );
// Create the font asset directory if it does not exist.
wp_mkdir_p( $theme_font_asset_location );
foreach ( $font_families as &$font_family ) {
if ( ! isset( $font_family['fontFace'] ) ) {
continue;
}
$font_family_dir_name = sanitize_title( $font_family['name'] );
$font_family_dir_path = path_join( $theme_font_asset_location, $font_family_dir_name );
// Crete a font family specific directory if it does not exist.
wp_mkdir_p( $font_family_dir_path );
foreach ( $font_family['fontFace'] as &$font_face ) {
// src can be a string or an array
// if it is a string, cast it to an array
$font_face['src'] = (array) $font_face['src'];
// Build a fresh srcs list so rejected sources (disallowed URL,
// failed download, MIME mismatch) are dropped rather than
// persisted into theme.json.
$kept_srcs = array();
foreach ( $font_face['src'] as $font_src_index => $font_src ) {
if ( str_starts_with( $font_src, 'file:' ) ) {
// If the font source starts with 'file:' then it's already a theme asset.
$kept_srcs[] = $font_src;
continue;
}
// Pre-download URL extension allowlist — applies to both
// the local-copy and remote-download branches because the
// URL itself is the input we don't trust.
if ( ! self::is_allowed_font_url( $font_src ) ) {
continue;
}
$font_src_path = (string) wp_parse_url( $font_src, PHP_URL_PATH );
$font_filename = basename( $font_src_path );
$font_pretty_filename = self::make_filename_from_fontface( $font_face, $font_src_path, $font_src_index );
$font_face_path = path_join( $font_family_dir_path, $font_pretty_filename );
$font_dir = wp_get_font_dir();
if ( str_contains( $font_src, $font_dir['url'] ) ) {
// If the file is hosted on this server then copy it to the theme.
$local_source = path_join( $font_dir['path'], $font_filename );
// Magic-byte allowlist on the local source too — the
// WP user-fonts directory is the input we don't trust:
// anything that ended up there (via a separate flow,
// or a polyglot) shouldn't be copied verbatim into
// the theme just because the URL extension looked OK.
if ( ! self::is_allowed_font_file( $local_source, $font_src ) ) {
continue;
}
copy( $local_source, $font_face_path );
} else {
// otherwise download it from wherever it is hosted
$tmp_file = download_url( $font_src );
if ( is_wp_error( $tmp_file ) ) {
continue;
}
// Post-download MIME allowlist.
if ( ! self::is_allowed_font_file( $tmp_file, $font_src ) ) {
@unlink( $tmp_file );
continue;
}
copy( $tmp_file, $font_face_path );
unlink( $tmp_file );
}
$font_face_family_path = path_join( $font_family_dir_name, $font_pretty_filename );
$kept_srcs[] = path_join( 'file:./assets/fonts/', $font_face_family_path );
}
$font_face['src'] = $kept_srcs;
}
}
return $font_families;
}
public static function copy_activated_fonts_to_theme() {
$font_families_to_copy = self::get_user_activated_fonts();
if ( is_null( $font_families_to_copy ) ) {
return;
}
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$copied_font_families = self::copy_font_assets_to_theme( $font_families_to_copy );
$theme_json['settings']['typography']['fontFamilies'] = array_merge(
$theme_json['settings']['typography']['fontFamilies'] ?? array(),
$copied_font_families
);
$user_settings = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
unset( $user_settings['typography']['fontFamilies']['custom'] );
if ( empty( $user_settings['typography']['fontFamilies'] ) ) {
unset( $user_settings['typography']['fontFamilies'] );
}
if ( empty( $user_settings['typography'] ) ) {
unset( $user_settings['typography'] );
}
CBT_Theme_JSON_Resolver::write_user_settings( $user_settings );
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
}
/**
* Remove font face assets from the theme that are not in the user configuration.
*
* @param array $font_families_to_not_remove
* @param array $theme_font_families
*/
private static function remove_deactivated_font_assets( $font_families_to_not_remove, $theme_font_families ) {
/* Bail if there are no theme font families, which can happen
* if the theme.json file, missing, or if the theme is a child theme, in
* which case the font families are inherited from the parent theme.
*/
if ( is_null( $theme_font_families ) ) {
return;
}
$theme_font_asset_location = get_stylesheet_directory() . '/assets/fonts/';
$font_families_to_remove = array_filter(
$theme_font_families,
function( $theme_font_family ) use ( $font_families_to_not_remove ) {
return ! in_array( $theme_font_family['slug'], array_column( $font_families_to_not_remove, 'slug' ), true );
}
);
foreach ( $font_families_to_remove as $font_family ) {
if ( isset( $font_family['fontFace'] ) ) {
foreach ( $font_family['fontFace'] as $font_face ) {
// src can be a string or an array
// if it is a string, cast it to an array
$srcs = (array) $font_face['src'];
foreach ( $srcs as $font_src ) {
$font_filename = basename( $font_src );
$file_path = $theme_font_asset_location . $font_filename;
if ( file_exists( $file_path ) ) {
unlink( $file_path );
}
}
}
}
}
}
/**
* Remove any deactivated fonts from the theme configuration.
* This includes removing the font face assets from the theme,
* but does not remove the font face assets from the user configuration.
*
* This is because the user may have deactivated a font, but still want to use it in the future.
*/
public static function remove_deactivated_fonts_from_theme() {
$user_settings = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
if ( ! isset( $user_settings['typography']['fontFamilies']['theme'] ) ) {
return;
}
$font_families_to_not_remove = $user_settings['typography']['fontFamilies']['theme'];
$theme_font_families = $theme_json['settings']['typography']['fontFamilies'] ?? null;
self::remove_deactivated_font_assets( $font_families_to_not_remove, $theme_font_families );
if ( ! is_null( $theme_font_families ) ) {
$theme_json['settings']['typography']['fontFamilies'] = array_filter(
$theme_font_families,
function( $theme_font_family ) use ( $font_families_to_not_remove ) {
return in_array( $theme_font_family['slug'], array_column( $font_families_to_not_remove, 'slug' ), true );
}
);
}
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
unset( $user_settings['typography']['fontFamilies']['theme'] );
if ( empty( $user_settings['typography']['fontFamilies'] ) ) {
unset( $user_settings['typography']['fontFamilies'] );
}
if ( empty( $user_settings['typography'] ) ) {
unset( $user_settings['typography'] );
}
CBT_Theme_JSON_Resolver::write_user_settings( $user_settings );
}
}