This commit is contained in:
rodolfomartinez 2026-02-02 00:13:36 -05:00
parent 572552ff13
commit 847ed92c23
10 changed files with 1232 additions and 591 deletions

View file

@ -16,23 +16,6 @@ if (!defined('ABSPATH')) {
*/
class MLF_Admin_Page {
/**
* Available font weights.
*
* @var array
*/
private $weights = [
100 => 'Thin',
200 => 'Extra Light',
300 => 'Light',
400 => 'Regular',
500 => 'Medium',
600 => 'Semi Bold',
700 => 'Bold',
800 => 'Extra Bold',
900 => 'Black',
];
/**
* Constructor.
*/
@ -53,7 +36,8 @@ class MLF_Admin_Page {
$installed_fonts = $registry->get_imported_fonts();
?>
<div class="wrap mlf-wrap">
<h1><?php esc_html_e('Maple Local Fonts', 'maple-local-fonts'); ?></h1>
<h1><?php esc_html_e('Maple Fonts', 'maple-local-fonts'); ?></h1>
<p class="mlf-description"><?php esc_html_e('Import Google Fonts to your local server for privacy-friendly, GDPR-compliant typography.', 'maple-local-fonts'); ?></p>
<div class="mlf-container">
<!-- Import Section -->
@ -61,73 +45,62 @@ class MLF_Admin_Page {
<h2><?php esc_html_e('Import from Google Fonts', 'maple-local-fonts'); ?></h2>
<form id="mlf-import-form" class="mlf-form">
<!-- Search Input -->
<div class="mlf-form-row">
<label for="mlf-font-name"><?php esc_html_e('Font Name', 'maple-local-fonts'); ?></label>
<input type="text" id="mlf-font-name" name="font_name" placeholder="<?php esc_attr_e('e.g., Open Sans', 'maple-local-fonts'); ?>" required />
<p class="description"><?php esc_html_e('Enter the exact font name as it appears on Google Fonts.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row">
<label><?php esc_html_e('Weights', 'maple-local-fonts'); ?></label>
<div class="mlf-checkbox-grid">
<?php foreach ($this->weights as $weight => $label) : ?>
<label class="mlf-checkbox-label">
<input type="checkbox" name="weights[]" value="<?php echo esc_attr($weight); ?>" <?php checked(in_array($weight, [400, 700], true)); ?> />
<span><?php echo esc_html($weight); ?> (<?php echo esc_html($label); ?>)</span>
</label>
<?php endforeach; ?>
<label for="mlf-font-search"><?php esc_html_e('Search Fonts', 'maple-local-fonts'); ?></label>
<div class="mlf-search-wrapper">
<span class="dashicons dashicons-search mlf-search-icon"></span>
<input type="text"
id="mlf-font-search"
class="mlf-search-input"
placeholder="<?php esc_attr_e('Search Google Fonts...', 'maple-local-fonts'); ?>"
autocomplete="off" />
<span class="spinner mlf-search-spinner" id="mlf-search-spinner"></span>
</div>
<p class="description"><?php esc_html_e('Type at least 2 characters to search.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row">
<label><?php esc_html_e('Styles', 'maple-local-fonts'); ?></label>
<div class="mlf-checkbox-grid mlf-checkbox-grid-small">
<label class="mlf-checkbox-label">
<input type="checkbox" name="styles[]" value="normal" checked />
<span><?php esc_html_e('Normal', 'maple-local-fonts'); ?></span>
</label>
<label class="mlf-checkbox-label">
<input type="checkbox" name="styles[]" value="italic" />
<span><?php esc_html_e('Italic', 'maple-local-fonts'); ?></span>
<!-- Search Results -->
<div class="mlf-search-results" id="mlf-search-results" style="display: none;">
<div class="mlf-results-list" id="mlf-results-list"></div>
</div>
<!-- Selected Font (hidden until a font is selected) -->
<div class="mlf-selected-font" id="mlf-selected-font" style="display: none;">
<div class="mlf-selected-font-header">
<span class="mlf-selected-label"><?php esc_html_e('Selected Font:', 'maple-local-fonts'); ?></span>
<span class="mlf-selected-name" id="mlf-selected-name"></span>
<button type="button" class="mlf-change-font" id="mlf-change-font">
<?php esc_html_e('Change', 'maple-local-fonts'); ?>
</button>
</div>
<!-- Hidden input for font name -->
<input type="hidden" id="mlf-font-name" name="font_name" value="" />
<div class="mlf-form-row mlf-italic-row">
<label class="mlf-checkbox-label mlf-italic-toggle">
<input type="checkbox" name="include_italic" id="mlf-include-italic" value="1" checked />
<span><?php esc_html_e('Include Italic styles', 'maple-local-fonts'); ?></span>
</label>
<p class="description"><?php esc_html_e('Italic styles are useful for emphasized text. Uncheck to reduce download size.', 'maple-local-fonts'); ?></p>
</div>
</div>
<div class="mlf-form-row mlf-form-row-info">
<span class="mlf-file-count">
<?php esc_html_e('Files to download:', 'maple-local-fonts'); ?>
<strong id="mlf-file-count">2</strong>
</span>
</div>
<!-- Font Preview Section -->
<div class="mlf-form-row mlf-preview-section" id="mlf-preview-section" style="display: none;">
<label><?php esc_html_e('Preview', 'maple-local-fonts'); ?></label>
<div class="mlf-preview-box" id="mlf-preview-box">
<div class="mlf-preview-text" id="mlf-preview-text">
<span class="mlf-preview-sample mlf-preview-heading"><?php esc_html_e('The quick brown fox jumps over the lazy dog', 'maple-local-fonts'); ?></span>
<span class="mlf-preview-sample mlf-preview-paragraph"><?php esc_html_e('ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789', 'maple-local-fonts'); ?></span>
</div>
<div class="mlf-preview-loading" id="mlf-preview-loading" style="display: none;">
<span class="spinner is-active"></span>
<span><?php esc_html_e('Loading preview...', 'maple-local-fonts'); ?></span>
</div>
<div class="mlf-preview-error" id="mlf-preview-error" style="display: none;">
<?php esc_html_e('Could not load font preview. The font may not exist on Google Fonts.', 'maple-local-fonts'); ?>
</div>
<div class="mlf-form-row mlf-form-row-submit">
<button type="submit" class="button button-primary" id="mlf-download-btn">
<?php esc_html_e('Download & Install', 'maple-local-fonts'); ?>
</button>
<span class="spinner" id="mlf-spinner"></span>
</div>
<p class="description"><?php esc_html_e('Preview is loaded directly from Google Fonts. After installation, the font will be served locally.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row mlf-form-row-submit">
<button type="submit" class="button button-primary" id="mlf-download-btn">
<?php esc_html_e('Download & Install', 'maple-local-fonts'); ?>
</button>
<span class="spinner" id="mlf-spinner"></span>
</div>
<div id="mlf-message" class="mlf-message" style="display: none;"></div>
</form>
<div class="mlf-info-note">
<span class="dashicons dashicons-info-outline"></span>
<span><?php esc_html_e('All font weights (100-900) will be downloaded automatically. Variable fonts are used when available for better performance.', 'maple-local-fonts'); ?></span>
</div>
</div>
<!-- Installed Fonts Section -->
@ -135,7 +108,7 @@ class MLF_Admin_Page {
<h2><?php esc_html_e('Installed Fonts', 'maple-local-fonts'); ?></h2>
<?php if (empty($installed_fonts)) : ?>
<p class="mlf-no-fonts"><?php esc_html_e('No fonts installed yet.', 'maple-local-fonts'); ?></p>
<p class="mlf-no-fonts"><?php esc_html_e('No fonts installed yet. Search and select a font above to get started.', 'maple-local-fonts'); ?></p>
<?php else : ?>
<div id="mlf-font-list" class="mlf-font-list">
<?php foreach ($installed_fonts as $font) : ?>
@ -167,20 +140,23 @@ class MLF_Admin_Page {
<div class="mlf-section mlf-info-section">
<?php if (wp_is_block_theme()) : ?>
<div class="mlf-info-box">
<span class="dashicons dashicons-info"></span>
<p>
<?php
printf(
/* translators: %s: link to WordPress Editor */
esc_html__('Use %s to apply fonts to your site.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('site-editor.php?path=%2Fwp_global_styles')) . '">' . esc_html__('Appearance → Editor → Styles → Typography', 'maple-local-fonts') . '</a>'
);
?>
</p>
<span class="dashicons dashicons-editor-textcolor"></span>
<div>
<p><strong><?php esc_html_e('How to use your fonts', 'maple-local-fonts'); ?></strong></p>
<p>
<?php
printf(
/* translators: %s: link to WordPress Editor */
esc_html__('Go to %s to apply fonts to your site.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('site-editor.php?path=%2Fwp_global_styles')) . '">' . esc_html__('Appearance → Editor → Styles → Typography', 'maple-local-fonts') . '</a>'
);
?>
</p>
</div>
</div>
<?php else : ?>
<div class="mlf-info-box mlf-info-box-classic">
<span class="dashicons dashicons-info"></span>
<span class="dashicons dashicons-editor-textcolor"></span>
<div class="mlf-classic-theme-info">
<p><strong><?php esc_html_e('Classic Theme Detected', 'maple-local-fonts'); ?></strong></p>
<p><?php esc_html_e('Your theme does not support the Full Site Editor. To use imported fonts, add custom CSS to your theme:', 'maple-local-fonts'); ?></p>

View file

@ -74,36 +74,13 @@ class MLF_Ajax_Handler {
wp_send_json_error(['message' => __('Font name is too long.', 'maple-local-fonts')]);
}
// Validate weights
$weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : [];
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
if (empty($weights)) {
wp_send_json_error(['message' => __('At least one weight is required.', 'maple-local-fonts')]);
}
if (count($weights) > MLF_MAX_WEIGHTS_PER_FONT) {
wp_send_json_error(['message' => __('Too many weights selected.', 'maple-local-fonts')]);
}
// Validate styles
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
$allowed_styles = ['normal', 'italic'];
// Sanitize each style value before filtering
$styles = array_map('sanitize_text_field', $styles);
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
if (empty($styles)) {
wp_send_json_error(['message' => __('At least one style is required.', 'maple-local-fonts')]);
}
// Validate include_italic (boolean)
$include_italic = isset($_POST['include_italic']) && $_POST['include_italic'] === '1';
// 5. PROCESS REQUEST
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $weights, $styles);
$download_result = $downloader->download($font_name, $include_italic);
if (is_wp_error($download_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($download_result)]);
@ -214,12 +191,12 @@ class MLF_Ajax_Handler {
'invalid_path' => __('Invalid file path.', 'maple-local-fonts'),
'invalid_url' => __('Invalid font URL.', 'maple-local-fonts'),
'invalid_name' => __('Invalid font name.', 'maple-local-fonts'),
'invalid_weights' => __('No valid weights specified.', 'maple-local-fonts'),
'invalid_styles' => __('No valid styles specified.', 'maple-local-fonts'),
'not_found' => __('Font not found.', 'maple-local-fonts'),
'not_ours' => __('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts'),
'response_too_large' => __('The font data is too large to process. Please try selecting fewer weights.', 'maple-local-fonts'),
'response_too_large' => __('The font data is too large to process.', 'maple-local-fonts'),
'file_too_large' => __('The font file is too large to download.', 'maple-local-fonts'),
'no_variable' => __('Variable font not available, trying static fonts...', 'maple-local-fonts'),
'no_fonts' => __('No font files found. The font may not support the requested styles.', 'maple-local-fonts'),
];
return $messages[$code] ?? __('An unexpected error occurred. Please try again.', 'maple-local-fonts');

View file

@ -13,6 +13,7 @@ if (!defined('ABSPATH')) {
* Class MLF_Font_Downloader
*
* Handles downloading fonts from Google Fonts CSS2 API.
* Attempts variable fonts first, falls back to static fonts.
*/
class MLF_Font_Downloader {
@ -23,38 +24,150 @@ class MLF_Font_Downloader {
*/
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* All available font weights.
*
* @var array
*/
private $all_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Download a font from Google Fonts.
*
* @param string $font_name Font family name.
* @param array $weights Weights to download.
* @param array $styles Styles to download.
* Attempts variable font first, falls back to static if not available.
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
public function download($font_name, $weights, $styles) {
// Validate inputs
public function download($font_name, $include_italic = true) {
// Validate font name
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid font name');
}
$weights = array_intersect(array_map('absint', $weights), [100, 200, 300, 400, 500, 600, 700, 800, 900]);
if (empty($weights)) {
return new WP_Error('invalid_weights', 'No valid weights specified');
if (strlen($font_name) > 100) {
return new WP_Error('invalid_name', 'Font name too long');
}
$styles = array_intersect($styles, ['normal', 'italic']);
if (empty($styles)) {
return new WP_Error('invalid_styles', 'No valid styles specified');
// Try variable font first
$result = $this->try_variable_font($font_name, $include_italic);
if (!is_wp_error($result)) {
return $result;
}
// Fall back to static fonts
return $this->download_static_fonts($font_name, $include_italic);
}
/**
* Attempt to download variable font.
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
private function try_variable_font($font_name, $include_italic) {
$font_slug = sanitize_title($font_name);
$downloaded = [];
// Try to fetch variable font CSS (roman/upright)
$css = $this->fetch_variable_css($font_name, false);
if (is_wp_error($css)) {
return $css;
}
// Parse and download roman variable font
$roman_faces = $this->parse_variable_css($css, $font_name);
if (is_wp_error($roman_faces) || empty($roman_faces)) {
return new WP_Error('no_variable', 'Variable font not available');
}
// Download roman variable font file(s)
foreach ($roman_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
'normal',
true // is_variable
);
if (!is_wp_error($result)) {
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => 'normal',
'is_variable' => true,
];
}
}
// Try italic variable font if requested
if ($include_italic) {
$italic_css = $this->fetch_variable_css($font_name, true);
if (!is_wp_error($italic_css)) {
$italic_faces = $this->parse_variable_css($italic_css, $font_name);
if (!is_wp_error($italic_faces) && !empty($italic_faces)) {
foreach ($italic_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
'italic',
true
);
if (!is_wp_error($result)) {
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => 'italic',
'is_variable' => true,
];
}
}
}
}
}
if (empty($downloaded)) {
return new WP_Error('download_failed', 'Could not download variable font files');
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
'is_variable' => true,
];
}
/**
* Download static fonts (fallback when variable not available).
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
private function download_static_fonts($font_name, $include_italic) {
$styles = $include_italic ? ['normal', 'italic'] : ['normal'];
// Fetch CSS from Google
$css = $this->fetch_css($font_name, $weights, $styles);
$css = $this->fetch_static_css($font_name, $this->all_weights, $styles);
if (is_wp_error($css)) {
return $css;
}
// Parse CSS to get font face data
$font_faces = $this->parse_css($css, $font_name);
$font_faces = $this->parse_static_css($css, $font_name);
if (is_wp_error($font_faces)) {
return $font_faces;
}
@ -62,42 +175,125 @@ class MLF_Font_Downloader {
// Download each font file
$font_slug = sanitize_title($font_name);
$downloaded = $this->download_files($font_faces, $font_slug);
if (is_wp_error($downloaded)) {
return $downloaded;
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
'is_variable' => false,
];
}
/**
* Build Google Fonts CSS2 API URL.
* Fetch variable font CSS from Google Fonts API.
*
* @param string $font_name Font family name.
* @param bool $italic Whether to fetch italic variant.
* @return string|WP_Error CSS content or error.
*/
private function fetch_variable_css($font_name, $italic = false) {
$family = str_replace(' ', '+', $font_name);
if ($italic) {
// Request italic variable font
$url = "https://fonts.googleapis.com/css2?family={$family}:ital,wght@1,100..900&display=swap";
} else {
// Request roman variable font
$url = "https://fonts.googleapis.com/css2?family={$family}:wght@100..900&display=swap";
}
return $this->fetch_css($url);
}
/**
* Fetch static font CSS from Google Fonts API.
*
* @param string $font_name Font family name.
* @param array $weights Weights to fetch.
* @param array $styles Styles to fetch.
* @return string|WP_Error CSS content or error.
*/
private function fetch_static_css($font_name, $weights, $styles) {
$url = $this->build_static_url($font_name, $weights, $styles);
return $this->fetch_css($url);
}
/**
* Fetch CSS from a Google Fonts URL.
*
* @param string $url Google Fonts CSS URL.
* @return string|WP_Error CSS content or error.
*/
private function fetch_css($url) {
// Validate URL
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'Invalid Google Fonts URL');
}
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('request_failed', $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found');
}
if ($status !== 200) {
return new WP_Error('http_error', 'HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
// Check CSS response size
$max_size = defined('MLF_MAX_CSS_SIZE') ? MLF_MAX_CSS_SIZE : 512 * 1024;
if (strlen($css) > $max_size) {
return new WP_Error('response_too_large', 'CSS response exceeds maximum size limit');
}
// Verify we got WOFF2
if (strpos($css, '.woff2)') === false) {
return new WP_Error('wrong_format', 'Did not receive WOFF2 format');
}
return $css;
}
/**
* Build static font URL.
*
* @param string $font_name Font family name.
* @param array $weights Array of weights.
* @param array $styles Array of styles.
* @return string Google Fonts CSS2 URL.
*/
private function build_url($font_name, $weights, $styles) {
// URL-encode font name (spaces become +)
private function build_static_url($font_name, $weights, $styles) {
$family = str_replace(' ', '+', $font_name);
// Sort for consistent URLs
sort($weights);
$has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true);
// If only normal styles, simpler format
if ($has_normal && !$has_italic) {
$wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
}
// Full format with ital axis
$variations = [];
foreach ($weights as $weight) {
if ($has_normal) {
@ -113,122 +309,102 @@ class MLF_Font_Downloader {
}
/**
* Fetch CSS from Google Fonts API.
* Parse variable font CSS.
*
* @param string $font_name Font family name.
* @param array $weights Weights to fetch.
* @param array $styles Styles to fetch.
* @return string|WP_Error CSS content or error.
*/
private function fetch_css($font_name, $weights, $styles) {
$url = $this->build_url($font_name, $weights, $styles);
// Validate URL
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'Invalid Google Fonts URL');
}
// CRITICAL: Must use modern browser user-agent to get WOFF2
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('request_failed', $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found');
}
if ($status !== 200) {
return new WP_Error('http_error', 'HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
// Check CSS response size to prevent memory issues
$max_size = defined('MLF_MAX_CSS_SIZE') ? MLF_MAX_CSS_SIZE : 512 * 1024;
if (strlen($css) > $max_size) {
return new WP_Error('response_too_large', 'CSS response exceeds maximum size limit');
}
// Verify we got WOFF2 (sanity check)
if (strpos($css, '.woff2)') === false) {
return new WP_Error('wrong_format', 'Did not receive WOFF2 format');
}
return $css;
}
/**
* Parse Google Fonts CSS and extract font face data.
*
* @param string $css CSS content from Google Fonts.
* @param string $css CSS content.
* @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error.
*/
private function parse_css($css, $font_name) {
private function parse_variable_css($css, $font_name) {
$font_faces = [];
// Match all @font-face blocks
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'Could not parse CSS - no @font-face rules found');
return new WP_Error('parse_failed', 'No @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = $this->parse_font_face_block($block);
$face_data = $this->parse_font_face_block($block, true);
if (is_wp_error($face_data)) {
continue; // Skip malformed blocks
continue;
}
// Verify font family matches
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
// For variable fonts, prefer latin subset
$key = $face_data['weight'] . '-' . $face_data['style'];
$is_latin = $this->is_latin_subset($face_data['unicode_range']);
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
return array_values($font_faces);
}
/**
* Parse static font CSS.
*
* @param string $css CSS content.
* @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error.
*/
private function parse_static_css($css, $font_name) {
$font_faces = [];
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'No @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = $this->parse_font_face_block($block, false);
if (is_wp_error($face_data)) {
continue;
}
// Verify font family matches (security)
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
// Create unique key for weight+style combo
$key = $face_data['weight'] . '-' . $face_data['style'];
// Prefer latin subset (usually comes after latin-ext)
$is_latin = $this->is_latin_subset($face_data['unicode_range']);
// Only store if:
// 1. We don't have this weight/style yet, OR
// 2. This is latin and replaces non-latin
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
if (empty($font_faces)) {
return new WP_Error('no_fonts', 'No valid font faces found in CSS');
return new WP_Error('no_fonts', 'No valid font faces found');
}
// Limit number of font faces to prevent excessive downloads
// Limit number of font faces
$max_faces = defined('MLF_MAX_FONT_FACES') ? MLF_MAX_FONT_FACES : 20;
$font_faces_array = array_values($font_faces);
if (count($font_faces_array) > $max_faces) {
$font_faces_array = array_slice($font_faces_array, 0, $max_faces);
$result = array_values($font_faces);
if (count($result) > $max_faces) {
$result = array_slice($result, 0, $max_faces);
}
return $font_faces_array;
return $result;
}
/**
* Parse a single @font-face block.
*
* @param string $block Content inside @font-face { }.
* @param string $block Content inside @font-face { }.
* @param bool $is_variable Whether this is a variable font.
* @return array|WP_Error Parsed data or error.
*/
private function parse_font_face_block($block) {
private function parse_font_face_block($block, $is_variable = false) {
$data = [];
// Extract font-family
@ -238,9 +414,9 @@ class MLF_Font_Downloader {
return new WP_Error('missing_family', 'Missing font-family');
}
// Extract font-weight
if (preg_match('/font-weight:\s*(\d+);/i', $block, $m)) {
$data['weight'] = $m[1];
// Extract font-weight (can be single value or range for variable)
if (preg_match('/font-weight:\s*(\d+(?:\s+\d+)?);/i', $block, $m)) {
$data['weight'] = trim($m[1]);
} else {
return new WP_Error('missing_weight', 'Missing font-weight');
}
@ -249,7 +425,7 @@ class MLF_Font_Downloader {
if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) {
$data['style'] = $m[1];
} else {
$data['style'] = 'normal'; // Default
$data['style'] = 'normal';
}
// Extract src URL - MUST be fonts.gstatic.com
@ -259,7 +435,7 @@ class MLF_Font_Downloader {
return new WP_Error('missing_src', 'Missing or invalid src URL');
}
// Extract unicode-range (optional, for subset detection)
// Extract unicode-range
if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) {
$data['unicode_range'] = trim($m[1]);
} else {
@ -277,11 +453,9 @@ class MLF_Font_Downloader {
*/
private function is_latin_subset($range) {
if (empty($range)) {
return true; // Assume latin if no range specified
return true;
}
// Latin subset typically includes basic ASCII range
// and does NOT include extended Latin (U+0100+) as primary
if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) {
return true;
}
@ -305,7 +479,8 @@ class MLF_Font_Downloader {
$face['url'],
$font_slug,
$face['weight'],
$face['style']
$face['style'],
false
);
if (is_wp_error($result)) {
@ -314,13 +489,13 @@ class MLF_Font_Downloader {
}
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => $face['style'],
'path' => $result,
'weight' => $face['weight'],
'style' => $face['style'],
'is_variable' => false,
];
}
// If no files downloaded, return error
if (empty($downloaded)) {
return new WP_Error(
'download_failed',
@ -332,45 +507,45 @@ class MLF_Font_Downloader {
}
/**
* Download a single WOFF2 file from Google Fonts.
* Download a single WOFF2 file.
*
* @param string $url Google Fonts static URL.
* @param string $font_slug Font slug for filename.
* @param string $weight Font weight.
* @param string $style Font style.
* @param string $url Google Fonts static URL.
* @param string $font_slug Font slug for filename.
* @param string $weight Font weight (single or range).
* @param string $style Font style.
* @param bool $is_variable Whether this is a variable font.
* @return string|WP_Error Local file path or error.
*/
private function download_single_file($url, $font_slug, $weight, $style) {
// Validate URL is from Google
private function download_single_file($url, $font_slug, $weight, $style, $is_variable = false) {
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'URL is not from Google Fonts');
}
// Build local filename
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight);
// Build filename
$weight_slug = str_replace(' ', '-', $weight);
if ($is_variable) {
$filename = sprintf('%s_%s_variable.woff2', $font_slug, $style);
} else {
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight_slug);
}
$filename = sanitize_file_name($filename);
// Validate filename
$filename = $this->sanitize_font_filename($filename);
if ($filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename');
}
// Get destination path
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $filename;
// Validate destination path before any file operations
if (!$this->validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid destination path');
}
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
// Download file
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
@ -378,31 +553,29 @@ class MLF_Font_Downloader {
]);
if (is_wp_error($response)) {
return new WP_Error('download_failed', 'Failed to download font file: ' . $response->get_error_message());
return new WP_Error('download_failed', 'Failed to download: ' . $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Font download returned HTTP ' . $status);
return new WP_Error('http_error', 'Download returned HTTP ' . $status);
}
$content = wp_remote_retrieve_body($response);
if (empty($content)) {
return new WP_Error('empty_file', 'Downloaded font file is empty');
return new WP_Error('empty_file', 'Downloaded file is empty');
}
// Check font file size to prevent memory issues
$max_size = defined('MLF_MAX_FONT_FILE_SIZE') ? MLF_MAX_FONT_FILE_SIZE : 5 * 1024 * 1024;
if (strlen($content) > $max_size) {
return new WP_Error('file_too_large', 'Font file exceeds maximum size limit');
return new WP_Error('file_too_large', 'Font file exceeds maximum size');
}
// Verify it looks like a WOFF2 file (magic bytes: wOF2)
// Verify WOFF2 magic bytes
if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2');
}
// Write file using WP Filesystem
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
@ -417,10 +590,10 @@ class MLF_Font_Downloader {
}
/**
* Validate that a URL is a legitimate Google Fonts URL.
* Validate Google Fonts URL.
*
* @param string $url URL to validate.
* @return bool True if valid Google Fonts URL.
* @return bool True if valid.
*/
private function is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url);
@ -429,7 +602,6 @@ class MLF_Font_Downloader {
return false;
}
// Only allow Google Fonts domains
$allowed_hosts = [
'fonts.googleapis.com',
'fonts.gstatic.com',
@ -439,26 +611,22 @@ class MLF_Font_Downloader {
}
/**
* Sanitize and validate a font filename.
* Sanitize font filename.
*
* @param string $filename The filename to validate.
* @return string|false Sanitized filename or false if invalid.
* @param string $filename Filename to sanitize.
* @return string|false Sanitized filename or false.
*/
private function sanitize_font_filename($filename) {
// WordPress sanitization first
$filename = sanitize_file_name($filename);
// Must have .woff2 extension
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// No path components
if ($filename !== basename($filename)) {
return false;
}
// Reasonable length
if (strlen($filename) > 200) {
return false;
}
@ -467,24 +635,21 @@ class MLF_Font_Downloader {
}
/**
* Validate that a path is within the WordPress fonts directory.
* Validate font path is within fonts directory.
*
* @param string $path Full path to validate.
* @return bool True if path is safe, false otherwise.
* @param string $path Path to validate.
* @return bool True if valid.
*/
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) {
// Directory doesn't exist yet, check parent
$parent_dir = dirname($dir);
$real_parent = realpath($parent_dir);
if ($real_parent === false) {
@ -498,7 +663,6 @@ class MLF_Font_Downloader {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
}

View file

@ -30,33 +30,44 @@ class MLF_Font_Registry {
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'publish',
'post_status' => 'any',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Get font directory
// 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']);
$font_faces[] = [
// 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,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'fontWeight' => $weight,
'src' => 'file:./' . $filename,
];
$font_faces[] = $face_data;
}
// Build font family settings
// 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' => sprintf('"%s", sans-serif', $font_name),
'fontFamily' => "'{$font_name}', {$fallback}",
'fontFace' => $font_faces,
];
@ -67,7 +78,7 @@ class MLF_Font_Registry {
'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;
@ -77,13 +88,14 @@ class MLF_Font_Registry {
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Create font face posts (children)
// 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' => $file['weight'],
'fontWeight' => $weight,
'fontStyle' => $file['style'],
'src' => 'file:./' . $filename,
];
@ -91,15 +103,15 @@ class MLF_Font_Registry {
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_title' => sprintf('%s %s %s', $font_name, $file['weight'], $file['style']),
'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 font caches
delete_transient('wp_font_library_fonts');
delete_transient('mlf_imported_fonts_list');
// Clear all font-related caches
$this->clear_font_caches();
return $family_id;
}
@ -154,13 +166,40 @@ class MLF_Font_Registry {
// Delete family post
wp_delete_post($family_id, true);
// Clear caches
delete_transient('wp_font_library_fonts');
delete_transient('mlf_imported_fonts_list');
// Clear all font-related caches
$this->clear_font_caches();
return true;
}
/**
* 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 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.
*
@ -193,9 +232,9 @@ class MLF_Font_Registry {
// 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, // Max 100 fonts × 9 weights × 2 styles = 1800, but limit reasonably
'post_status' => 'publish',
'post_type' => 'wp_font_face',
'posts_per_page' => 1000,
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
@ -234,7 +273,11 @@ class MLF_Font_Registry {
// Sort variants by weight then style
usort($variants, function($a, $b) {
$weight_cmp = intval($a['weight']) - intval($b['weight']);
// 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;
}

View file

@ -0,0 +1,265 @@
<?php
/**
* Font Search for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Search
*
* Handles searching Google Fonts metadata.
*/
class MLF_Font_Search {
/**
* Cache key for fonts metadata.
*
* @var string
*/
const CACHE_KEY = 'mlf_google_fonts_metadata';
/**
* Cache duration in seconds (24 hours).
*
* @var int
*/
const CACHE_DURATION = DAY_IN_SECONDS;
/**
* Google Fonts metadata URL.
*
* @var string
*/
const METADATA_URL = 'https://fonts.google.com/metadata/fonts';
/**
* Maximum metadata response size (2MB).
*
* @var int
*/
const MAX_METADATA_SIZE = 2 * 1024 * 1024;
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
add_action('wp_ajax_mlf_search_fonts', [$this, 'handle_search']);
// No nopriv - admin only
// Initialize rate limiter: 30 requests per minute (search can be rapid while typing)
$this->rate_limiter = new MLF_Rate_Limiter(30, 60);
}
/**
* Handle font search AJAX request.
*/
public function handle_search() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_search_fonts', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('search')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
$query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : '';
if (strlen($query) < 2) {
wp_send_json_success(['fonts' => []]);
}
// Limit query length
if (strlen($query) > 100) {
wp_send_json_error(['message' => __('Search query too long.', 'maple-local-fonts')]);
}
// 5. PROCESS REQUEST
$fonts = $this->get_fonts_metadata();
if (is_wp_error($fonts)) {
wp_send_json_error(['message' => $fonts->get_error_message()]);
}
// Search fonts
$results = $this->search_fonts($fonts, $query);
wp_send_json_success(['fonts' => $results]);
}
/**
* Get Google Fonts metadata (cached).
*
* @return array|WP_Error Array of fonts or error.
*/
public function get_fonts_metadata() {
// Check cache first
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return $cached;
}
// Fetch from Google
$response = wp_remote_get(self::METADATA_URL, [
'timeout' => 30,
'sslverify' => true,
]);
if (is_wp_error($response)) {
return new WP_Error('fetch_failed', __('Could not fetch fonts list from Google.', 'maple-local-fonts'));
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('fetch_failed', __('Google Fonts returned an error.', 'maple-local-fonts'));
}
$body = wp_remote_retrieve_body($response);
// Validate response size
if (strlen($body) > self::MAX_METADATA_SIZE) {
return new WP_Error('response_too_large', __('Font metadata response too large.', 'maple-local-fonts'));
}
// Validate response is not empty
if (empty($body)) {
return new WP_Error('empty_response', __('Empty response from Google Fonts.', 'maple-local-fonts'));
}
// Google's metadata has a )]}' prefix for security, remove it
$body = preg_replace('/^\)\]\}\'\s*/', '', $body);
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts'));
}
if (!$data || !isset($data['familyMetadataList'])) {
return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts'));
}
// Process and simplify the data
$fonts = [];
foreach ($data['familyMetadataList'] as $font) {
$fonts[] = [
'family' => $font['family'],
'category' => $font['category'] ?? 'sans-serif',
'variants' => $font['fonts'] ?? [],
'subsets' => $font['subsets'] ?? ['latin'],
];
}
// Cache for 24 hours
set_transient(self::CACHE_KEY, $fonts, self::CACHE_DURATION);
return $fonts;
}
/**
* Search fonts by query.
*
* @param array $fonts All fonts.
* @param string $query Search query.
* @return array Matching fonts (max 20).
*/
private function search_fonts($fonts, $query) {
$query_lower = strtolower($query);
$exact_matches = [];
$starts_with = [];
$contains = [];
foreach ($fonts as $font) {
$family_lower = strtolower($font['family']);
// Exact match
if ($family_lower === $query_lower) {
$exact_matches[] = $this->format_font_result($font);
}
// Starts with query
elseif (strpos($family_lower, $query_lower) === 0) {
$starts_with[] = $this->format_font_result($font);
}
// Contains query
elseif (strpos($family_lower, $query_lower) !== false) {
$contains[] = $this->format_font_result($font);
}
}
// Combine results: exact matches first, then starts with, then contains
$results = array_merge($exact_matches, $starts_with, $contains);
// Limit to 20 results
return array_slice($results, 0, 20);
}
/**
* Format a font for the search results.
*
* @param array $font Font data.
* @return array Formatted font.
*/
private function format_font_result($font) {
// Check if font has variable version
$has_variable = false;
$has_italic = false;
$weights = [];
if (!empty($font['variants'])) {
foreach ($font['variants'] as $variant => $data) {
// Variable fonts have ranges like "100..900"
if (strpos($variant, '..') !== false) {
$has_variable = true;
}
if (strpos($variant, 'i') !== false || strpos($variant, 'italic') !== false) {
$has_italic = true;
}
// Extract weight
$weight = preg_replace('/[^0-9]/', '', $variant);
if ($weight && !in_array($weight, $weights, true)) {
$weights[] = $weight;
}
}
}
// Sort weights
sort($weights, SORT_NUMERIC);
return [
'family' => $font['family'],
'category' => $font['category'],
'has_variable' => $has_variable,
'has_italic' => $has_italic,
'weights' => $weights,
];
}
/**
* Clear the fonts metadata cache.
*/
public function clear_cache() {
delete_transient(self::CACHE_KEY);
}
}

View file

@ -258,8 +258,7 @@ class MLF_Rest_Controller extends WP_REST_Controller {
*/
public function create_item($request) {
$font_name = sanitize_text_field($request->get_param('font_name'));
$weights = array_map('absint', (array) $request->get_param('weights'));
$styles = array_map('sanitize_text_field', (array) $request->get_param('styles'));
$include_italic = (bool) $request->get_param('include_italic');
// Validate font name
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
@ -278,43 +277,9 @@ class MLF_Rest_Controller extends WP_REST_Controller {
);
}
// Validate weights
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
if (empty($weights)) {
return new WP_Error(
'rest_invalid_param',
__('At least one valid weight is required.', 'maple-local-fonts'),
['status' => 400]
);
}
if (count($weights) > MLF_MAX_WEIGHTS_PER_FONT) {
return new WP_Error(
'rest_invalid_param',
__('Too many weights selected.', 'maple-local-fonts'),
['status' => 400]
);
}
// Validate styles
$allowed_styles = ['normal', 'italic'];
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
if (empty($styles)) {
return new WP_Error(
'rest_invalid_param',
__('At least one valid style is required.', 'maple-local-fonts'),
['status' => 400]
);
}
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $weights, $styles);
$download_result = $downloader->download($font_name, $include_italic);
if (is_wp_error($download_result)) {
return new WP_Error(
@ -455,23 +420,10 @@ class MLF_Rest_Controller extends WP_REST_Controller {
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
'weights' => [
'description' => __('Array of font weights to download.', 'maple-local-fonts'),
'type' => 'array',
'required' => true,
'items' => [
'type' => 'integer',
'enum' => [100, 200, 300, 400, 500, 600, 700, 800, 900],
],
],
'styles' => [
'description' => __('Array of font styles to download.', 'maple-local-fonts'),
'type' => 'array',
'required' => true,
'items' => [
'type' => 'string',
'enum' => ['normal', 'italic'],
],
'include_italic' => [
'description' => __('Whether to include italic styles.', 'maple-local-fonts'),
'type' => 'boolean',
'default' => true,
],
];
}