monorepo/native/wordpress/maple-fonts-wp/includes/class-mlf-font-registry.php
2026-02-02 11:04:00 -05:00

502 lines
17 KiB
PHP

<?php
/**
* Font Registry for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Registry
*
* Handles registering fonts with WordPress Font Library API.
*/
class MLF_Font_Registry {
/**
* Register a font family with WordPress Font Library.
*
* @param string $font_name Display name (e.g., "Open Sans").
* @param string $font_slug Slug (e.g., "open-sans").
* @param array $files Array of downloaded file data.
* @param string $font_version Google Fonts version (e.g., "v35").
* @param string $last_modified Google Fonts last modified date.
* @return int|WP_Error Font family post ID or error.
*/
public function register_font($font_name, $font_slug, $files, $font_version = '', $last_modified = '') {
// Check if font already exists
$existing = get_posts([
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'any',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Get font directory info
$font_dir = wp_get_font_dir();
// Build font face array for WordPress
$font_faces = [];
foreach ($files as $file) {
$filename = basename($file['path']);
// Determine if this is a variable font (weight is a range like "100 900")
$is_variable = isset($file['is_variable']) && $file['is_variable'];
$weight = $file['weight'];
$face_data = [
'fontFamily' => $font_name,
'fontStyle' => $file['style'],
'fontWeight' => $weight,
'src' => 'file:./' . $filename,
];
$font_faces[] = $face_data;
}
// Determine font category for fallback
// Default to sans-serif, but could be enhanced to detect from Google Fonts metadata
$fallback = 'sans-serif';
// Build font family settings (this is what Gutenberg reads)
$font_family_settings = [
'name' => $font_name,
'slug' => $font_slug,
'fontFamily' => "'{$font_name}', {$fallback}",
'fontFace' => $font_faces,
];
// Create font family post
$family_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $font_name,
'post_name' => $font_slug,
'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings),
], true);
if (is_wp_error($family_id)) {
return $family_id;
}
// Mark as imported by our plugin (for identification)
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Store version info for update checking
if (!empty($font_version)) {
update_post_meta($family_id, '_mlf_font_version', $font_version);
}
if (!empty($last_modified)) {
update_post_meta($family_id, '_mlf_font_last_modified', $last_modified);
}
// Create font face posts (children) - WordPress also reads these
foreach ($files as $file) {
$filename = basename($file['path']);
$weight = $file['weight'];
$face_settings = [
'fontFamily' => $font_name,
'fontWeight' => $weight,
'fontStyle' => $file['style'],
'src' => 'file:./' . $filename,
];
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_title' => sprintf('%s %s %s', $font_name, $weight, $file['style']),
'post_name' => sanitize_title(sprintf('%s-%s-%s', $font_slug, $weight, $file['style'])),
'post_status' => 'publish',
'post_content' => wp_json_encode($face_settings),
]);
}
// Clear all font-related caches
$this->clear_font_caches();
return $family_id;
}
/**
* Delete a font family and its files.
*
* @param int $family_id Font family post ID.
* @return bool|WP_Error True on success, error on failure.
*/
public function delete_font($family_id) {
$family = get_post($family_id);
if (!$family || $family->post_type !== 'wp_font_family') {
return new WP_Error('not_found', 'Font family not found');
}
// Verify it's one we imported
if (get_post_meta($family_id, '_mlf_imported', true) !== '1') {
return new WP_Error('not_ours', 'Cannot delete fonts not imported by this plugin');
}
// Get font info before deletion for cleanup
$settings = json_decode($family->post_content, true);
$font_slug = $settings['slug'] ?? $family->post_name;
// Get font faces (children)
$faces = get_children([
'post_parent' => $family_id,
'post_type' => 'wp_font_face',
]);
$font_dir = wp_get_font_dir();
// Delete font face files and posts
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
if (isset($face_settings['src'])) {
// Convert file:. URL to path
$src = $face_settings['src'];
$src = str_replace('file:./', '', $src);
$file_path = trailingslashit($font_dir['path']) . basename($src);
// Validate path and extension before deletion
if ($this->validate_font_path($file_path)
&& pathinfo($file_path, PATHINFO_EXTENSION) === 'woff2'
&& file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family post
wp_delete_post($family_id, true);
// Clean up global styles references to this font
$this->remove_font_from_global_styles($font_slug);
// Clear all font-related caches
$this->clear_font_caches();
return true;
}
/**
* Remove references to a deleted font from global styles.
*
* This prevents block errors when a font is deleted but still referenced.
*
* @param string $font_slug The font slug to remove.
*/
private function remove_font_from_global_styles($font_slug) {
// Get the global styles post for the current theme
$global_styles = get_posts([
'post_type' => 'wp_global_styles',
'posts_per_page' => 1,
'post_status' => 'publish',
'tax_query' => [
[
'taxonomy' => 'wp_theme',
'field' => 'name',
'terms' => get_stylesheet(),
],
],
]);
if (empty($global_styles)) {
return;
}
$global_styles_post = $global_styles[0];
$content = json_decode($global_styles_post->post_content, true);
if (empty($content)) {
return;
}
$modified = false;
// Helper function to recursively remove font references
$remove_font_refs = function (&$data) use ($font_slug, &$modified, &$remove_font_refs) {
if (!is_array($data)) {
return;
}
foreach ($data as $key => &$value) {
// Check for fontFamily references using the slug pattern
if ($key === 'fontFamily' && is_string($value)) {
// WordPress uses format like: var(--wp--preset--font-family--font-slug)
if (strpos($value, '--font-family--' . $font_slug) !== false) {
unset($data[$key]);
$modified = true;
}
}
// Check for typography.fontFamily in element/block styles
if (is_array($value)) {
$remove_font_refs($value);
}
}
};
$remove_font_refs($content);
if ($modified) {
wp_update_post([
'ID' => $global_styles_post->ID,
'post_content' => wp_json_encode($content),
]);
}
}
/**
* Clear all font-related caches.
*/
private function clear_font_caches() {
// Clear WordPress Font Library cache
delete_transient('wp_font_library_fonts');
// Clear our plugin's cache
delete_transient('mlf_imported_fonts_list');
// Clear global settings cache (used by Gutenberg)
wp_cache_delete('wp_get_global_settings', 'theme_json');
wp_cache_delete('wp_get_global_stylesheet', 'theme_json');
// Clear theme.json related caches
delete_transient('global_styles');
delete_transient('global_styles_' . get_stylesheet());
// Clear WP_Theme_JSON caches
if (class_exists('WP_Theme_JSON_Resolver')) {
WP_Theme_JSON_Resolver::clean_cached_data();
}
// Clear object cache for post queries
wp_cache_flush_group('posts');
// Clear any theme mods cache
delete_transient('theme_mods_' . get_stylesheet());
// Trigger action for other plugins/themes that might cache fonts
do_action('mlf_fonts_cache_cleared');
}
/**
* Get all fonts imported by this plugin.
*
* Uses optimized queries to avoid N+1 pattern.
*
* @return array Array of font data.
*/
public function get_imported_fonts() {
// Check transient cache first
$cached = get_transient('mlf_imported_fonts_list');
if ($cached !== false) {
return $cached;
}
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
if (empty($fonts)) {
set_transient('mlf_imported_fonts_list', [], 5 * MINUTE_IN_SECONDS);
return [];
}
// Collect all font IDs for batch query
$font_ids = wp_list_pluck($fonts, 'ID');
// Single query to get ALL font faces for ALL fonts (fixes N+1)
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => 1000,
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
// Group faces by parent font ID
$faces_by_font = [];
foreach ($all_faces as $face) {
$parent_id = $face->post_parent;
if (!isset($faces_by_font[$parent_id])) {
$faces_by_font[$parent_id] = [];
}
$faces_by_font[$parent_id][] = $face;
}
// Batch get all metadata
$import_dates = [];
$font_versions = [];
$font_last_modified = [];
foreach ($font_ids as $font_id) {
$import_dates[$font_id] = get_post_meta($font_id, '_mlf_import_date', true);
$font_versions[$font_id] = get_post_meta($font_id, '_mlf_font_version', true);
$font_last_modified[$font_id] = get_post_meta($font_id, '_mlf_font_last_modified', true);
}
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants from pre-fetched data
$faces = $faces_by_font[$font->ID] ?? [];
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
];
}
// Sort variants by weight then style
usort($variants, function($a, $b) {
// Handle weight ranges (variable fonts)
$a_weight = is_numeric($a['weight']) ? intval($a['weight']) : intval(explode(' ', $a['weight'])[0]);
$b_weight = is_numeric($b['weight']) ? intval($b['weight']) : intval(explode(' ', $b['weight'])[0]);
$weight_cmp = $a_weight - $b_weight;
if ($weight_cmp !== 0) {
return $weight_cmp;
}
return strcmp($a['style'], $b['style']);
});
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
'import_date' => $import_dates[$font->ID] ?? '',
'version' => $font_versions[$font->ID] ?? '',
'last_modified' => $font_last_modified[$font->ID] ?? '',
];
}
// Cache for 5 minutes
set_transient('mlf_imported_fonts_list', $result, 5 * MINUTE_IN_SECONDS);
return $result;
}
/**
* Get all fonts imported by this plugin, including actual filenames.
*
* This method includes the actual filename stored in the database,
* which is needed to correctly reference variable vs static font files.
*
* @return array Array of font data with filenames.
*/
public function get_imported_fonts_with_src() {
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
if (empty($fonts)) {
return [];
}
// Collect all font IDs for batch query
$font_ids = wp_list_pluck($fonts, 'ID');
// Single query to get ALL font faces for ALL fonts
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => 1000,
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
// Group faces by parent font ID
$faces_by_font = [];
foreach ($all_faces as $face) {
$parent_id = $face->post_parent;
if (!isset($faces_by_font[$parent_id])) {
$faces_by_font[$parent_id] = [];
}
$faces_by_font[$parent_id][] = $face;
}
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants from pre-fetched data
$faces = $faces_by_font[$font->ID] ?? [];
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
// Extract filename from src (format: "file:./filename.woff2")
$src = $face_settings['src'] ?? '';
$filename = str_replace('file:./', '', $src);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
'filename' => $filename,
];
}
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
];
}
return $result;
}
/**
* Validate that a path is within the WordPress fonts directory.
*
* @param string $path Full path to validate.
* @return bool True if path is safe, false otherwise.
*/
private function validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
}