feat: lokales Google-Fonts-Hosting + "Lizenz upgraden"-button (v1.3.0)

- Lizenz-Tab: bei vollen Plaetzen erscheint ein "Lizenz upgraden"-Button
  (https://lucas-orth.de/wp-plugins/gdpr-content-blocker/) zusaetzlich zur
  domain-freigabe.
- Neues Feature "Schriften": Google Fonts lokal hosten (button-modell).
  Admin klickt "Jetzt herunterladen & einbinden" -> plugin scannt die seiten,
  laedt googleapis-CSS + gstatic-schriftdateien einmalig in den uploads-ordner,
  schreibt die pfade lokal um und tauscht sie per output-buffer im quelltext.
  Abgedeckt: <link>, inline-@import/url() UND @import/url() in gleicher-origin
  externen CSS-dateien (mit absolutierung der restlichen relativen pfade).
  preconnect/dns-prefetch zu google-font-hosts werden entfernt (kein IP-leak).
  Toggle zum aus/einschalten + "Lokale Schriften loeschen".
- i18n-pipeline robuster: build-en-mo.py nutzt jetzt translations-en.json
  (lookup per quell-string, reihenfolge-unabhaengig) und bricht mit liste ab,
  falls eine uebersetzung fehlt. Alle neuen strings DE/EN ergaenzt (139).
- version 1.3.0 (header + CB_VERSION).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-08 07:16:19 +02:00
parent ca0f2f2c32
commit 377c64f8d0
10 changed files with 990 additions and 152 deletions

View File

@@ -0,0 +1,529 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Optional: host Google Fonts locally to avoid the connection to Google's CDN
* (privacy + performance).
*
* Trigger model: "button". The admin clicks "download & embed"; the plugin
* visits the configured pages, finds every Google Fonts reference and downloads
* everything once:
* - <link href="…fonts.googleapis.com/css…"> (also protocol-relative)
* - @import / direct gstatic URLs inside inline <style> blocks
* - @import / url(…gstatic…) inside SAME-ORIGIN external CSS files
* The CSS + font files are stored under wp-content/uploads, all paths are
* rewritten to the local copies, and a remote→local URL map is saved. On the
* front end a lightweight output-buffer pass swaps the mapped URLs (no network).
*
* Anything not covered by the scanned pages simply stays remote until the next
* "download & embed" run (predictable, no per-visitor latency).
*/
class CB_Fonts {
const OPTION = 'cb_fonts';
const DIR = 'gdpr-content-blocker-fonts';
// Modern browser UA so fonts.googleapis.com returns woff2 (not legacy ttf).
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const MAX_CSS = 40;
const MAX_FONTS = 200;
/* Per-build memoisation (avoid re-downloading across pages). */
private static array $css_done = [];
private static array $font_done = [];
private static array $map = [];
private static int $font_count = 0;
private static int $css_count = 0;
public static function init(): void {
add_action( 'template_redirect', [ __CLASS__, 'start_buffer' ], 5 );
add_action( 'admin_post_cb_save_fonts', [ __CLASS__, 'handle_save' ] );
add_action( 'wp_ajax_cb_fonts_fetch', [ __CLASS__, 'ajax_fetch' ] );
add_action( 'wp_ajax_cb_fonts_clear', [ __CLASS__, 'ajax_clear' ] );
}
public static function get_settings(): array {
$s = get_option( self::OPTION, [] );
$s = wp_parse_args( is_array( $s ) ? $s : [], [
'enabled' => false,
'map' => [],
'updated_at' => 0,
'count' => 0,
'css_count' => 0,
] );
if ( ! is_array( $s['map'] ) ) {
$s['map'] = [];
}
return $s;
}
public static function is_enabled(): bool {
$s = self::get_settings();
return ! empty( $s['enabled'] ) && ! empty( $s['map'] );
}
/* ───────────────────────── front-end rewrite ───────────────────────── */
public static function start_buffer(): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
return;
}
if ( ! self::is_enabled() ) {
return;
}
ob_start( [ __CLASS__, 'rewrite' ] );
}
public static function rewrite( string $html ): string {
if ( $html === '' ) {
return $html;
}
$map = self::get_settings()['map'];
if ( empty( $map ) ) {
return $html;
}
// Longest keys first so a shorter URL can't pre-empt a longer one.
uksort( $map, fn( $a, $b ) => strlen( (string) $b ) <=> strlen( (string) $a ) );
$html = str_replace( array_keys( $map ), array_values( $map ), $html );
// Drop now-pointless resource hints to Google's font hosts — a preconnect
// would still open a connection and leak the visitor's IP. Match any
// attribute order via look-aheads.
$out = preg_replace(
'#<link\b(?=[^>]*\brel=["\'](?:preconnect|dns-prefetch|preload)["\'])(?=[^>]*(?:fonts\.googleapis\.com|fonts\.gstatic\.com))[^>]*>\s*#i',
'',
$html
);
return $out === null ? $html : $out;
}
/* ───────────────────────── admin actions ───────────────────────── */
public static function handle_save(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_fonts', 'cb_fonts_nonce' );
$s = self::get_settings();
$s['enabled'] = ! empty( $_POST['cb_fonts_enabled'] );
update_option( self::OPTION, $s );
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=fonts&cb_saved=1' ) );
exit;
}
public static function ajax_fetch(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_fonts', 'nonce' );
$res = self::build_cache();
if ( is_wp_error( $res ) ) {
wp_send_json_error( [ 'message' => $res->get_error_message() ] );
}
wp_send_json_success( $res );
}
public static function ajax_clear(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_fonts', 'nonce' );
self::delete_dir();
$s = self::get_settings();
$s['map'] = [];
$s['count'] = 0;
$s['css_count'] = 0;
$s['updated_at'] = 0;
update_option( self::OPTION, $s );
wp_send_json_success( [ 'message' => __( 'Lokale Schriften gelöscht.', 'gdpr-content-blocker' ) ] );
}
/* ───────────────────────── download / cache ───────────────────────── */
private static function build_cache(): array|WP_Error {
self::$css_done = [];
self::$font_done = [];
self::$map = [];
self::$font_count = 0;
self::$css_count = 0;
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'cb_upload', (string) $upload['error'] );
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! wp_mkdir_p( $dir ) ) {
return new WP_Error( 'cb_mkdir', __( 'Upload-Verzeichnis konnte nicht erstellt werden.', 'gdpr-content-blocker' ) );
}
$home_host = self::host( home_url() );
$pages = CB_Settings::scan_urls();
foreach ( $pages as $page ) {
$html = self::http_get( $page );
if ( $html === null ) {
continue;
}
// 1) Google Fonts stylesheets referenced directly (link href / @import).
if ( preg_match_all( '#(?:https?:)?//fonts\.googleapis\.com/[^"\'()\s<>]+#i', $html, $g ) ) {
foreach ( array_unique( $g[0] ) as $found ) {
if ( isset( self::$map[ $found ] ) ) {
continue;
}
$local = self::process_googleapis_css( self::abs_url( $found ) );
if ( $local ) {
self::$map[ $found ] = $local;
}
}
}
// 2) Direct gstatic font URLs in the HTML (e.g. inline @font-face).
if ( preg_match_all( '#(?:https?:)?//fonts\.gstatic\.com/[^"\'()\s<>]+#i', $html, $gs ) ) {
foreach ( array_unique( $gs[0] ) as $found ) {
if ( isset( self::$map[ $found ] ) ) {
continue;
}
$local = self::download_font( self::abs_url( $found ) );
if ( $local ) {
self::$map[ $found ] = $local;
}
}
}
// 3) Same-origin external CSS files that themselves reference Google Fonts.
if ( preg_match_all( '#<link\b[^>]*>#i', $html, $links ) ) {
foreach ( $links[0] as $tag ) {
if ( self::$css_count >= self::MAX_CSS ) {
break;
}
$rel = self::attr( $tag, 'rel' );
$href = self::attr( $tag, 'href' );
if ( $href === '' || stripos( $rel, 'stylesheet' ) === false ) {
continue;
}
$abs = self::abs_url( $href );
$h = self::host( $abs );
if ( $h === 'fonts.googleapis.com' || $h !== $home_host ) {
continue; // googleapis handled in (1); only our own CSS files here
}
$cache_key = 'ext:' . $abs;
if ( isset( self::$css_done[ $cache_key ] ) ) {
continue;
}
self::$css_done[ $cache_key ] = true;
$css = self::http_get( $abs, true );
if ( $css === null
|| ( stripos( $css, 'fonts.googleapis.com' ) === false
&& stripos( $css, 'fonts.gstatic.com' ) === false ) ) {
continue;
}
$localized = self::localize_css( $css, $abs, true );
if ( $localized !== $css ) {
$url = self::save( 'css', $localized );
if ( $url ) {
self::$map[ $href ] = $url;
self::$css_count++;
}
}
}
}
}
$s = self::get_settings();
$s['map'] = self::$map;
$s['count'] = self::$font_count;
$s['css_count'] = self::$css_count;
$s['updated_at'] = time();
$s['enabled'] = true; // a successful build implies the feature is wanted
update_option( self::OPTION, $s );
return [
'fonts' => self::$font_count,
'css' => self::$css_count,
'mapped' => count( self::$map ),
'message' => sprintf(
/* translators: 1: number of stylesheets, 2: number of font files */
__( '%1$d Schrift-Stylesheet(s) und %2$d Schriftdatei(en) lokal gespeichert.', 'gdpr-content-blocker' ),
self::$css_count,
self::$font_count
),
];
}
/** Fetch a googleapis CSS, localise its gstatic URLs, store it; return local URL. */
private static function process_googleapis_css( string $absUrl ): ?string {
if ( array_key_exists( $absUrl, self::$css_done ) ) {
return self::$css_done[ $absUrl ];
}
if ( self::$css_count >= self::MAX_CSS || self::host( $absUrl ) !== 'fonts.googleapis.com' ) {
return null;
}
$css = self::http_get( $absUrl, true );
if ( $css === null ) {
self::$css_done[ $absUrl ] = null;
return null;
}
$css = self::localize_css( $css, $absUrl, false );
$url = self::save( 'css', $css );
if ( $url ) {
self::$css_count++;
}
self::$css_done[ $absUrl ] = $url;
return $url;
}
/** Download a single gstatic font file; return its local URL. */
private static function download_font( string $absUrl ): ?string {
if ( array_key_exists( $absUrl, self::$font_done ) ) {
return self::$font_done[ $absUrl ];
}
if ( self::$font_count >= self::MAX_FONTS || self::host( $absUrl ) !== 'fonts.gstatic.com' ) {
return null;
}
$bin = self::http_get( $absUrl );
if ( $bin === null ) {
self::$font_done[ $absUrl ] = null;
return null;
}
$url = self::save( self::ext_from_url( $absUrl ), $bin );
if ( $url ) {
self::$font_count++;
}
self::$font_done[ $absUrl ] = $url;
return $url;
}
/**
* Rewrite a CSS body: googleapis @import → local; gstatic url() → local file.
* When $absolutize is true (external theme CSS we copy elsewhere), all other
* relative url()s are made absolute against $baseUrl so they keep working.
*/
private static function localize_css( string $css, string $baseUrl, bool $absolutize ): string {
// @import url("…googleapis…") or @import "…googleapis…"
$css = preg_replace_callback(
'#@import\s+(?:url\(\s*)?([\'"]?)([^\'"\)\s]+)\1\s*\)?\s*;#i',
function ( array $m ): string {
$abs = self::abs_url( $m[2] );
if ( self::host( $abs ) !== 'fonts.googleapis.com' ) {
return $m[0];
}
$local = self::process_googleapis_css( $abs );
return $local ? '@import url("' . $local . '");' : $m[0];
},
$css
) ?? $css;
// url(…gstatic…woff2) → local
$css = preg_replace_callback(
'#url\(\s*([\'"]?)([^\'"\)\s]+)\1\s*\)#i',
function ( array $m ) use ( $baseUrl, $absolutize ): string {
$raw = $m[2];
$abs = self::abs_url( $raw, $baseUrl );
if ( self::host( $abs ) === 'fonts.gstatic.com' ) {
$local = self::download_font( $abs );
return $local ? 'url("' . $local . '")' : $m[0];
}
// Keep other resources working when we relocate the CSS file.
if ( $absolutize && ! preg_match( '#^(data:|https?://|//)#i', $raw ) ) {
return 'url("' . $abs . '")';
}
return $m[0];
},
$css
) ?? $css;
return $css;
}
/* ───────────────────────── small helpers ───────────────────────── */
/** GET a URL with a browser UA. Returns the body (string) or null on failure. */
private static function http_get( string $url, bool $css = false ): ?string {
$args = [
'timeout' => 20,
'redirection' => 3,
'user-agent' => self::UA,
];
if ( $css ) {
$args['headers'] = [ 'Accept' => 'text/css,*/*;q=0.1' ];
}
$resp = wp_remote_get( $url, $args );
if ( is_wp_error( $resp ) || wp_remote_retrieve_response_code( $resp ) !== 200 ) {
return null;
}
$body = wp_remote_retrieve_body( $resp );
return $body === '' ? null : $body;
}
/** Save bytes to the uploads font dir (deduped by content hash); return URL. */
private static function save( string $ext, string $contents ): ?string {
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return null;
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! wp_mkdir_p( $dir ) ) {
return null;
}
$ext = preg_replace( '/[^a-z0-9]/', '', strtolower( $ext ) ) ?: 'bin';
$name = md5( $contents ) . '.' . $ext;
$path = trailingslashit( $dir ) . $name;
if ( ! file_exists( $path ) && file_put_contents( $path, $contents ) === false ) {
return null;
}
return trailingslashit( $upload['baseurl'] ) . self::DIR . '/' . $name;
}
private static function delete_dir(): void {
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return;
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! is_dir( $dir ) ) {
return;
}
foreach ( (array) glob( trailingslashit( $dir ) . '*' ) as $file ) {
if ( is_file( $file ) ) {
@unlink( $file );
}
}
}
/** Resolve protocol-relative / relative URLs (and decode HTML entities). */
private static function abs_url( string $url, string $base = '' ): string {
$url = html_entity_decode( trim( $url ), ENT_QUOTES );
if ( str_starts_with( $url, '//' ) ) {
return 'https:' . $url;
}
if ( preg_match( '#^(data:|https?://)#i', $url ) ) {
return $url;
}
if ( $base === '' ) {
return $url;
}
// Relative → absolute against $base.
$b = wp_parse_url( $base );
$scheme = $b['scheme'] ?? 'https';
$host = $b['host'] ?? '';
$port = isset( $b['port'] ) ? ':' . $b['port'] : '';
$path = $b['path'] ?? '/';
if ( str_starts_with( $url, '/' ) ) {
$full = $url;
} else {
$full = preg_replace( '#/[^/]*$#', '/', $path ) . $url;
}
$segs = [];
foreach ( explode( '/', $full ) as $seg ) {
if ( $seg === '..' ) {
array_pop( $segs );
} elseif ( $seg !== '.' && $seg !== '' ) {
$segs[] = $seg;
}
}
return $scheme . '://' . $host . $port . '/' . implode( '/', $segs );
}
private static function host( string $url ): string {
return strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
}
private static function ext_from_url( string $url ): string {
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
return in_array( $ext, [ 'woff2', 'woff', 'ttf', 'otf', 'eot', 'svg' ], true ) ? $ext : 'woff2';
}
/** Read a single attribute value out of an HTML tag string. */
private static function attr( string $tag, string $name ): string {
$re = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i';
if ( preg_match( $re, $tag, $m ) ) {
if ( ( $m[2] ?? '' ) !== '' ) {
return $m[2];
}
if ( ( $m[3] ?? '' ) !== '' ) {
return $m[3];
}
return $m[4] ?? '';
}
return '';
}
/* ───────────────────────── settings tab ───────────────────────── */
public static function render_tab(): void {
$s = self::get_settings();
$has_cache = ! empty( $s['map'] );
$when = $s['updated_at']
? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $s['updated_at'] )
: '';
?>
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Bindet Google Fonts lokal ein: Das Plugin lädt die Schrift-Stylesheets und Schriftdateien einmalig herunter, speichert sie in Ihrem Uploads-Ordner und ersetzt die Pfade im Seitenquelltext. So wird beim Seitenaufruf keine Verbindung mehr zu Google aufgebaut (kein IP-Transfer) und die Schriften laden schneller.', 'gdpr-content-blocker' ); ?>
</p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'cb_fonts', 'cb_fonts_nonce' ); ?>
<input type="hidden" name="action" value="cb_save_fonts">
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Lokales Hosting', 'gdpr-content-blocker' ); ?></th>
<td>
<label class="cb-switch" title="<?php esc_attr_e( 'Lokale Schriften ein/aus', 'gdpr-content-blocker' ); ?>">
<input type="checkbox" name="cb_fonts_enabled" value="1" <?php checked( ! empty( $s['enabled'] ) ); ?>>
<span class="cb-switch__slider"></span>
</label>
<p class="description"><?php esc_html_e( 'Wenn aktiv, werden auf der Webseite die heruntergeladenen lokalen Schriften statt der Google-Pfade ausgeliefert.', 'gdpr-content-blocker' ); ?></p>
</td>
</tr>
</tbody></table>
<?php submit_button( __( 'Speichern', 'gdpr-content-blocker' ) ); ?>
</form>
<hr>
<h2><?php esc_html_e( 'Schriften herunterladen', 'gdpr-content-blocker' ); ?></h2>
<p>
<button type="button" id="cb-fonts-fetch" class="button button-primary">
<?php esc_html_e( 'Jetzt herunterladen & einbinden', 'gdpr-content-blocker' ); ?>
</button>
<?php if ( $has_cache ) : ?>
<button type="button" id="cb-fonts-clear" class="button" style="margin-left:6px;">
<?php esc_html_e( 'Lokale Schriften löschen', 'gdpr-content-blocker' ); ?>
</button>
<?php endif; ?>
<span id="cb-fonts-status" style="margin-left:10px;"></span>
</p>
<?php if ( $has_cache ) : ?>
<p class="description">
<?php
printf(
/* translators: 1: stylesheet count, 2: font file count, 3: date/time */
esc_html__( 'Aktuell lokal: %1$d Stylesheet(s), %2$d Schriftdatei(en) – zuletzt aktualisiert am %3$s.', 'gdpr-content-blocker' ),
(int) ( $s['css_count'] ?? 0 ),
(int) ( $s['count'] ?? 0 ),
esc_html( $when )
);
?>
</p>
<?php endif; ?>
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Hinweis: Gescannt werden die Startseite und einige Unterseiten. Falls auf einzelnen Seiten weitere Google Fonts vorkommen, führen Sie die Aktion nach Inhaltsänderungen erneut aus.', 'gdpr-content-blocker' ); ?>
</p>
<?php
}
}

View File

@@ -358,6 +358,18 @@ class CB_License {
</tr>
</tbody></table>
<?php if ( $status === 'limit' ) : ?>
<!-- Not enough seats to activate this site: offer an upgrade. -->
<p style="margin:8px 0 16px;">
<a href="https://lucas-orth.de/wp-plugins/gdpr-content-blocker/" target="_blank" rel="noopener noreferrer" class="button button-primary">
<?php esc_html_e( 'Lizenz upgraden', 'gdpr-content-blocker' ); ?>
</a>
<span class="description" style="margin-left:8px;">
<?php esc_html_e( 'Schalten Sie weitere Domains frei, um diese Seite zusätzlich zu aktivieren – oder geben Sie unten eine bestehende Domain frei.', 'gdpr-content-blocker' ); ?>
</span>
</p>
<?php endif; ?>
<?php if ( $status === 'limit' && ! empty( $lic['pending_domains'] ) ) : ?>
<!-- No free slots: list every bound domain with a trash button to free it. -->
<h2><?php esc_html_e( 'Belegte Domains', 'gdpr-content-blocker' ); ?></h2>

View File

@@ -170,6 +170,7 @@ class CB_Settings {
'newServiceLbl' => __( 'Neuer Dienst', 'gdpr-content-blocker' ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'scanNonce' => wp_create_nonce( 'cb_scan' ),
'fontsNonce' => wp_create_nonce( 'cb_fonts' ),
'i18n' => [
'scanning' => __( 'Scanne Webseite …', 'gdpr-content-blocker' ),
'scanError' => __( 'Scan fehlgeschlagen:', 'gdpr-content-blocker' ),
@@ -188,6 +189,9 @@ class CB_Settings {
'templateAvail' => __( 'Vorlage verfügbar', 'gdpr-content-blocker' ),
'scannedPages'=> __( 'Gescannte Seiten:', 'gdpr-content-blocker' ),
'foundOn' => __( 'Gefunden auf', 'gdpr-content-blocker' ),
'fontsFetching' => __( 'Schriften werden heruntergeladen …', 'gdpr-content-blocker' ),
'fontsError' => __( 'Fehler beim Herunterladen:', 'gdpr-content-blocker' ),
'fontsClearConfirm'=> __( 'Lokale Schriften wirklich löschen?', 'gdpr-content-blocker' ),
],
] );
}
@@ -273,6 +277,9 @@ class CB_Settings {
<a href="#cb-tab-style" class="nav-tab" data-cb-tab="style">
<?php esc_html_e( 'Darstellung', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-fonts" class="nav-tab" data-cb-tab="fonts">
<?php esc_html_e( 'Schriften', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-shortcodes" class="nav-tab" data-cb-tab="shortcodes">
<?php esc_html_e( 'Shortcodes', 'gdpr-content-blocker' ); ?>
</a>
@@ -345,6 +352,11 @@ class CB_Settings {
</form>
</div>
<!-- Fonts Tab -->
<div id="cb-tab-fonts" class="cb-tab-content" style="display:none;">
<?php CB_Fonts::render_tab(); ?>
</div>
<!-- Shortcodes Tab -->
<div id="cb-tab-shortcodes" class="cb-tab-content" style="display:none;">
<?php self::render_shortcodes_help(); ?>
@@ -735,7 +747,7 @@ class CB_Settings {
* Build the list of URLs to scan: the home page plus a few published
* pages/posts, so the scan covers more than just the front page.
*/
private static function scan_urls(): array {
public static function scan_urls(): array {
$urls = [ home_url( '/' ) ];
$posts = get_posts( [