PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
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.4, at src/Fonts/Registry.php

601 lines 17.2 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 // Duplicate key: a concurrent request inserted the same variant
121 // between our SELECT and INSERT. Fall back to an update so the
122 // write still lands instead of surfacing a spurious error.
123 if ( ! empty( $wpdb->last_error ) && false !== stripos( $wpdb->last_error, 'duplicate' ) ) {
124 unset( $row['created_at'] );
125 array_pop( $formats );
126
127 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
128 $winner = $wpdb->get_var(
129 $wpdb->prepare( "SELECT id FROM {$table} WHERE variant_key = %s", $key )
130 );
131
132 if ( $winner ) {
133 $wpdb->last_error = '';
134 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
135 $wpdb->update( $table, $row, array( 'id' => (int) $winner ), $formats, array( '%d' ) );
136 }
137 }
138 }
139
140 if ( ! empty( $wpdb->last_error ) ) {
141 self::$last_error = $wpdb->last_error;
142
143 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
144 error_log( 'EasyFonts: font registration failed — ' . $wpdb->last_error ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
145 }
146 }
147 }
148
149 /**
150 * Variant keys that the user has disabled (is_enabled = 0). The consolidator
151 * consults this to skip those variants when building the stylesheet.
152 *
153 * @return array<string,bool> Map of variant_key => true.
154 */
155 public function disabled_keys(): array {
156 global $wpdb;
157
158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
159 $keys = $wpdb->get_col( "SELECT variant_key FROM {$this->table()} WHERE is_enabled = 0" );
160
161 return $keys ? array_fill_keys( $keys, true ) : array();
162 }
163
164 /**
165 * Batch-fetch the currently-stored font file for a set of variant keys, so
166 * the consolidator can register a page's variants with a single SELECT
167 * instead of one per variant. Returns a map of variant_key => font_file
168 * (only for keys that exist).
169 *
170 * @param string[] $keys Variant keys.
171 * @return array<string,string>
172 */
173 public function existing_map( array $keys ): array {
174 global $wpdb;
175
176 $keys = array_values( array_unique( array_filter( $keys, 'strlen' ) ) );
177
178 if ( empty( $keys ) ) {
179 return array();
180 }
181
182 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
183
184 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
185 $rows = $wpdb->get_results(
186 $wpdb->prepare(
187 "SELECT variant_key, font_file FROM {$this->table()} WHERE variant_key IN ( {$placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
188 ...$keys
189 ),
190 ARRAY_A
191 );
192
193 $out = array();
194
195 if ( $rows ) {
196 foreach ( $rows as $r ) {
197 $out[ $r['variant_key'] ] = (string) $r['font_file'];
198 }
199 }
200
201 return $out;
202 }
203
204 /**
205 * User-FORCED preload targets: variants the user explicitly toggled to
206 * "always preload" (is_preloaded = 1 AND preload_user_set = 1) and that are
207 * still enabled, each resolved to a font file. These are intentional global
208 * overrides — they apply on every page that actually uses the family (the
209 * caller intersects with the fonts present on the current page). Automatic,
210 * above-the-fold preloads are NOT sourced here; they come from the per-route
211 * beacon decision (see UsageTracker::get_decision / SmartPreload).
212 *
213 * Deduped: one entry per family+weight+style, preferring the latin subset.
214 *
215 * @return array<int,array{family:string,weight:string,style:string,file:string}>
216 */
217 public function preload_targets(): array {
218 global $wpdb;
219
220 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
221 $rows = $wpdb->get_results(
222 "SELECT family, weight, style, font_file, subset
223 FROM {$this->table()}
224 WHERE is_preloaded = 1 AND preload_user_set = 1 AND is_enabled = 1 AND font_file <> ''
225 ORDER BY ( subset = 'latin' ) DESC",
226 ARRAY_A
227 );
228
229 if ( ! $rows ) {
230 return array();
231 }
232
233 $seen = array();
234 $out = array();
235
236 foreach ( $rows as $r ) {
237 $dedupe = strtolower( $r['family'] ) . '|' . $r['weight'] . '|' . $r['style'];
238
239 if ( isset( $seen[ $dedupe ] ) ) {
240 continue; // First (latin-preferred) wins.
241 }
242
243 $seen[ $dedupe ] = true;
244 $out[] = array(
245 'family' => $r['family'],
246 'weight' => $r['weight'],
247 'style' => $r['style'],
248 'file' => $r['font_file'],
249 );
250 }
251
252 return $out;
253 }
254
255 /**
256 * Set a boolean flag (is_enabled | is_preloaded) on ALL variants at once.
257 *
258 * @param string $field 'is_enabled' or 'is_preloaded'.
259 * @param bool $value New value.
260 * @return void
261 */
262 public function set_flag_all( string $field, bool $value ): void {
263 global $wpdb;
264
265 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
266 return;
267 }
268
269 // Bulk toggles are user actions, so also stamp the matching user_set
270 // column to protect the choice from later auto-detection.
271 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
272
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
274 $wpdb->query( "UPDATE {$this->table()} SET `{$field}` = " . ( $value ? 1 : 0 ) . ", `{$col}` = 1" );
275 }
276
277 /**
278 * Set a boolean flag (is_enabled | is_preloaded) on variants.
279 *
280 * @param string $field 'is_enabled' or 'is_preloaded'.
281 * @param bool $value New value.
282 * @param string $family Family to target.
283 * @param string $weight Weight (empty = whole family).
284 * @param string $style Style (empty = whole family).
285 * @return void
286 */
287 public function set_flag( string $field, bool $value, string $family, string $weight = '', string $style = '', bool $mark_user = true ): void {
288 global $wpdb;
289
290 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
291 return;
292 }
293
294 $where = array( 'family' => $family );
295 $where_formats = array( '%s' );
296
297 if ( '' !== $weight ) {
298 $where['weight'] = $weight;
299 $where_formats[] = '%s';
300 }
301
302 if ( '' !== $style ) {
303 $where['style'] = $style;
304 $where_formats[] = '%s';
305 }
306
307 $data = array( $field => $value ? 1 : 0 );
308 $formats = array( '%d' );
309
310 // Record that a human set this, so the auto-detect logic never overrides
311 // the choice later (load → load_user_set, preload → preload_user_set).
312 if ( $mark_user ) {
313 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
314 $data[ $col ] = 1;
315 $formats[] = '%d';
316 }
317
318 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
319 $wpdb->update(
320 $this->table(),
321 $data,
322 $where,
323 $formats,
324 $where_formats
325 );
326 }
327
328 /**
329 * Grouped families with usage classification + per-(weight,style) flags, for
330 * the admin Fonts screen. Subsets are merged into each weight/style row.
331 *
332 * @param array<string,bool> $rendered Set of "family|weight|style" (lowercased) seen rendering.
333 * @param array<string,bool> $rendered_family Set of lowercased families seen rendering.
334 * @return array<int,array<string,mixed>>
335 */
336 public function grouped_with_usage( array $rendered, array $rendered_family ): array {
337 global $wpdb;
338
339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
340 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
341
342 if ( ! $rows ) {
343 return array();
344 }
345
346 $families = array();
347
348 foreach ( $rows as $row ) {
349 $family = $row['family'];
350 $lc = strtolower( $family );
351
352 if ( ! isset( $families[ $family ] ) ) {
353 $families[ $family ] = array(
354 'family' => $family,
355 'is_variable' => (int) $row['is_variable'],
356 'has_metrics' => ! empty( $row['metrics'] ),
357 'used' => isset( $rendered_family[ $lc ] ),
358 'total_size' => 0,
359 'enabled' => false,
360 '_variants' => array(),
361 );
362 }
363
364 $ws = $row['weight'] . '|' . $row['style'];
365 $key = $lc . '|' . $row['weight'] . '|' . $row['style'];
366
367 if ( ! isset( $families[ $family ]['_variants'][ $ws ] ) ) {
368 $families[ $family ]['_variants'][ $ws ] = array(
369 'weight' => $row['weight'],
370 'style' => $row['style'],
371 'subsets' => array(),
372 'is_enabled' => true,
373 'is_preloaded' => false,
374 'used' => isset( $rendered[ $key ] ),
375 'size' => 0,
376 );
377 }
378
379 $v = &$families[ $family ]['_variants'][ $ws ];
380 $v['subsets'][] = $row['subset'];
381 $v['size'] += (int) $row['file_size'];
382 $v['is_enabled'] = $v['is_enabled'] && ( 1 === (int) $row['is_enabled'] );
383 $v['is_preloaded'] = $v['is_preloaded'] || ( 1 === (int) $row['is_preloaded'] );
384 unset( $v );
385
386 $families[ $family ]['total_size'] += (int) $row['file_size'];
387
388 if ( 1 === (int) $row['is_enabled'] ) {
389 $families[ $family ]['enabled'] = true;
390 }
391 }
392
393 // Flatten variant maps to lists.
394 $out = array();
395
396 foreach ( $families as $fam ) {
397 $fam['variants'] = array_values( $fam['_variants'] );
398 unset( $fam['_variants'] );
399 $out[] = $fam;
400 }
401
402 return $out;
403 }
404
405 /**
406 * All variants grouped by family.
407 *
408 * @return array<int,array<string,mixed>>
409 */
410 public function grouped(): array {
411 global $wpdb;
412
413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
414 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
415
416 if ( ! $rows ) {
417 return array();
418 }
419
420 $out = array();
421
422 foreach ( $rows as $row ) {
423 $family = $row['family'];
424
425 if ( ! isset( $out[ $family ] ) ) {
426 $out[ $family ] = array(
427 'family' => $family,
428 'is_variable' => (int) $row['is_variable'],
429 'has_metrics' => ! empty( $row['metrics'] ),
430 'total_size' => 0,
431 'variants' => array(),
432 );
433 }
434
435 $out[ $family ]['variants'][] = array(
436 'id' => (int) $row['id'],
437 'weight' => $row['weight'],
438 'style' => $row['style'],
439 'subset' => $row['subset'],
440 'size' => (int) $row['file_size'],
441 );
442
443 $out[ $family ]['total_size'] += (int) $row['file_size'];
444 }
445
446 return array_values( $out );
447 }
448
449 /**
450 * Store metrics for every variant of a family (used by the backfill).
451 *
452 * @param string $family Family.
453 * @param array<string,float|int> $metrics Metrics.
454 * @return void
455 */
456 public function set_family_metrics( string $family, array $metrics ): void {
457 global $wpdb;
458
459 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
460 $wpdb->update(
461 $this->table(),
462 array( 'metrics' => wp_json_encode( $metrics ) ),
463 array( 'family' => $family ),
464 array( '%s' ),
465 array( '%s' )
466 );
467 }
468
469 /**
470 * Stored metrics for a family (first variant that has them).
471 *
472 * @param string $family Family.
473 * @return array<string,float|int>|null
474 */
475 public function metrics_for( string $family ): ?array {
476 global $wpdb;
477
478 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
479 $json = $wpdb->get_var(
480 $wpdb->prepare(
481 "SELECT metrics FROM {$this->table()} WHERE family = %s AND metrics IS NOT NULL LIMIT 1",
482 $family
483 )
484 );
485
486 if ( ! $json ) {
487 return null;
488 }
489
490 $decoded = json_decode( $json, true );
491
492 return is_array( $decoded ) ? $decoded : null;
493 }
494
495 /**
496 * Resolve the local font file for a family/weight/style (first subset match).
497 *
498 * @param string $family Family.
499 * @param string $weight Weight.
500 * @param string $style Style.
501 * @return string|null Cache-relative font filename.
502 */
503 public function resolve_file( string $family, string $weight, string $style ): ?string {
504 global $wpdb;
505
506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
507 $file = $wpdb->get_var(
508 $wpdb->prepare(
509 "SELECT font_file FROM {$this->table()}
510 WHERE family = %s AND weight = %s AND style = %s AND font_file <> ''
511 ORDER BY ( subset = 'latin' ) DESC
512 LIMIT 1",
513 $family,
514 $weight,
515 $style
516 )
517 );
518
519 return $file ? (string) $file : null;
520 }
521
522 /**
523 * Best hosted file to PRELOAD for a family, tolerant of weight/style drift.
524 * The beacon reports the above-the-fold face using the element's COMPUTED
525 * weight/style, which often differs from the hosted variant (e.g. a
526 * single-weight family rendered bold reports weight 700, but only 400 is
527 * hosted). An exact lookup would then return nothing and the font would
528 * never preload. So we prefer an exact match, then the same style, then the
529 * page's subset (2.0.4; e.g. cyrillic on a Russian site), then latin, then
530 * the closest available weight — always returning a real file when the
531 * (enabled) family is hosted at all.
532 *
533 * @param string $family Family.
534 * @param string $weight Desired weight.
535 * @param string $style Desired style.
536 * @param string $subset Preferred subset.
537 * @return string|null
538 */
539 public function resolve_preload_file( string $family, string $weight, string $style, string $subset = 'latin' ): ?string {
540 global $wpdb;
541
542 $target = (int) preg_replace( '/\D+/', '', $weight );
543 $target = $target > 0 ? $target : 400;
544
545 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
546 $file = $wpdb->get_var(
547 $wpdb->prepare(
548 "SELECT font_file FROM {$this->table()}
549 WHERE family = %s AND is_enabled = 1 AND font_file <> ''
550 ORDER BY
551 ( weight = %s AND style = %s ) DESC,
552 ( style = %s ) DESC,
553 ( subset = %s ) DESC,
554 ( subset = 'latin' ) DESC,
555 ABS( CAST( COALESCE( NULLIF(weight,''), '400' ) AS SIGNED ) - %d ) ASC,
556 id ASC
557 LIMIT 1",
558 $family,
559 $weight,
560 $style,
561 $style,
562 $subset,
563 $target
564 )
565 );
566
567 return $file ? (string) $file : null;
568 }
569
570 /**
571 * Stats helper.
572 *
573 * @return array{families:int,variants:int,bytes:int}
574 */
575 public function stats(): array {
576 global $wpdb;
577
578 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
579 $variants = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table()}" );
580 $families = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT family) FROM {$this->table()}" );
581 $bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(file_size),0) FROM {$this->table()}" );
582 // phpcs:enable
583
584 return array(
585 'families' => $families,
586 'variants' => $variants,
587 'bytes' => $bytes,
588 );
589 }
590
591 /**
592 * Wipe the table (used on purge).
593 */
594 public function truncate(): void {
595 global $wpdb;
596
597 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
598 $wpdb->query( "TRUNCATE TABLE {$this->table()}" );
599 }
600 }
601