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

@ -6,7 +6,17 @@
/* Container */ /* Container */
.mlf-wrap { .mlf-wrap {
max-width: 900px; max-width: 800px;
}
.mlf-wrap > h1 {
margin-bottom: 0;
}
.mlf-description {
color: #646970;
margin-top: 8px;
margin-bottom: 20px;
} }
.mlf-container { .mlf-container {
@ -18,14 +28,16 @@
background: #fff; background: #fff;
border: 1px solid #c3c4c7; border: 1px solid #c3c4c7;
border-radius: 4px; border-radius: 4px;
padding: 20px; padding: 20px 24px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.mlf-section h2 { .mlf-section h2 {
margin-top: 0; margin-top: 0;
padding-bottom: 10px; margin-bottom: 16px;
border-bottom: 1px solid #c3c4c7; padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
font-size: 1.1em;
} }
/* Form */ /* Form */
@ -33,7 +45,11 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.mlf-form-row label { .mlf-form-row:last-of-type {
margin-bottom: 0;
}
.mlf-form-row > label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
@ -43,126 +59,212 @@
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
padding: 8px 12px; padding: 8px 12px;
font-size: 14px;
} }
.mlf-form-row .description { .mlf-form-row .description {
margin-top: 8px; margin-top: 8px;
color: #646970; color: #646970;
font-style: italic; font-style: italic;
font-size: 13px;
} }
/* Checkbox Grid */ /* Search Input */
.mlf-checkbox-grid { .mlf-search-wrapper {
display: grid; position: relative;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); max-width: 500px;
gap: 10px;
} }
.mlf-checkbox-grid-small { .mlf-search-icon {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); position: absolute;
max-width: 300px; left: 10px;
top: 50%;
transform: translateY(-50%);
color: #646970;
font-size: 18px;
width: 18px;
height: 18px;
} }
.mlf-checkbox-label { .mlf-search-input {
display: flex; width: 100% !important;
align-items: center; max-width: none !important;
gap: 8px; padding-left: 36px !important;
padding: 8px 12px; padding-right: 36px !important;
background: #f6f7f7; }
border: 1px solid #dcdcde;
.mlf-search-spinner {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
float: none !important;
margin: 0 !important;
}
/* Search Results */
.mlf-search-results {
margin-top: 12px;
border: 1px solid #c3c4c7;
border-radius: 4px; border-radius: 4px;
max-height: 400px;
overflow-y: auto;
background: #fff;
}
.mlf-results-list {
padding: 0;
}
.mlf-result-item {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.mlf-checkbox-label:hover { .mlf-result-item:last-child {
background: #f0f0f1; border-bottom: none;
} }
.mlf-checkbox-label input[type="checkbox"] { .mlf-result-item:hover {
margin: 0; background: #f0f6fc;
} }
.mlf-checkbox-label input[type="checkbox"]:checked + span { .mlf-result-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.mlf-result-name {
font-weight: 600; font-weight: 600;
font-size: 15px;
color: #1d2327;
} }
/* File Count */ .mlf-result-category {
.mlf-form-row-info { font-size: 12px;
padding: 12px 16px; color: #646970;
padding: 2px 8px;
background: #f0f0f1;
border-radius: 3px;
}
.mlf-result-badges {
display: flex;
gap: 6px;
}
.mlf-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
}
.mlf-badge-variable {
background: #d4edda;
color: #155724;
}
.mlf-result-preview {
font-size: 22px;
color: #50575e;
line-height: 1.3;
padding-top: 4px;
}
.mlf-no-results {
padding: 24px;
text-align: center;
color: #646970;
font-style: italic;
}
/* Selected Font */
.mlf-selected-font {
margin-top: 16px;
padding: 16px;
background: #f0f6fc; background: #f0f6fc;
border: 1px solid #c5d9ed; border: 1px solid #c5d9ed;
border-radius: 4px; border-radius: 4px;
} }
.mlf-file-count { .mlf-selected-font-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #c5d9ed;
}
.mlf-selected-label {
color: #646970;
font-size: 13px;
}
.mlf-selected-name {
font-weight: 600;
font-size: 16px;
color: #1d2327;
flex: 1;
}
.mlf-change-font {
background: none;
border: none;
color: #2271b1; color: #2271b1;
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
} }
.mlf-file-count strong { .mlf-change-font:hover {
font-size: 1.1em; color: #135e96;
text-decoration: underline;
} }
/* Font Preview */ .mlf-italic-row {
.mlf-preview-section { margin-bottom: 16px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdcde;
} }
.mlf-preview-box { /* Italic Toggle */
.mlf-italic-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff; background: #fff;
border: 1px solid #dcdcde; border: 1px solid #dcdcde;
border-radius: 4px; border-radius: 4px;
padding: 24px; cursor: pointer;
min-height: 100px; font-weight: normal !important;
position: relative; transition: background-color 0.15s ease;
} }
.mlf-preview-text { .mlf-italic-toggle:hover {
display: flex; background: #f6f7f7;
flex-direction: column;
gap: 16px;
} }
.mlf-preview-sample { .mlf-italic-toggle input[type="checkbox"] {
display: block;
line-height: 1.4;
}
.mlf-preview-heading {
font-size: 28px;
}
.mlf-preview-paragraph {
font-size: 16px;
color: #50575e;
}
.mlf-preview-loading {
display: flex;
align-items: center;
gap: 10px;
color: #646970;
padding: 20px 0;
}
.mlf-preview-loading .spinner {
float: none;
margin: 0; margin: 0;
} }
.mlf-preview-error {
color: #b32d2e;
padding: 20px 0;
text-align: center;
}
/* Submit Row */ /* Submit Row */
.mlf-form-row-submit { .mlf-form-row-submit {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-top: 0;
padding-top: 0;
border-top: none;
}
.mlf-selected-font .mlf-form-row-submit {
margin-top: 0;
} }
.mlf-form-row-submit .spinner { .mlf-form-row-submit .spinner {
@ -189,6 +291,28 @@
color: #721c24; color: #721c24;
} }
/* Info Note */
.mlf-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 20px;
padding: 12px 16px;
background: #f0f6fc;
border: 1px solid #c5d9ed;
border-radius: 4px;
font-size: 13px;
color: #1d2327;
}
.mlf-info-note .dashicons {
color: #2271b1;
font-size: 18px;
width: 18px;
height: 18px;
margin-top: 1px;
}
/* Font List */ /* Font List */
.mlf-font-list { .mlf-font-list {
display: flex; display: flex;
@ -245,10 +369,11 @@
.mlf-no-fonts { .mlf-no-fonts {
color: #646970; color: #646970;
font-style: italic; font-style: italic;
padding: 20px; padding: 24px;
text-align: center; text-align: center;
background: #f6f7f7; background: #f6f7f7;
border-radius: 4px; border-radius: 4px;
margin: 0;
} }
/* Info Box */ /* Info Box */
@ -264,12 +389,18 @@
.mlf-info-box .dashicons { .mlf-info-box .dashicons {
color: #2271b1; color: #2271b1;
font-size: 20px;
width: 20px;
height: 20px;
margin-top: 2px; margin-top: 2px;
} }
.mlf-info-box p { .mlf-info-box p {
margin: 0; margin: 0 0 8px 0;
color: #1d2327; }
.mlf-info-box p:last-child {
margin-bottom: 0;
} }
.mlf-info-box a { .mlf-info-box a {
@ -332,10 +463,6 @@
/* Responsive */ /* Responsive */
@media screen and (max-width: 782px) { @media screen and (max-width: 782px) {
.mlf-checkbox-grid {
grid-template-columns: repeat(2, 1fr);
}
.mlf-font-item { .mlf-font-item {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -345,10 +472,12 @@
.mlf-font-actions { .mlf-font-actions {
margin-left: 0; margin-left: 0;
} }
.mlf-result-info {
flex-wrap: wrap;
} }
@media screen and (max-width: 480px) { .mlf-selected-font-header {
.mlf-checkbox-grid { flex-wrap: wrap;
grid-template-columns: 1fr;
} }
} }

View file

@ -9,182 +9,304 @@
var MLF = { var MLF = {
/** /**
* Preview debounce timer. * Search debounce timer.
*/ */
previewTimer: null, searchTimer: null,
/** /**
* Currently loaded preview font. * Currently selected font.
*/ */
currentPreviewFont: null, selectedFont: null,
/**
* Loaded font preview stylesheets.
*/
loadedFonts: {},
/** /**
* Initialize the admin functionality. * Initialize the admin functionality.
*/ */
init: function() { init: function() {
this.bindEvents(); this.bindEvents();
this.updateFileCount();
}, },
/** /**
* Bind event handlers. * Bind event handlers.
*/ */
bindEvents: function() { bindEvents: function() {
// Search input
$('#mlf-font-search').on('input', this.handleSearchInput.bind(this));
// Click outside to close search results
$(document).on('click', this.handleDocumentClick.bind(this));
// Prevent closing when clicking inside search area
$('.mlf-import-section').on('click', function(e) {
e.stopPropagation();
});
// Font selection from results
$(document).on('click', '.mlf-result-item', this.handleFontSelect.bind(this));
// Change font button
$('#mlf-change-font').on('click', this.handleChangeFont.bind(this));
// Form submission // Form submission
$('#mlf-import-form').on('submit', this.handleDownload.bind(this)); $('#mlf-import-form').on('submit', this.handleDownload.bind(this));
// Delete button clicks // Delete button clicks
$(document).on('click', '.mlf-delete-btn', this.handleDelete.bind(this)); $(document).on('click', '.mlf-delete-btn', this.handleDelete.bind(this));
// Update file count on checkbox change
$('#mlf-import-form').on('change', 'input[type="checkbox"]', this.updateFileCount.bind(this));
// Font name input for preview (debounced)
$('#mlf-font-name').on('input', this.handleFontNameInput.bind(this));
}, },
/** /**
* Handle font name input for preview (debounced). * Handle search input (debounced).
* *
* @param {Event} e Input event. * @param {Event} e Input event.
*/ */
handleFontNameInput: function(e) { handleSearchInput: function(e) {
var fontName = $(e.target).val().trim(); var query = $(e.target).val().trim();
// Clear previous timer // Clear previous timer
if (this.previewTimer) { if (this.searchTimer) {
clearTimeout(this.previewTimer); clearTimeout(this.searchTimer);
} }
// Hide preview if empty // Hide results if query too short
if (!fontName) { if (query.length < 2) {
this.hidePreview(); this.hideSearchResults();
return; return;
} }
// Validate font name (only allowed characters) // Debounce: wait 300ms before searching
if (!/^[a-zA-Z0-9\s\-]+$/.test(fontName)) { this.searchTimer = setTimeout(function() {
this.hidePreview(); MLF.performSearch(query);
return; }, 300);
}
// Debounce: wait 500ms before loading preview
this.previewTimer = setTimeout(function() {
MLF.loadFontPreview(fontName);
}, 500);
}, },
/** /**
* Load font preview from Google Fonts. * Perform the search.
* *
* @param {string} fontName Font family name. * @param {string} query Search query.
*/ */
loadFontPreview: function(fontName) { performSearch: function(query) {
var $section = $('#mlf-preview-section'); var $spinner = $('#mlf-search-spinner');
var $text = $('#mlf-preview-text'); var $results = $('#mlf-search-results');
var $loading = $('#mlf-preview-loading'); var $list = $('#mlf-results-list');
var $error = $('#mlf-preview-error');
// Skip if same font already loaded // Show spinner
if (this.currentPreviewFont === fontName) { $spinner.addClass('is-active');
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_search_fonts',
nonce: mapleLocalFontsData.searchNonce,
query: query
},
success: function(response) {
if (response.success && response.data.fonts) {
MLF.displaySearchResults(response.data.fonts);
} else {
MLF.displayNoResults();
}
},
error: function() {
MLF.displayNoResults();
},
complete: function() {
$spinner.removeClass('is-active');
}
});
},
/**
* Display search results.
*
* @param {Array} fonts Array of font objects.
*/
displaySearchResults: function(fonts) {
var $results = $('#mlf-search-results');
var $list = $('#mlf-results-list');
if (fonts.length === 0) {
this.displayNoResults();
return; return;
} }
// Show section with loading state var html = '';
$section.show(); var previewText = mapleLocalFontsData.strings.previewText || 'Maple Fonts Preview';
$text.hide();
$loading.show();
$error.hide();
// Remove previous preview font link fonts.forEach(function(font) {
$('#mlf-preview-font-link').remove(); var fontFamily = font.family;
var category = font.category || 'sans-serif';
var categoryLabel = MLF.getCategoryLabel(category);
var badges = [];
// Build Google Fonts URL for preview (just 400 weight for preview) if (font.has_variable) {
var fontFamily = fontName.replace(/\s+/g, '+'); badges.push('<span class="mlf-badge mlf-badge-variable">Variable</span>');
var previewUrl = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(fontFamily) + ':wght@400&display=swap'; }
// Create link element html += '<div class="mlf-result-item" data-font-family="' + MLF.escapeHtml(fontFamily) + '">';
html += ' <div class="mlf-result-info">';
html += ' <span class="mlf-result-name">' + MLF.escapeHtml(fontFamily) + '</span>';
html += ' <span class="mlf-result-category">' + MLF.escapeHtml(categoryLabel) + '</span>';
if (badges.length > 0) {
html += ' <span class="mlf-result-badges">' + badges.join('') + '</span>';
}
html += ' </div>';
html += ' <div class="mlf-result-preview" style="font-family: \'' + MLF.escapeHtml(fontFamily) + '\', ' + category + ';">';
html += MLF.escapeHtml(previewText);
html += ' </div>';
html += '</div>';
// Load font for preview
MLF.loadFontPreview(fontFamily);
});
$list.html(html);
$results.show();
},
/**
* Display no results message.
*/
displayNoResults: function() {
var $results = $('#mlf-search-results');
var $list = $('#mlf-results-list');
var message = mapleLocalFontsData.strings.noResults || 'No fonts found.';
$list.html('<div class="mlf-no-results">' + MLF.escapeHtml(message) + '</div>');
$results.show();
},
/**
* Hide search results.
*/
hideSearchResults: function() {
$('#mlf-search-results').hide();
},
/**
* Maximum number of font previews to load.
*/
maxLoadedFonts: 50,
/**
* Load a font for preview from Google Fonts.
*
* @param {string} fontFamily Font family name.
*/
loadFontPreview: function(fontFamily) {
// Skip if already loaded
if (this.loadedFonts[fontFamily]) {
return;
}
// Limit number of loaded fonts to prevent memory accumulation
if (Object.keys(this.loadedFonts).length >= this.maxLoadedFonts) {
return;
}
// Mark as loading
this.loadedFonts[fontFamily] = true;
// Create Google Fonts link
var fontUrl = 'https://fonts.googleapis.com/css2?family=' +
encodeURIComponent(fontFamily.replace(/ /g, '+')) +
':wght@400&display=swap';
// Create and append link element
var $link = $('<link>', { var $link = $('<link>', {
id: 'mlf-preview-font-link',
rel: 'stylesheet', rel: 'stylesheet',
href: previewUrl href: fontUrl
}); });
// Handle load success
$link.on('load', function() {
MLF.currentPreviewFont = fontName;
$text.css('font-family', '"' + fontName + '", sans-serif');
$loading.hide();
$text.show();
});
// Handle load error
$link.on('error', function() {
MLF.currentPreviewFont = null;
$loading.hide();
$error.show();
});
// Append to head
$('head').append($link); $('head').append($link);
// Fallback timeout (5 seconds)
setTimeout(function() {
if ($loading.is(':visible')) {
// Check if font actually loaded by measuring text width
var testSpan = $('<span>').text('test').css({
'font-family': '"' + fontName + '", monospace',
'position': 'absolute',
'visibility': 'hidden'
}).appendTo('body');
var testWidth = testSpan.width();
var fallbackSpan = $('<span>').text('test').css({
'font-family': 'monospace',
'position': 'absolute',
'visibility': 'hidden'
}).appendTo('body');
var fallbackWidth = fallbackSpan.width();
testSpan.remove();
fallbackSpan.remove();
if (testWidth !== fallbackWidth) {
// Font loaded successfully
MLF.currentPreviewFont = fontName;
$text.css('font-family', '"' + fontName + '", sans-serif');
$loading.hide();
$text.show();
} else {
// Font failed to load
MLF.currentPreviewFont = null;
$loading.hide();
$error.show();
}
}
}, 5000);
}, },
/** /**
* Hide the font preview section. * Handle font selection.
*
* @param {Event} e Click event.
*/ */
hidePreview: function() { handleFontSelect: function(e) {
this.currentPreviewFont = null; var $item = $(e.currentTarget);
$('#mlf-preview-section').hide(); var fontFamily = $item.data('font-family');
$('#mlf-preview-font-link').remove();
// Store selected font
this.selectedFont = fontFamily;
// Update UI
$('#mlf-font-name').val(fontFamily);
$('#mlf-selected-name').text(fontFamily);
// Hide search, show selected font panel
this.hideSearchResults();
$('#mlf-font-search').closest('.mlf-form-row').hide();
$('#mlf-selected-font').show();
// Clear search input
$('#mlf-font-search').val('');
}, },
/** /**
* Update the file count display. * Handle change font button.
*
* @param {Event} e Click event.
*/ */
updateFileCount: function() { handleChangeFont: function(e) {
var weights = $('input[name="weights[]"]:checked').length; e.preventDefault();
var styles = $('input[name="styles[]"]:checked').length;
var count = weights * styles;
$('#mlf-file-count').text(count); // Clear selection
this.selectedFont = null;
$('#mlf-font-name').val('');
// Show search, hide selected font panel
$('#mlf-selected-font').hide();
$('#mlf-font-search').closest('.mlf-form-row').show();
$('#mlf-font-search').focus();
},
/**
* Handle document click (close search results).
*
* @param {Event} e Click event.
*/
handleDocumentClick: function(e) {
if (!$(e.target).closest('.mlf-import-section').length) {
this.hideSearchResults();
}
},
/**
* Get human-readable category label.
*
* @param {string} category Category slug.
* @return {string} Category label.
*/
getCategoryLabel: function(category) {
var labels = {
'sans-serif': 'Sans Serif',
'serif': 'Serif',
'display': 'Display',
'handwriting': 'Handwriting',
'monospace': 'Monospace'
};
return labels[category] || category;
},
/**
* Escape HTML entities.
*
* @param {string} str String to escape.
* @return {string} Escaped string.
*/
escapeHtml: function(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}, },
/** /**
@ -202,31 +324,17 @@
// Get form values // Get form values
var fontName = $('#mlf-font-name').val().trim(); var fontName = $('#mlf-font-name').val().trim();
var weights = []; var includeItalic = $('#mlf-include-italic').is(':checked') ? '1' : '0';
var styles = [];
$('input[name="weights[]"]:checked').each(function() {
weights.push($(this).val());
});
$('input[name="styles[]"]:checked').each(function() {
styles.push($(this).val());
});
// Validate // Validate
if (!fontName) { if (!fontName) {
this.showMessage($message, mapleLocalFontsData.strings.enterFontName, 'error'); this.showMessage($message, mapleLocalFontsData.strings.selectFont || 'Please select a font.', 'error');
return; return;
} }
if (weights.length === 0) { // Store original button text
this.showMessage($message, mapleLocalFontsData.strings.selectWeight, 'error'); if (!$button.data('original-text')) {
return; $button.data('original-text', $button.text());
}
if (styles.length === 0) {
this.showMessage($message, mapleLocalFontsData.strings.selectStyle, 'error');
return;
} }
// Disable form // Disable form
@ -243,8 +351,7 @@
action: 'mlf_download_font', action: 'mlf_download_font',
nonce: mapleLocalFontsData.downloadNonce, nonce: mapleLocalFontsData.downloadNonce,
font_name: fontName, font_name: fontName,
weights: weights, include_italic: includeItalic
styles: styles
}, },
success: function(response) { success: function(response) {
if (response.success) { if (response.success) {
@ -257,20 +364,19 @@
MLF.showMessage($message, response.data.message || mapleLocalFontsData.strings.error, 'error'); MLF.showMessage($message, response.data.message || mapleLocalFontsData.strings.error, 'error');
} }
}, },
error: function() { error: function(xhr) {
MLF.showMessage($message, mapleLocalFontsData.strings.error, 'error'); var message = mapleLocalFontsData.strings.error;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
MLF.showMessage($message, message, 'error');
}, },
complete: function() { complete: function() {
$form.removeClass('mlf-loading'); $form.removeClass('mlf-loading');
$button.prop('disabled', false).text($button.data('original-text') || 'Download & Install'); $button.prop('disabled', false).text($button.data('original-text'));
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
} }
}); });
// Store original button text
if (!$button.data('original-text')) {
$button.data('original-text', $button.text());
}
}, },
/** /**
@ -290,6 +396,9 @@
return; return;
} }
// Store original button text
var originalText = $button.text();
// Disable button // Disable button
$button.prop('disabled', true).text(mapleLocalFontsData.strings.deleting); $button.prop('disabled', true).text(mapleLocalFontsData.strings.deleting);
$fontItem.addClass('mlf-loading'); $fontItem.addClass('mlf-loading');
@ -311,20 +420,24 @@
// Check if any fonts remain // Check if any fonts remain
if ($('.mlf-font-item').length === 0) { if ($('.mlf-font-item').length === 0) {
$('#mlf-font-list').html( $('#mlf-font-list').replaceWith(
'<p class="mlf-no-fonts">No fonts installed yet.</p>' '<p class="mlf-no-fonts">No fonts installed yet. Search and select a font above to get started.</p>'
); );
} }
}); });
} else { } else {
alert(response.data.message || mapleLocalFontsData.strings.error); alert(response.data.message || mapleLocalFontsData.strings.error);
$button.prop('disabled', false).text('Delete'); $button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading'); $fontItem.removeClass('mlf-loading');
} }
}, },
error: function() { error: function(xhr) {
alert(mapleLocalFontsData.strings.error); var message = mapleLocalFontsData.strings.error;
$button.prop('disabled', false).text('Delete'); if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
alert(message);
$button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading'); $fontItem.removeClass('mlf-loading');
} }
}); });

View file

@ -16,23 +16,6 @@ if (!defined('ABSPATH')) {
*/ */
class MLF_Admin_Page { 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. * Constructor.
*/ */
@ -53,7 +36,8 @@ class MLF_Admin_Page {
$installed_fonts = $registry->get_imported_fonts(); $installed_fonts = $registry->get_imported_fonts();
?> ?>
<div class="wrap mlf-wrap"> <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"> <div class="mlf-container">
<!-- Import Section --> <!-- Import Section -->
@ -61,62 +45,45 @@ class MLF_Admin_Page {
<h2><?php esc_html_e('Import from Google Fonts', 'maple-local-fonts'); ?></h2> <h2><?php esc_html_e('Import from Google Fonts', 'maple-local-fonts'); ?></h2>
<form id="mlf-import-form" class="mlf-form"> <form id="mlf-import-form" class="mlf-form">
<!-- Search Input -->
<div class="mlf-form-row"> <div class="mlf-form-row">
<label for="mlf-font-name"><?php esc_html_e('Font Name', 'maple-local-fonts'); ?></label> <label for="mlf-font-search"><?php esc_html_e('Search Fonts', '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 /> <div class="mlf-search-wrapper">
<p class="description"><?php esc_html_e('Enter the exact font name as it appears on Google Fonts.', 'maple-local-fonts'); ?></p> <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>
<div class="mlf-form-row"> <!-- Search Results -->
<label><?php esc_html_e('Weights', 'maple-local-fonts'); ?></label> <div class="mlf-search-results" id="mlf-search-results" style="display: none;">
<div class="mlf-checkbox-grid"> <div class="mlf-results-list" id="mlf-results-list"></div>
<?php foreach ($this->weights as $weight => $label) : ?> </div>
<label class="mlf-checkbox-label">
<input type="checkbox" name="weights[]" value="<?php echo esc_attr($weight); ?>" <?php checked(in_array($weight, [400, 700], true)); ?> /> <!-- Selected Font (hidden until a font is selected) -->
<span><?php echo esc_html($weight); ?> (<?php echo esc_html($label); ?>)</span> <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> </label>
<?php endforeach; ?> <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">
<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>
</label>
</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>
<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>
<div class="mlf-form-row mlf-form-row-submit"> <div class="mlf-form-row mlf-form-row-submit">
@ -125,9 +92,15 @@ class MLF_Admin_Page {
</button> </button>
<span class="spinner" id="mlf-spinner"></span> <span class="spinner" id="mlf-spinner"></span>
</div> </div>
</div>
<div id="mlf-message" class="mlf-message" style="display: none;"></div> <div id="mlf-message" class="mlf-message" style="display: none;"></div>
</form> </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> </div>
<!-- Installed Fonts Section --> <!-- Installed Fonts Section -->
@ -135,7 +108,7 @@ class MLF_Admin_Page {
<h2><?php esc_html_e('Installed Fonts', 'maple-local-fonts'); ?></h2> <h2><?php esc_html_e('Installed Fonts', 'maple-local-fonts'); ?></h2>
<?php if (empty($installed_fonts)) : ?> <?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 : ?> <?php else : ?>
<div id="mlf-font-list" class="mlf-font-list"> <div id="mlf-font-list" class="mlf-font-list">
<?php foreach ($installed_fonts as $font) : ?> <?php foreach ($installed_fonts as $font) : ?>
@ -167,20 +140,23 @@ class MLF_Admin_Page {
<div class="mlf-section mlf-info-section"> <div class="mlf-section mlf-info-section">
<?php if (wp_is_block_theme()) : ?> <?php if (wp_is_block_theme()) : ?>
<div class="mlf-info-box"> <div class="mlf-info-box">
<span class="dashicons dashicons-info"></span> <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> <p>
<?php <?php
printf( printf(
/* translators: %s: link to WordPress Editor */ /* translators: %s: link to WordPress Editor */
esc_html__('Use %s to apply fonts to your site.', 'maple-local-fonts'), 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>' '<a href="' . esc_url(admin_url('site-editor.php?path=%2Fwp_global_styles')) . '">' . esc_html__('Appearance → Editor → Styles → Typography', 'maple-local-fonts') . '</a>'
); );
?> ?>
</p> </p>
</div> </div>
</div>
<?php else : ?> <?php else : ?>
<div class="mlf-info-box mlf-info-box-classic"> <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"> <div class="mlf-classic-theme-info">
<p><strong><?php esc_html_e('Classic Theme Detected', 'maple-local-fonts'); ?></strong></p> <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> <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')]); wp_send_json_error(['message' => __('Font name is too long.', 'maple-local-fonts')]);
} }
// Validate weights // Validate include_italic (boolean)
$weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : []; $include_italic = isset($_POST['include_italic']) && $_POST['include_italic'] === '1';
$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')]);
}
// 5. PROCESS REQUEST // 5. PROCESS REQUEST
try { try {
$downloader = new MLF_Font_Downloader(); $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)) { if (is_wp_error($download_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($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_path' => __('Invalid file path.', 'maple-local-fonts'),
'invalid_url' => __('Invalid font URL.', 'maple-local-fonts'), 'invalid_url' => __('Invalid font URL.', 'maple-local-fonts'),
'invalid_name' => __('Invalid font name.', '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_found' => __('Font not found.', 'maple-local-fonts'),
'not_ours' => __('Cannot delete fonts not imported by this plugin.', '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'), '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'); 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 * Class MLF_Font_Downloader
* *
* Handles downloading fonts from Google Fonts CSS2 API. * Handles downloading fonts from Google Fonts CSS2 API.
* Attempts variable fonts first, falls back to static fonts.
*/ */
class MLF_Font_Downloader { 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'; 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. * Download a font from Google Fonts.
* *
* Attempts variable font first, falls back to static if not available.
*
* @param string $font_name Font family name. * @param string $font_name Font family name.
* @param array $weights Weights to download. * @param bool $include_italic Whether to include italic styles.
* @param array $styles Styles to download.
* @return array|WP_Error Download result or error. * @return array|WP_Error Download result or error.
*/ */
public function download($font_name, $weights, $styles) { public function download($font_name, $include_italic = true) {
// Validate inputs // Validate font name
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) { if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid 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 (strlen($font_name) > 100) {
if (empty($weights)) { return new WP_Error('invalid_name', 'Font name too long');
return new WP_Error('invalid_weights', 'No valid weights specified');
} }
$styles = array_intersect($styles, ['normal', 'italic']); // Try variable font first
if (empty($styles)) { $result = $this->try_variable_font($font_name, $include_italic);
return new WP_Error('invalid_styles', 'No valid styles specified');
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 // 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)) { if (is_wp_error($css)) {
return $css; return $css;
} }
// Parse CSS to get font face data // 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)) { if (is_wp_error($font_faces)) {
return $font_faces; return $font_faces;
} }
@ -62,6 +175,7 @@ class MLF_Font_Downloader {
// Download each font file // Download each font file
$font_slug = sanitize_title($font_name); $font_slug = sanitize_title($font_name);
$downloaded = $this->download_files($font_faces, $font_slug); $downloaded = $this->download_files($font_faces, $font_slug);
if (is_wp_error($downloaded)) { if (is_wp_error($downloaded)) {
return $downloaded; return $downloaded;
} }
@ -70,34 +184,116 @@ class MLF_Font_Downloader {
'font_name' => $font_name, 'font_name' => $font_name,
'font_slug' => $font_slug, 'font_slug' => $font_slug,
'files' => $downloaded, '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 string $font_name Font family name.
* @param array $weights Array of weights. * @param array $weights Array of weights.
* @param array $styles Array of styles. * @param array $styles Array of styles.
* @return string Google Fonts CSS2 URL. * @return string Google Fonts CSS2 URL.
*/ */
private function build_url($font_name, $weights, $styles) { private function build_static_url($font_name, $weights, $styles) {
// URL-encode font name (spaces become +)
$family = str_replace(' ', '+', $font_name); $family = str_replace(' ', '+', $font_name);
// Sort for consistent URLs
sort($weights); sort($weights);
$has_italic = in_array('italic', $styles, true); $has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true); $has_normal = in_array('normal', $styles, true);
// If only normal styles, simpler format
if ($has_normal && !$has_italic) { if ($has_normal && !$has_italic) {
$wght = implode(';', $weights); $wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap"; return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
} }
// Full format with ital axis
$variations = []; $variations = [];
foreach ($weights as $weight) { foreach ($weights as $weight) {
if ($has_normal) { 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 string $css CSS content.
* @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 $font_name Expected font family name. * @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error. * @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 = []; $font_faces = [];
// Match all @font-face blocks // Match all @font-face blocks
$pattern = '/@font-face\s*\{([^}]+)\}/s'; $pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) { 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) { 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)) { 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) { if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue; continue;
} }
// Create unique key for weight+style combo
$key = $face_data['weight'] . '-' . $face_data['style']; $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']); $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) { if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data; $font_faces[$key] = $face_data;
} }
} }
if (empty($font_faces)) { 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; $max_faces = defined('MLF_MAX_FONT_FACES') ? MLF_MAX_FONT_FACES : 20;
$font_faces_array = array_values($font_faces); $result = array_values($font_faces);
if (count($font_faces_array) > $max_faces) {
$font_faces_array = array_slice($font_faces_array, 0, $max_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. * 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. * @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 = []; $data = [];
// Extract font-family // Extract font-family
@ -238,9 +414,9 @@ class MLF_Font_Downloader {
return new WP_Error('missing_family', 'Missing font-family'); return new WP_Error('missing_family', 'Missing font-family');
} }
// Extract font-weight // Extract font-weight (can be single value or range for variable)
if (preg_match('/font-weight:\s*(\d+);/i', $block, $m)) { if (preg_match('/font-weight:\s*(\d+(?:\s+\d+)?);/i', $block, $m)) {
$data['weight'] = $m[1]; $data['weight'] = trim($m[1]);
} else { } else {
return new WP_Error('missing_weight', 'Missing font-weight'); 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)) { if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) {
$data['style'] = $m[1]; $data['style'] = $m[1];
} else { } else {
$data['style'] = 'normal'; // Default $data['style'] = 'normal';
} }
// Extract src URL - MUST be fonts.gstatic.com // 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'); 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)) { if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) {
$data['unicode_range'] = trim($m[1]); $data['unicode_range'] = trim($m[1]);
} else { } else {
@ -277,11 +453,9 @@ class MLF_Font_Downloader {
*/ */
private function is_latin_subset($range) { private function is_latin_subset($range) {
if (empty($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)) { if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) {
return true; return true;
} }
@ -305,7 +479,8 @@ class MLF_Font_Downloader {
$face['url'], $face['url'],
$font_slug, $font_slug,
$face['weight'], $face['weight'],
$face['style'] $face['style'],
false
); );
if (is_wp_error($result)) { if (is_wp_error($result)) {
@ -317,10 +492,10 @@ class MLF_Font_Downloader {
'path' => $result, 'path' => $result,
'weight' => $face['weight'], 'weight' => $face['weight'],
'style' => $face['style'], 'style' => $face['style'],
'is_variable' => false,
]; ];
} }
// If no files downloaded, return error
if (empty($downloaded)) { if (empty($downloaded)) {
return new WP_Error( return new WP_Error(
'download_failed', '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 $url Google Fonts static URL.
* @param string $font_slug Font slug for filename. * @param string $font_slug Font slug for filename.
* @param string $weight Font weight. * @param string $weight Font weight (single or range).
* @param string $style Font style. * @param string $style Font style.
* @param bool $is_variable Whether this is a variable font.
* @return string|WP_Error Local file path or error. * @return string|WP_Error Local file path or error.
*/ */
private function download_single_file($url, $font_slug, $weight, $style) { private function download_single_file($url, $font_slug, $weight, $style, $is_variable = false) {
// Validate URL is from Google
if (!$this->is_valid_google_fonts_url($url)) { if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'URL is not from Google Fonts'); return new WP_Error('invalid_url', 'URL is not from Google Fonts');
} }
// Build local filename // Build filename
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight); $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); $filename = sanitize_file_name($filename);
// Validate filename
$filename = $this->sanitize_font_filename($filename); $filename = $this->sanitize_font_filename($filename);
if ($filename === false) { if ($filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename'); return new WP_Error('invalid_filename', 'Invalid filename');
} }
// Get destination path
$font_dir = wp_get_font_dir(); $font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $filename; $destination = trailingslashit($font_dir['path']) . $filename;
// Validate destination path before any file operations
if (!$this->validate_font_path($destination)) { if (!$this->validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid destination path'); return new WP_Error('invalid_path', 'Invalid destination path');
} }
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) { if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory'); return new WP_Error('mkdir_failed', 'Could not create fonts directory');
} }
// Download file
$response = wp_remote_get($url, [ $response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT, 'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true, 'sslverify' => true,
@ -378,31 +553,29 @@ class MLF_Font_Downloader {
]); ]);
if (is_wp_error($response)) { 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); $status = wp_remote_retrieve_response_code($response);
if ($status !== 200) { 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); $content = wp_remote_retrieve_body($response);
if (empty($content)) { 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; $max_size = defined('MLF_MAX_FONT_FILE_SIZE') ? MLF_MAX_FONT_FILE_SIZE : 5 * 1024 * 1024;
if (strlen($content) > $max_size) { 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') { if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2'); return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2');
} }
// Write file using WP Filesystem
global $wp_filesystem; global $wp_filesystem;
if (empty($wp_filesystem)) { if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php'; 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. * @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) { private function is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url); $parsed = wp_parse_url($url);
@ -429,7 +602,6 @@ class MLF_Font_Downloader {
return false; return false;
} }
// Only allow Google Fonts domains
$allowed_hosts = [ $allowed_hosts = [
'fonts.googleapis.com', 'fonts.googleapis.com',
'fonts.gstatic.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. * @param string $filename Filename to sanitize.
* @return string|false Sanitized filename or false if invalid. * @return string|false Sanitized filename or false.
*/ */
private function sanitize_font_filename($filename) { private function sanitize_font_filename($filename) {
// WordPress sanitization first
$filename = sanitize_file_name($filename); $filename = sanitize_file_name($filename);
// Must have .woff2 extension
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') { if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false; return false;
} }
// No path components
if ($filename !== basename($filename)) { if ($filename !== basename($filename)) {
return false; return false;
} }
// Reasonable length
if (strlen($filename) > 200) { if (strlen($filename) > 200) {
return false; 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. * @param string $path Path to validate.
* @return bool True if path is safe, false otherwise. * @return bool True if valid.
*/ */
private function validate_font_path($path) { private function validate_font_path($path) {
$font_dir = wp_get_font_dir(); $font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path'])); $fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path); $real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) { if ($real_path === false) {
$dir = dirname($path); $dir = dirname($path);
$real_dir = realpath($dir); $real_dir = realpath($dir);
if ($real_dir === false) { if ($real_dir === false) {
// Directory doesn't exist yet, check parent
$parent_dir = dirname($dir); $parent_dir = dirname($dir);
$real_parent = realpath($parent_dir); $real_parent = realpath($parent_dir);
if ($real_parent === false) { if ($real_parent === false) {
@ -498,7 +663,6 @@ class MLF_Font_Downloader {
$real_path = wp_normalize_path($real_path); $real_path = wp_normalize_path($real_path);
} }
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0; return strpos($real_path, $fonts_path) === 0;
} }
} }

View file

@ -30,33 +30,44 @@ class MLF_Font_Registry {
'post_type' => 'wp_font_family', 'post_type' => 'wp_font_family',
'name' => $font_slug, 'name' => $font_slug,
'posts_per_page' => 1, 'posts_per_page' => 1,
'post_status' => 'publish', 'post_status' => 'any',
]); ]);
if (!empty($existing)) { if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed'); return new WP_Error('font_exists', 'Font family already installed');
} }
// Get font directory // Get font directory info
$font_dir = wp_get_font_dir(); $font_dir = wp_get_font_dir();
// Build font face array for WordPress // Build font face array for WordPress
$font_faces = []; $font_faces = [];
foreach ($files as $file) { foreach ($files as $file) {
$filename = basename($file['path']); $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, 'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'], 'fontStyle' => $file['style'],
'fontWeight' => $weight,
'src' => 'file:./' . $filename, '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 = [ $font_family_settings = [
'name' => $font_name, 'name' => $font_name,
'slug' => $font_slug, 'slug' => $font_slug,
'fontFamily' => sprintf('"%s", sans-serif', $font_name), 'fontFamily' => "'{$font_name}', {$fallback}",
'fontFace' => $font_faces, 'fontFace' => $font_faces,
]; ];
@ -67,7 +78,7 @@ class MLF_Font_Registry {
'post_name' => $font_slug, 'post_name' => $font_slug,
'post_status' => 'publish', 'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings), 'post_content' => wp_json_encode($font_family_settings),
]); ], true);
if (is_wp_error($family_id)) { if (is_wp_error($family_id)) {
return $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_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql')); 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) { foreach ($files as $file) {
$filename = basename($file['path']); $filename = basename($file['path']);
$weight = $file['weight'];
$face_settings = [ $face_settings = [
'fontFamily' => $font_name, 'fontFamily' => $font_name,
'fontWeight' => $file['weight'], 'fontWeight' => $weight,
'fontStyle' => $file['style'], 'fontStyle' => $file['style'],
'src' => 'file:./' . $filename, 'src' => 'file:./' . $filename,
]; ];
@ -91,15 +103,15 @@ class MLF_Font_Registry {
wp_insert_post([ wp_insert_post([
'post_type' => 'wp_font_face', 'post_type' => 'wp_font_face',
'post_parent' => $family_id, '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_status' => 'publish',
'post_content' => wp_json_encode($face_settings), 'post_content' => wp_json_encode($face_settings),
]); ]);
} }
// Clear font caches // Clear all font-related caches
delete_transient('wp_font_library_fonts'); $this->clear_font_caches();
delete_transient('mlf_imported_fonts_list');
return $family_id; return $family_id;
} }
@ -154,13 +166,40 @@ class MLF_Font_Registry {
// Delete family post // Delete family post
wp_delete_post($family_id, true); wp_delete_post($family_id, true);
// Clear caches // Clear all font-related caches
delete_transient('wp_font_library_fonts'); $this->clear_font_caches();
delete_transient('mlf_imported_fonts_list');
return true; 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. * Get all fonts imported by this plugin.
* *
@ -194,7 +233,7 @@ class MLF_Font_Registry {
// Single query to get ALL font faces for ALL fonts (fixes N+1) // Single query to get ALL font faces for ALL fonts (fixes N+1)
$all_faces = get_posts([ $all_faces = get_posts([
'post_type' => 'wp_font_face', 'post_type' => 'wp_font_face',
'posts_per_page' => 1000, // Max 100 fonts × 9 weights × 2 styles = 1800, but limit reasonably 'posts_per_page' => 1000,
'post_status' => 'publish', 'post_status' => 'publish',
'post_parent__in' => $font_ids, 'post_parent__in' => $font_ids,
]); ]);
@ -234,7 +273,11 @@ class MLF_Font_Registry {
// Sort variants by weight then style // Sort variants by weight then style
usort($variants, function($a, $b) { 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) { if ($weight_cmp !== 0) {
return $weight_cmp; 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) { public function create_item($request) {
$font_name = sanitize_text_field($request->get_param('font_name')); $font_name = sanitize_text_field($request->get_param('font_name'));
$weights = array_map('absint', (array) $request->get_param('weights')); $include_italic = (bool) $request->get_param('include_italic');
$styles = array_map('sanitize_text_field', (array) $request->get_param('styles'));
// Validate font name // Validate font name
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $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 { try {
$downloader = new MLF_Font_Downloader(); $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)) { if (is_wp_error($download_result)) {
return new WP_Error( return new WP_Error(
@ -455,23 +420,10 @@ class MLF_Rest_Controller extends WP_REST_Controller {
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
], ],
'weights' => [ 'include_italic' => [
'description' => __('Array of font weights to download.', 'maple-local-fonts'), 'description' => __('Whether to include italic styles.', 'maple-local-fonts'),
'type' => 'array', 'type' => 'boolean',
'required' => true, 'default' => 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'],
],
], ],
]; ];
} }

View file

@ -122,6 +122,7 @@ function mlf_init() {
if (is_admin()) { if (is_admin()) {
new MLF_Admin_Page(); new MLF_Admin_Page();
new MLF_Ajax_Handler(); new MLF_Ajax_Handler();
new MLF_Font_Search();
} }
} }
add_action('plugins_loaded', 'mlf_init', 20); add_action('plugins_loaded', 'mlf_init', 20);
@ -155,9 +156,9 @@ function mlf_get_capability() {
*/ */
function mlf_register_menu() { function mlf_register_menu() {
add_submenu_page( add_submenu_page(
'themes.php', 'options-general.php',
__('Maple Local Fonts', 'maple-local-fonts'), __('Maple Fonts', 'maple-local-fonts'),
__('Local Fonts', 'maple-local-fonts'), __('Maple Fonts', 'maple-local-fonts'),
mlf_get_capability(), mlf_get_capability(),
'maple-local-fonts', 'maple-local-fonts',
'mlf_render_admin_page' 'mlf_render_admin_page'
@ -165,6 +166,23 @@ function mlf_register_menu() {
} }
add_action('admin_menu', '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(
'<a href="%s">%s</a>',
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). * 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. * @param string $hook The current admin page hook.
*/ */
function mlf_enqueue_admin_assets($hook) { function mlf_enqueue_admin_assets($hook) {
if ($hook !== 'appearance_page_maple-local-fonts') { if ($hook !== 'settings_page_maple-local-fonts') {
return; return;
} }
@ -202,14 +220,17 @@ function mlf_enqueue_admin_assets($hook) {
'ajaxUrl' => admin_url('admin-ajax.php'), 'ajaxUrl' => admin_url('admin-ajax.php'),
'downloadNonce' => wp_create_nonce('mlf_download_font'), 'downloadNonce' => wp_create_nonce('mlf_download_font'),
'deleteNonce' => wp_create_nonce('mlf_delete_font'), 'deleteNonce' => wp_create_nonce('mlf_delete_font'),
'searchNonce' => wp_create_nonce('mlf_search_fonts'),
'strings' => [ 'strings' => [
'downloading' => __('Downloading...', 'maple-local-fonts'), 'downloading' => __('Downloading...', 'maple-local-fonts'),
'deleting' => __('Deleting...', 'maple-local-fonts'), 'deleting' => __('Deleting...', 'maple-local-fonts'),
'confirmDelete' => __('Are you sure you want to delete this font?', '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'), 'error' => __('An error occurred. Please try again.', 'maple-local-fonts'),
'selectWeight' => __('Please select at least one weight.', 'maple-local-fonts'), 'searchPlaceholder' => __('Search Google Fonts...', 'maple-local-fonts'),
'selectStyle' => __('Please select at least one style.', 'maple-local-fonts'), 'searching' => __('Searching...', 'maple-local-fonts'),
'enterFontName' => __('Please enter a font name.', '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'),
], ],
]); ]);
} }

View file

@ -74,13 +74,14 @@ function mlf_uninstall() {
wp_delete_post($font_id, true); wp_delete_post($font_id, true);
} }
$processed += count($fonts); $fonts_count = count($fonts);
$processed += $fonts_count;
// Free memory // Free memory
unset($fonts, $all_faces); unset($fonts, $all_faces);
// If we got fewer than batch_size, we're done // If we got fewer than batch_size, we're done
if (count($fonts) < $batch_size) { if ($fonts_count < $batch_size) {
break; break;
} }
} }