'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; } }