PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.2
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.2
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Fonts / Registry.php

Registry.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.2, at src/Fonts/Registry.php

578 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registry of hosted font variants.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * CRUD over the easyfonts_fonts table.
14 */
15 class Registry {
16
17 /**
18 * Table name.
19 *
20 * @return string
21 */
22 private function table(): string {
23 global $wpdb;
24
25 return $wpdb->prefix . 'easyfonts_fonts';
26 }
27
28 /**
29 * Stable identity for a variant.
30 *
31 * @param string $family Family.
32 * @param string $weight Weight.
33 * @param string $style Style.
34 * @param string $subset Subset.
35 * @return string
36 */
37 public static function variant_key( string $family, string $weight, string $style, string $subset ): string {
38 return strtolower( str_replace( ' ', '-', $family ) ) . ':' . $weight . ':' . $style . ':' . $subset;
39 }
40
41 /**
42 * Last DB error from a write, if any (for diagnostics).
43 *
44 * @var string
45 */
46 private static string $last_error = '';
47
48 /**
49 * Most recent write error.
50 *
51 * @return string
52 */
53 public static function last_error(): string {
54 return self::$last_error;
55 }
56
57 /**
58 * Insert or update a variant by its variant_key.
59 *
60 * Uses SELECT + insert/update with explicit format arrays (rather than a
61 * hand-built INSERT … ON DUPLICATE KEY UPDATE) so NULL metrics and the
62 * MySQL-8.0.20-deprecated VALUES() function can't break the write.
63 *
64 * @param array<string,mixed> $data Variant data.
65 * @return void
66 */
67 public function upsert( array $data ): void {
68 global $wpdb;
69
70 $family = sanitize_text_field( $data['family'] ?? '' );
71
72 if ( '' === $family ) {
73 return;
74 }
75
76 $weight = sanitize_text_field( $data['weight'] ?? '400' );
77 $style = sanitize_text_field( $data['style'] ?? 'normal' );
78 $subset = sanitize_text_field( $data['subset'] ?? 'latin' );
79 $key = self::variant_key( $family, $weight, $style, $subset );
80
81 $row = array(
82 'family' => $family,
83 'weight' => $weight,
84 'style' => $style,
85 'subset' => $subset,
86 'variant_key' => $key,
87 'is_variable' => ! empty( $data['is_variable'] ) ? 1 : 0,
88 'provider' => sanitize_text_field( $data['provider'] ?? 'google' ),
89 'css_file' => sanitize_text_field( $data['css_file'] ?? '' ),
90 'font_file' => sanitize_text_field( $data['font_file'] ?? '' ),
91 'file_size' => absint( $data['file_size'] ?? 0 ),
92 'source_url' => esc_url_raw( $data['source_url'] ?? '' ),
93 );
94
95 $formats = array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s' );
96
97 // metrics is nullable: only include it when we actually have an array,
98 // so a missing value leaves the existing column untouched.
99 if ( ! empty( $data['metrics'] ) && is_array( $data['metrics'] ) ) {
100 $row['metrics'] = wp_json_encode( $data['metrics'] );
101 $formats[] = '%s';
102 }
103
104 $table = $this->table();
105
106 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
107 $existing = $wpdb->get_var(
108 $wpdb->prepare( "SELECT id FROM {$table} WHERE variant_key = %s", $key )
109 );
110
111 if ( $existing ) {
112 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
113 $wpdb->update( $table, $row, array( 'id' => (int) $existing ), $formats, array( '%d' ) );
114 } else {
115 $row['created_at'] = current_time( 'mysql' );
116 $formats[] = '%s';
117 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
118 $wpdb->insert( $table, $row, $formats );
119 }
120
121 if ( ! empty( $wpdb->last_error ) ) {
122 self::$last_error = $wpdb->last_error;
123
124 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
125 error_log( 'EasyFonts: font registration failed — ' . $wpdb->last_error ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
126 }
127 }
128 }
129
130 /**
131 * Variant keys that the user has disabled (is_enabled = 0). The consolidator
132 * consults this to skip those variants when building the stylesheet.
133 *
134 * @return array<string,bool> Map of variant_key => true.
135 */
136 public function disabled_keys(): array {
137 global $wpdb;
138
139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
140 $keys = $wpdb->get_col( "SELECT variant_key FROM {$this->table()} WHERE is_enabled = 0" );
141
142 return $keys ? array_fill_keys( $keys, true ) : array();
143 }
144
145 /**
146 * Batch-fetch the currently-stored font file for a set of variant keys, so
147 * the consolidator can register a page's variants with a single SELECT
148 * instead of one per variant. Returns a map of variant_key => font_file
149 * (only for keys that exist).
150 *
151 * @param string[] $keys Variant keys.
152 * @return array<string,string>
153 */
154 public function existing_map( array $keys ): array {
155 global $wpdb;
156
157 $keys = array_values( array_unique( array_filter( $keys, 'strlen' ) ) );
158
159 if ( empty( $keys ) ) {
160 return array();
161 }
162
163 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
164
165 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
166 $rows = $wpdb->get_results(
167 $wpdb->prepare(
168 "SELECT variant_key, font_file FROM {$this->table()} WHERE variant_key IN ( {$placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
169 ...$keys
170 ),
171 ARRAY_A
172 );
173
174 $out = array();
175
176 if ( $rows ) {
177 foreach ( $rows as $r ) {
178 $out[ $r['variant_key'] ] = (string) $r['font_file'];
179 }
180 }
181
182 return $out;
183 }
184
185 /**
186 * User-FORCED preload targets: variants the user explicitly toggled to
187 * "always preload" (is_preloaded = 1 AND preload_user_set = 1) and that are
188 * still enabled, each resolved to a font file. These are intentional global
189 * overrides — they apply on every page that actually uses the family (the
190 * caller intersects with the fonts present on the current page). Automatic,
191 * above-the-fold preloads are NOT sourced here; they come from the per-route
192 * beacon decision (see UsageTracker::get_decision / SmartPreload).
193 *
194 * Deduped: one entry per family+weight+style, preferring the latin subset.
195 *
196 * @return array<int,array{family:string,weight:string,style:string,file:string}>
197 */
198 public function preload_targets(): array {
199 global $wpdb;
200
201 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
202 $rows = $wpdb->get_results(
203 "SELECT family, weight, style, font_file, subset
204 FROM {$this->table()}
205 WHERE is_preloaded = 1 AND preload_user_set = 1 AND is_enabled = 1 AND font_file <> ''
206 ORDER BY ( subset = 'latin' ) DESC",
207 ARRAY_A
208 );
209
210 if ( ! $rows ) {
211 return array();
212 }
213
214 $seen = array();
215 $out = array();
216
217 foreach ( $rows as $r ) {
218 $dedupe = strtolower( $r['family'] ) . '|' . $r['weight'] . '|' . $r['style'];
219
220 if ( isset( $seen[ $dedupe ] ) ) {
221 continue; // First (latin-preferred) wins.
222 }
223
224 $seen[ $dedupe ] = true;
225 $out[] = array(
226 'family' => $r['family'],
227 'weight' => $r['weight'],
228 'style' => $r['style'],
229 'file' => $r['font_file'],
230 );
231 }
232
233 return $out;
234 }
235
236 /**
237 * Set a boolean flag (is_enabled | is_preloaded) on ALL variants at once.
238 *
239 * @param string $field 'is_enabled' or 'is_preloaded'.
240 * @param bool $value New value.
241 * @return void
242 */
243 public function set_flag_all( string $field, bool $value ): void {
244 global $wpdb;
245
246 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
247 return;
248 }
249
250 // Bulk toggles are user actions, so also stamp the matching user_set
251 // column to protect the choice from later auto-detection.
252 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
253
254 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
255 $wpdb->query( "UPDATE {$this->table()} SET `{$field}` = " . ( $value ? 1 : 0 ) . ", `{$col}` = 1" );
256 }
257
258 /**
259 * Set a boolean flag (is_enabled | is_preloaded) on variants.
260 *
261 * @param string $field 'is_enabled' or 'is_preloaded'.
262 * @param bool $value New value.
263 * @param string $family Family to target.
264 * @param string $weight Weight (empty = whole family).
265 * @param string $style Style (empty = whole family).
266 * @return void
267 */
268 public function set_flag( string $field, bool $value, string $family, string $weight = '', string $style = '', bool $mark_user = true ): void {
269 global $wpdb;
270
271 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
272 return;
273 }
274
275 $where = array( 'family' => $family );
276 $where_formats = array( '%s' );
277
278 if ( '' !== $weight ) {
279 $where['weight'] = $weight;
280 $where_formats[] = '%s';
281 }
282
283 if ( '' !== $style ) {
284 $where['style'] = $style;
285 $where_formats[] = '%s';
286 }
287
288 $data = array( $field => $value ? 1 : 0 );
289 $formats = array( '%d' );
290
291 // Record that a human set this, so the auto-detect logic never overrides
292 // the choice later (load → load_user_set, preload → preload_user_set).
293 if ( $mark_user ) {
294 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
295 $data[ $col ] = 1;
296 $formats[] = '%d';
297 }
298
299 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
300 $wpdb->update(
301 $this->table(),
302 $data,
303 $where,
304 $formats,
305 $where_formats
306 );
307 }
308
309 /**
310 * Grouped families with usage classification + per-(weight,style) flags, for
311 * the admin Fonts screen. Subsets are merged into each weight/style row.
312 *
313 * @param array<string,bool> $rendered Set of "family|weight|style" (lowercased) seen rendering.
314 * @param array<string,bool> $rendered_family Set of lowercased families seen rendering.
315 * @return array<int,array<string,mixed>>
316 */
317 public function grouped_with_usage( array $rendered, array $rendered_family ): array {
318 global $wpdb;
319
320 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
321 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
322
323 if ( ! $rows ) {
324 return array();
325 }
326
327 $families = array();
328
329 foreach ( $rows as $row ) {
330 $family = $row['family'];
331 $lc = strtolower( $family );
332
333 if ( ! isset( $families[ $family ] ) ) {
334 $families[ $family ] = array(
335 'family' => $family,
336 'is_variable' => (int) $row['is_variable'],
337 'has_metrics' => ! empty( $row['metrics'] ),
338 'used' => isset( $rendered_family[ $lc ] ),
339 'total_size' => 0,
340 'enabled' => false,
341 '_variants' => array(),
342 );
343 }
344
345 $ws = $row['weight'] . '|' . $row['style'];
346 $key = $lc . '|' . $row['weight'] . '|' . $row['style'];
347
348 if ( ! isset( $families[ $family ]['_variants'][ $ws ] ) ) {
349 $families[ $family ]['_variants'][ $ws ] = array(
350 'weight' => $row['weight'],
351 'style' => $row['style'],
352 'subsets' => array(),
353 'is_enabled' => true,
354 'is_preloaded' => false,
355 'used' => isset( $rendered[ $key ] ),
356 'size' => 0,
357 );
358 }
359
360 $v = &$families[ $family ]['_variants'][ $ws ];
361 $v['subsets'][] = $row['subset'];
362 $v['size'] += (int) $row['file_size'];
363 $v['is_enabled'] = $v['is_enabled'] && ( 1 === (int) $row['is_enabled'] );
364 $v['is_preloaded'] = $v['is_preloaded'] || ( 1 === (int) $row['is_preloaded'] );
365 unset( $v );
366
367 $families[ $family ]['total_size'] += (int) $row['file_size'];
368
369 if ( 1 === (int) $row['is_enabled'] ) {
370 $families[ $family ]['enabled'] = true;
371 }
372 }
373
374 // Flatten variant maps to lists.
375 $out = array();
376
377 foreach ( $families as $fam ) {
378 $fam['variants'] = array_values( $fam['_variants'] );
379 unset( $fam['_variants'] );
380 $out[] = $fam;
381 }
382
383 return $out;
384 }
385
386 /**
387 * All variants grouped by family.
388 *
389 * @return array<int,array<string,mixed>>
390 */
391 public function grouped(): array {
392 global $wpdb;
393
394 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
395 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
396
397 if ( ! $rows ) {
398 return array();
399 }
400
401 $out = array();
402
403 foreach ( $rows as $row ) {
404 $family = $row['family'];
405
406 if ( ! isset( $out[ $family ] ) ) {
407 $out[ $family ] = array(
408 'family' => $family,
409 'is_variable' => (int) $row['is_variable'],
410 'has_metrics' => ! empty( $row['metrics'] ),
411 'total_size' => 0,
412 'variants' => array(),
413 );
414 }
415
416 $out[ $family ]['variants'][] = array(
417 'id' => (int) $row['id'],
418 'weight' => $row['weight'],
419 'style' => $row['style'],
420 'subset' => $row['subset'],
421 'size' => (int) $row['file_size'],
422 );
423
424 $out[ $family ]['total_size'] += (int) $row['file_size'];
425 }
426
427 return array_values( $out );
428 }
429
430 /**
431 * Store metrics for every variant of a family (used by the backfill).
432 *
433 * @param string $family Family.
434 * @param array<string,float|int> $metrics Metrics.
435 * @return void
436 */
437 public function set_family_metrics( string $family, array $metrics ): void {
438 global $wpdb;
439
440 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
441 $wpdb->update(
442 $this->table(),
443 array( 'metrics' => wp_json_encode( $metrics ) ),
444 array( 'family' => $family ),
445 array( '%s' ),
446 array( '%s' )
447 );
448 }
449
450 /**
451 * Stored metrics for a family (first variant that has them).
452 *
453 * @param string $family Family.
454 * @return array<string,float|int>|null
455 */
456 public function metrics_for( string $family ): ?array {
457 global $wpdb;
458
459 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
460 $json = $wpdb->get_var(
461 $wpdb->prepare(
462 "SELECT metrics FROM {$this->table()} WHERE family = %s AND metrics IS NOT NULL LIMIT 1",
463 $family
464 )
465 );
466
467 if ( ! $json ) {
468 return null;
469 }
470
471 $decoded = json_decode( $json, true );
472
473 return is_array( $decoded ) ? $decoded : null;
474 }
475
476 /**
477 * Resolve the local font file for a family/weight/style (first subset match).
478 *
479 * @param string $family Family.
480 * @param string $weight Weight.
481 * @param string $style Style.
482 * @return string|null Cache-relative font filename.
483 */
484 public function resolve_file( string $family, string $weight, string $style ): ?string {
485 global $wpdb;
486
487 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
488 $file = $wpdb->get_var(
489 $wpdb->prepare(
490 "SELECT font_file FROM {$this->table()}
491 WHERE family = %s AND weight = %s AND style = %s AND font_file <> ''
492 ORDER BY ( subset = 'latin' ) DESC
493 LIMIT 1",
494 $family,
495 $weight,
496 $style
497 )
498 );
499
500 return $file ? (string) $file : null;
501 }
502
503 /**
504 * Best hosted file to PRELOAD for a family, tolerant of weight/style drift.
505 * The beacon reports the above-the-fold face using the element's COMPUTED
506 * weight/style, which often differs from the hosted variant (e.g. a
507 * single-weight family rendered bold reports weight 700, but only 400 is
508 * hosted). An exact lookup would then return nothing and the font would
509 * never preload. So we prefer an exact match, then the same style, then the
510 * latin subset, then the closest available weight — always returning a real
511 * file when the (enabled) family is hosted at all.
512 *
513 * @param string $family Family.
514 * @param string $weight Desired weight.
515 * @param string $style Desired style.
516 * @return string|null
517 */
518 public function resolve_preload_file( string $family, string $weight, string $style ): ?string {
519 global $wpdb;
520
521 $target = (int) preg_replace( '/\D+/', '', $weight );
522 $target = $target > 0 ? $target : 400;
523
524 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
525 $file = $wpdb->get_var(
526 $wpdb->prepare(
527 "SELECT font_file FROM {$this->table()}
528 WHERE family = %s AND is_enabled = 1 AND font_file <> ''
529 ORDER BY
530 ( weight = %s AND style = %s ) DESC,
531 ( style = %s ) DESC,
532 ( subset = 'latin' ) DESC,
533 ABS( CAST( NULLIF(weight,'') AS UNSIGNED ) - %d ) ASC,
534 id ASC
535 LIMIT 1",
536 $family,
537 $weight,
538 $style,
539 $style,
540 $target
541 )
542 );
543
544 return $file ? (string) $file : null;
545 }
546
547 /**
548 * Stats helper.
549 *
550 * @return array{families:int,variants:int,bytes:int}
551 */
552 public function stats(): array {
553 global $wpdb;
554
555 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
556 $variants = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table()}" );
557 $families = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT family) FROM {$this->table()}" );
558 $bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(file_size),0) FROM {$this->table()}" );
559 // phpcs:enable
560
561 return array(
562 'families' => $families,
563 'variants' => $variants,
564 'bytes' => $bytes,
565 );
566 }
567
568 /**
569 * Wipe the table (used on purge).
570 */
571 public function truncate(): void {
572 global $wpdb;
573
574 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
575 $wpdb->query( "TRUNCATE TABLE {$this->table()}" );
576 }
577 }
578