v1-pre
This commit is contained in:
parent
572552ff13
commit
847ed92c23
10 changed files with 1232 additions and 591 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue