diff --git a/native/wordpress/maple-fonts-wp/includes/class-mlf-ajax-handler.php b/native/wordpress/maple-fonts-wp/includes/class-mlf-ajax-handler.php
index 23c55fb..972a86a 100644
--- a/native/wordpress/maple-fonts-wp/includes/class-mlf-ajax-handler.php
+++ b/native/wordpress/maple-fonts-wp/includes/class-mlf-ajax-handler.php
@@ -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');
diff --git a/native/wordpress/maple-fonts-wp/includes/class-mlf-font-downloader.php b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-downloader.php
index 63f900c..c9584d6 100644
--- a/native/wordpress/maple-fonts-wp/includes/class-mlf-font-downloader.php
+++ b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-downloader.php
@@ -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;
}
}
diff --git a/native/wordpress/maple-fonts-wp/includes/class-mlf-font-registry.php b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-registry.php
index db9ec47..3241108 100644
--- a/native/wordpress/maple-fonts-wp/includes/class-mlf-font-registry.php
+++ b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-registry.php
@@ -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;
}
diff --git a/native/wordpress/maple-fonts-wp/includes/class-mlf-font-search.php b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-search.php
new file mode 100644
index 0000000..59ac8e1
--- /dev/null
+++ b/native/wordpress/maple-fonts-wp/includes/class-mlf-font-search.php
@@ -0,0 +1,265 @@
+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);
+ }
+}
diff --git a/native/wordpress/maple-fonts-wp/includes/class-mlf-rest-controller.php b/native/wordpress/maple-fonts-wp/includes/class-mlf-rest-controller.php
index a17a716..e397758 100644
--- a/native/wordpress/maple-fonts-wp/includes/class-mlf-rest-controller.php
+++ b/native/wordpress/maple-fonts-wp/includes/class-mlf-rest-controller.php
@@ -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,
],
];
}
diff --git a/native/wordpress/maple-fonts-wp/maple-local-fonts.php b/native/wordpress/maple-fonts-wp/maple-local-fonts.php
index c0c60ea..48d0317 100644
--- a/native/wordpress/maple-fonts-wp/maple-local-fonts.php
+++ b/native/wordpress/maple-fonts-wp/maple-local-fonts.php
@@ -122,6 +122,7 @@ function mlf_init() {
if (is_admin()) {
new MLF_Admin_Page();
new MLF_Ajax_Handler();
+ new MLF_Font_Search();
}
}
add_action('plugins_loaded', 'mlf_init', 20);
@@ -155,9 +156,9 @@ function mlf_get_capability() {
*/
function mlf_register_menu() {
add_submenu_page(
- 'themes.php',
- __('Maple Local Fonts', 'maple-local-fonts'),
- __('Local Fonts', 'maple-local-fonts'),
+ 'options-general.php',
+ __('Maple Fonts', 'maple-local-fonts'),
+ __('Maple Fonts', 'maple-local-fonts'),
mlf_get_capability(),
'maple-local-fonts',
'mlf_render_admin_page'
@@ -165,6 +166,23 @@ function mlf_register_menu() {
}
add_action('admin_menu', 'mlf_register_menu');
+/**
+ * Add settings link to plugin action links.
+ *
+ * @param array $links Existing plugin action links.
+ * @return array Modified plugin action links.
+ */
+function mlf_plugin_action_links($links) {
+ $settings_link = sprintf(
+ '
%s',
+ esc_url(admin_url('options-general.php?page=maple-local-fonts')),
+ esc_html__('Settings', 'maple-local-fonts')
+ );
+ array_unshift($links, $settings_link);
+ return $links;
+}
+add_filter('plugin_action_links_' . MLF_PLUGIN_BASENAME, 'mlf_plugin_action_links');
+
/**
* Render admin page (delegates to MLF_Admin_Page).
*/
@@ -179,7 +197,7 @@ function mlf_render_admin_page() {
* @param string $hook The current admin page hook.
*/
function mlf_enqueue_admin_assets($hook) {
- if ($hook !== 'appearance_page_maple-local-fonts') {
+ if ($hook !== 'settings_page_maple-local-fonts') {
return;
}
@@ -202,14 +220,17 @@ function mlf_enqueue_admin_assets($hook) {
'ajaxUrl' => admin_url('admin-ajax.php'),
'downloadNonce' => wp_create_nonce('mlf_download_font'),
'deleteNonce' => wp_create_nonce('mlf_delete_font'),
+ 'searchNonce' => wp_create_nonce('mlf_search_fonts'),
'strings' => [
- 'downloading' => __('Downloading...', 'maple-local-fonts'),
- 'deleting' => __('Deleting...', 'maple-local-fonts'),
- 'confirmDelete' => __('Are you sure you want to delete this font?', 'maple-local-fonts'),
- 'error' => __('An error occurred. Please try again.', 'maple-local-fonts'),
- 'selectWeight' => __('Please select at least one weight.', 'maple-local-fonts'),
- 'selectStyle' => __('Please select at least one style.', 'maple-local-fonts'),
- 'enterFontName' => __('Please enter a font name.', 'maple-local-fonts'),
+ 'downloading' => __('Downloading...', 'maple-local-fonts'),
+ 'deleting' => __('Deleting...', 'maple-local-fonts'),
+ 'confirmDelete' => __('Are you sure you want to delete this font?', 'maple-local-fonts'),
+ 'error' => __('An error occurred. Please try again.', 'maple-local-fonts'),
+ 'searchPlaceholder' => __('Search Google Fonts...', 'maple-local-fonts'),
+ 'searching' => __('Searching...', 'maple-local-fonts'),
+ 'noResults' => __('No fonts found. Try a different search term.', 'maple-local-fonts'),
+ 'selectFont' => __('Select a font from the search results above.', 'maple-local-fonts'),
+ 'previewText' => __('Maple Fonts Preview', 'maple-local-fonts'),
],
]);
}
diff --git a/native/wordpress/maple-fonts-wp/uninstall.php b/native/wordpress/maple-fonts-wp/uninstall.php
index 875b2fb..992125d 100644
--- a/native/wordpress/maple-fonts-wp/uninstall.php
+++ b/native/wordpress/maple-fonts-wp/uninstall.php
@@ -74,13 +74,14 @@ function mlf_uninstall() {
wp_delete_post($font_id, true);
}
- $processed += count($fonts);
+ $fonts_count = count($fonts);
+ $processed += $fonts_count;
// Free memory
unset($fonts, $all_faces);
// If we got fewer than batch_size, we're done
- if (count($fonts) < $batch_size) {
+ if ($fonts_count < $batch_size) {
break;
}
}