Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54eccc2d5 | ||
|
|
868dbe0ff4 | ||
|
|
4cd1390a94 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -5,6 +5,38 @@ All notable changes to **h4b-image-optim** will be documented here.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-05-19
|
||||||
|
|
||||||
|
Added migrate-from-smush + Picture-tag rewriter. The plugin is now usable
|
||||||
|
end-to-end on a site that previously ran Smush — no double-processing, and
|
||||||
|
WebP / AVIF actually get served to visitors.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`wp h4b-img migrate-from-smush`** — reads Smush's `wp-smpro-smush-data`
|
||||||
|
postmeta, writes equivalent `_h4b_img_optim` markers so our bulk command
|
||||||
|
skips already-optimised attachments.
|
||||||
|
- `--dry-run` to see counts only
|
||||||
|
- `--force-rescan` to mark for re-optimisation instead (use when migrating
|
||||||
|
off Smush completely)
|
||||||
|
- `--remove-smush-meta` to delete the Smush postmeta after migration
|
||||||
|
- **`Picture_Tag`** rewriter (the_content + post_thumbnail_html + widget_text +
|
||||||
|
Elementor frontend + wp_get_attachment_image filters at priority 99). Wraps
|
||||||
|
`<img>` tags with `<picture><source type="image/avif"><source type="image/webp"><img></picture>`
|
||||||
|
when sibling files exist on disk.
|
||||||
|
- Skips images already inside a `<picture>` block (double-wrap protection
|
||||||
|
via byte-range tracking)
|
||||||
|
- Skips images with `data-no-h4b` attribute (per-image opt-out)
|
||||||
|
- Resolves siblings either alongside the JPG (`foo.jpg.webp`) or in Smush's
|
||||||
|
`wp-content/smush-webp/` mirror tree
|
||||||
|
- Caches sibling lookups per request to keep it cheap
|
||||||
|
- Preserves srcset, sizes, alt, class, and all other original `<img>` attrs
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- migrate-from-smush: 100 attachments migrated cleanly on dev.rds.ink
|
||||||
|
- bulk count correctly drops after migration (734 → 634)
|
||||||
|
- Picture_Tag: 8 edge-case tests pass (no siblings, multiple images, opt-out,
|
||||||
|
srcset preservation, external URLs, no `<img>`, existing `<picture>`, mixed)
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-19
|
## [0.1.0] — 2026-05-19
|
||||||
|
|
||||||
Initial MVP. Replaces Smush Pro's optimisation pipeline without the
|
Initial MVP. Replaces Smush Pro's optimisation pipeline without the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: H4B Image Optim
|
* Plugin Name: H4B Image Optim
|
||||||
* Plugin URI: https://gitea.help4bis.com/help4bis/h4b-image-optim
|
* Plugin URI: https://gitea.help4bis.com/help4bis/h4b-image-optim
|
||||||
* Description: ICC-safe image optimisation with WebP + AVIF generation. Replaces Smush Pro without the grey-wash bug. No CDN.
|
* Description: ICC-safe image optimisation with WebP + AVIF generation. Replaces Smush Pro without the grey-wash bug. No CDN.
|
||||||
* Version: 0.1.0
|
* Version: 0.2.2
|
||||||
* Author: help4bis (Henk + Claude)
|
* Author: help4bis (Henk + Claude)
|
||||||
* Author URI: https://help4bis.com
|
* Author URI: https://help4bis.com
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
@@ -17,7 +17,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'H4B_IMG_OPTIM_VERSION', '0.1.0' );
|
define( 'H4B_IMG_OPTIM_VERSION', '0.2.2' );
|
||||||
define( 'H4B_IMG_OPTIM_FILE', __FILE__ );
|
define( 'H4B_IMG_OPTIM_FILE', __FILE__ );
|
||||||
define( 'H4B_IMG_OPTIM_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'H4B_IMG_OPTIM_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'H4B_IMG_OPTIM_URL', plugin_dir_url( __FILE__ ) );
|
define( 'H4B_IMG_OPTIM_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|||||||
193
includes/class-cli-migrate.php
Normal file
193
includes/class-cli-migrate.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* wp h4b-img migrate-from-smush
|
||||||
|
*
|
||||||
|
* Reads Smush's `wp-smpro-smush-data` postmeta and writes an equivalent
|
||||||
|
* `_h4b_img_optim` marker so our bulk command skips already-processed images.
|
||||||
|
*
|
||||||
|
* @package H4B\ImageOptim
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace H4B\ImageOptim;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CLI_Migrate {
|
||||||
|
|
||||||
|
public const SMUSH_META_KEY = 'wp-smpro-smush-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate Smush optimisation history into h4b-image-optim's tracking.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count only — do not write any postmeta.
|
||||||
|
*
|
||||||
|
* [--force-rescan]
|
||||||
|
* : Mark Smush-processed images as UNPROCESSED so our bulk command
|
||||||
|
* re-runs them through our pipeline. Use this when migrating off
|
||||||
|
* Smush and you want to re-optimise everything with our ICC-safe
|
||||||
|
* settings. Default: false (skip them, trust Smush did fine).
|
||||||
|
*
|
||||||
|
* [--remove-smush-meta]
|
||||||
|
* : After migration, delete the wp-smpro-smush-data postmeta. Frees
|
||||||
|
* ~1KB per attachment. Default: false (keep for audit).
|
||||||
|
*
|
||||||
|
* [--limit=<n>]
|
||||||
|
* : Max attachments to migrate. 0 = unlimited.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
* # See how many Smush-processed images exist
|
||||||
|
* wp h4b-img migrate-from-smush --dry-run
|
||||||
|
*
|
||||||
|
* # Trust Smush's prior work — just mark them processed
|
||||||
|
* wp h4b-img migrate-from-smush
|
||||||
|
*
|
||||||
|
* # Re-optimise everything from scratch through our pipeline
|
||||||
|
* wp h4b-img migrate-from-smush --force-rescan
|
||||||
|
*
|
||||||
|
* # Also strip the Smush postmeta after migration
|
||||||
|
* wp h4b-img migrate-from-smush --remove-smush-meta
|
||||||
|
*/
|
||||||
|
public function __invoke( $args, $assoc ): void {
|
||||||
|
$dry_run = ! empty( $assoc['dry-run'] );
|
||||||
|
$force_rescan = ! empty( $assoc['force-rescan'] );
|
||||||
|
$remove_smush = ! empty( $assoc['remove-smush-meta'] );
|
||||||
|
$limit = (int) ( $assoc['limit'] ?? 0 );
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$base_sql = "SELECT pm.post_id, pm.meta_value, p.post_mime_type
|
||||||
|
FROM {$wpdb->postmeta} pm
|
||||||
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||||
|
WHERE pm.meta_key = %s
|
||||||
|
ORDER BY pm.post_id ASC";
|
||||||
|
if ( $limit > 0 ) {
|
||||||
|
$base_sql .= ' LIMIT ' . (int) $limit;
|
||||||
|
}
|
||||||
|
$rows = $wpdb->get_results( $wpdb->prepare( $base_sql, self::SMUSH_META_KEY ) );
|
||||||
|
|
||||||
|
$total = count( $rows );
|
||||||
|
\WP_CLI::log( "Found $total attachments with Smush optimisation history." );
|
||||||
|
if ( $total === 0 ) {
|
||||||
|
\WP_CLI::success( 'Nothing to migrate.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
$action = $force_rescan ? 'mark for re-optimisation' : 'mark as already processed';
|
||||||
|
$remove = $remove_smush ? ' + remove Smush meta' : '';
|
||||||
|
\WP_CLI::success( "Dry-run only. Would $action $total attachments$remove." );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = \WP_CLI\Utils\make_progress_bar( 'Migrating', $total );
|
||||||
|
$migrated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$errored = 0;
|
||||||
|
$removed_meta = 0;
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$id = (int) $row->post_id;
|
||||||
|
try {
|
||||||
|
if ( $force_rescan ) {
|
||||||
|
// Wipe our marker so bulk treats this as unprocessed
|
||||||
|
delete_post_meta( $id, Attachment_Meta::META_KEY );
|
||||||
|
} else {
|
||||||
|
$existing = Attachment_Meta::get( $id );
|
||||||
|
if ( ! empty( $existing['processed_at'] ) ) {
|
||||||
|
$skipped++;
|
||||||
|
$progress->tick();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$stats = self::translate_smush_stats( (string) $row->meta_value );
|
||||||
|
$meta = [
|
||||||
|
'version' => H4B_IMG_OPTIM_VERSION,
|
||||||
|
'processed_at' => current_time( 'c' ),
|
||||||
|
'migrated_from' => 'smush',
|
||||||
|
'tool_chain' => [ 'smush_legacy' ],
|
||||||
|
'sizes' => $stats['sizes'],
|
||||||
|
'totals' => $stats['totals'],
|
||||||
|
'note' => 'Migrated from Smush — not re-processed by h4b-image-optim.',
|
||||||
|
];
|
||||||
|
Attachment_Meta::set( $id, $meta );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $remove_smush ) {
|
||||||
|
if ( delete_post_meta( $id, self::SMUSH_META_KEY ) ) {
|
||||||
|
$removed_meta++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$migrated++;
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
$errored++;
|
||||||
|
}
|
||||||
|
$progress->tick();
|
||||||
|
}
|
||||||
|
$progress->finish();
|
||||||
|
|
||||||
|
\WP_CLI::success( sprintf(
|
||||||
|
"Migration done.\n Migrated: %d\n Already-marked (skipped): %d\n Errors: %d\n Smush meta removed: %d",
|
||||||
|
$migrated, $skipped, $errored, $removed_meta
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate Smush's serialized stats into our schema.
|
||||||
|
*
|
||||||
|
* Smush stores:
|
||||||
|
* a:2:{
|
||||||
|
* s:"stats" => [ time, bytes, percent, size_before, size_after, lossy, keep_exif, api_version ],
|
||||||
|
* s:"sizes" => [ <size_key> => stdClass{ time, bytes, percent, size_before, size_after }, ... ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function translate_smush_stats( string $raw ): array {
|
||||||
|
$result = [
|
||||||
|
'sizes' => [],
|
||||||
|
'totals' => [
|
||||||
|
'bytes_saved' => 0,
|
||||||
|
'bytes_before' => 0,
|
||||||
|
'bytes_after' => 0,
|
||||||
|
'percent' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Smush uses PHP serialize. Allow stdClass.
|
||||||
|
$data = @unserialize( $raw, [ 'allowed_classes' => [ \stdClass::class ] ] );
|
||||||
|
if ( ! is_array( $data ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isset( $data['stats'] ) && is_array( $data['stats'] ) ) {
|
||||||
|
$result['totals']['bytes_saved'] = (int) ( $data['stats']['bytes'] ?? 0 );
|
||||||
|
$result['totals']['bytes_before'] = (int) ( $data['stats']['size_before'] ?? 0 );
|
||||||
|
$result['totals']['bytes_after'] = (int) ( $data['stats']['size_after'] ?? 0 );
|
||||||
|
$result['totals']['percent'] = (float) ( $data['stats']['percent'] ?? 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isset( $data['sizes'] ) && is_array( $data['sizes'] ) ) {
|
||||||
|
foreach ( $data['sizes'] as $size_key => $info ) {
|
||||||
|
// $info can be stdClass or array
|
||||||
|
$arr = is_object( $info ) ? get_object_vars( $info ) : (array) $info;
|
||||||
|
$result['sizes'][ (string) $size_key ] = [
|
||||||
|
'status' => 'migrated_from_smush',
|
||||||
|
'before' => (int) ( $arr['size_before'] ?? 0 ),
|
||||||
|
'after' => (int) ( $arr['size_after'] ?? 0 ),
|
||||||
|
'percent' => (float) ( $arr['percent'] ?? 0 ),
|
||||||
|
'icc_preserved' => false, // unknown — Smush stripped it
|
||||||
|
'tool_chain' => [ 'smush_legacy' ],
|
||||||
|
'webp' => null,
|
||||||
|
'avif' => null,
|
||||||
|
'avif_status' => 'never_generated',
|
||||||
|
'backup' => null,
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
includes/class-cli-siblings.php
Normal file
300
includes/class-cli-siblings.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* wp h4b-img generate-missing-siblings
|
||||||
|
*
|
||||||
|
* Walks every attachment that WP knows about (via wp_attachment_metadata),
|
||||||
|
* and for each registered size file, creates any missing .webp / .avif
|
||||||
|
* sibling synchronously. Does NOT re-encode the source JPG/PNG.
|
||||||
|
*
|
||||||
|
* Why this exists
|
||||||
|
* ===============
|
||||||
|
* The `bulk` command queues AVIF jobs via wp_schedule_single_event(time()+30, …).
|
||||||
|
* WP-Cron coalesces identical timestamped events, so when bulk enqueues several
|
||||||
|
* hundred at the same instant a chunk get dropped. This command does the work
|
||||||
|
* inline — no queueing, no coalescing — and only touches files that WordPress
|
||||||
|
* actually references.
|
||||||
|
*
|
||||||
|
* @package H4B\ImageOptim
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace H4B\ImageOptim;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CLI_Siblings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate any missing .webp / .avif siblings for registered attachment sizes.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Just count what would be done.
|
||||||
|
*
|
||||||
|
* [--limit=<n>]
|
||||||
|
* : Max attachments to process. 0 = unlimited.
|
||||||
|
*
|
||||||
|
* [--webp-only]
|
||||||
|
* : Generate only WebP siblings.
|
||||||
|
*
|
||||||
|
* [--avif-only]
|
||||||
|
* : Generate only AVIF siblings.
|
||||||
|
*
|
||||||
|
* [--min-size=<bytes>]
|
||||||
|
* : Skip source files smaller than this. Default: 20480 (20 KB).
|
||||||
|
*
|
||||||
|
* [--include-orphans]
|
||||||
|
* : Also process disk files that aren't in wp_attachment_metadata.
|
||||||
|
* Default: false (only registered sizes).
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
* wp h4b-img generate-missing-siblings --dry-run
|
||||||
|
* wp h4b-img generate-missing-siblings --avif-only
|
||||||
|
* wp h4b-img generate-missing-siblings --limit=50
|
||||||
|
*/
|
||||||
|
public function __invoke( $args, $assoc ): void {
|
||||||
|
$dry_run = ! empty( $assoc['dry-run'] );
|
||||||
|
$webp_only = ! empty( $assoc['webp-only'] );
|
||||||
|
$avif_only = ! empty( $assoc['avif-only'] );
|
||||||
|
$limit = (int) ( $assoc['limit'] ?? 0 );
|
||||||
|
$min_size = (int) ( $assoc['min-size'] ?? 20480 );
|
||||||
|
$include_orph = ! empty( $assoc['include-orphans'] );
|
||||||
|
|
||||||
|
if ( $webp_only && $avif_only ) {
|
||||||
|
\WP_CLI::error( 'Pick one of --webp-only / --avif-only, not both.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$do_webp = ! $avif_only;
|
||||||
|
$do_avif = ! $webp_only;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$uploads = wp_get_upload_dir();
|
||||||
|
$basedir = trailingslashit( $uploads['basedir'] );
|
||||||
|
|
||||||
|
// Collect all registered source paths
|
||||||
|
$registered_paths = self::collect_registered_paths();
|
||||||
|
\WP_CLI::log( sprintf( 'Registered image paths: %d', count( $registered_paths ) ) );
|
||||||
|
|
||||||
|
// Optionally also walk disk for orphans
|
||||||
|
if ( $include_orph ) {
|
||||||
|
$disk_paths = self::collect_disk_paths( $basedir );
|
||||||
|
$orphans = array_diff_key( $disk_paths, $registered_paths );
|
||||||
|
\WP_CLI::log( sprintf( ' + including %d orphan disk files', count( $orphans ) ) );
|
||||||
|
$registered_paths = $registered_paths + $orphans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by --min-size and existing siblings
|
||||||
|
$todo = []; // [ ['path'=>abs, 'webp'=>bool, 'avif'=>bool], … ]
|
||||||
|
foreach ( $registered_paths as $rel => $_ ) {
|
||||||
|
$abs = $basedir . $rel;
|
||||||
|
if ( ! is_readable( $abs ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( filesize( $abs ) < $min_size ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$need_webp = $do_webp && ! file_exists( $abs . '.webp' );
|
||||||
|
$need_avif = $do_avif && Tools::has_avif() && ! file_exists( $abs . '.avif' );
|
||||||
|
if ( $need_webp || $need_avif ) {
|
||||||
|
$todo[] = [ 'path' => $abs, 'webp' => $need_webp, 'avif' => $need_avif ];
|
||||||
|
if ( $limit > 0 && count( $todo ) >= $limit ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
\WP_CLI::log( sprintf( 'Need siblings: %d files', count( $todo ) ) );
|
||||||
|
if ( $dry_run ) {
|
||||||
|
\WP_CLI::success( 'Dry-run only.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $todo ) ) {
|
||||||
|
\WP_CLI::success( 'Nothing to do.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = \WP_CLI\Utils\make_progress_bar( 'Generating', count( $todo ) );
|
||||||
|
$summary = [ 'webp_done' => 0, 'webp_failed' => 0, 'avif_done' => 0, 'avif_failed' => 0 ];
|
||||||
|
foreach ( $todo as $item ) {
|
||||||
|
if ( $item['webp'] ) {
|
||||||
|
$r = Format_Generator::make_webp( $item['path'] );
|
||||||
|
$key = $r['status'] === 'done' ? 'webp_done' : 'webp_failed';
|
||||||
|
$summary[ $key ]++;
|
||||||
|
}
|
||||||
|
if ( $item['avif'] ) {
|
||||||
|
// Run synchronously here — bypass the cron queue path
|
||||||
|
$settings = Settings::all();
|
||||||
|
$settings['avif_async'] = false;
|
||||||
|
$r = Format_Generator::make_avif( $item['path'], $settings );
|
||||||
|
$key = $r['status'] === 'done' ? 'avif_done' : 'avif_failed';
|
||||||
|
$summary[ $key ]++;
|
||||||
|
}
|
||||||
|
$progress->tick();
|
||||||
|
}
|
||||||
|
$progress->finish();
|
||||||
|
|
||||||
|
\WP_CLI::success( sprintf(
|
||||||
|
"Done.\n WebP generated: %d\n WebP failed: %d\n AVIF generated: %d\n AVIF failed: %d",
|
||||||
|
$summary['webp_done'], $summary['webp_failed'],
|
||||||
|
$summary['avif_done'], $summary['avif_failed']
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile attachment postmeta with what's actually on disk.
|
||||||
|
*
|
||||||
|
* For each `_h4b_img_optim` size entry, if avif_status is 'queued' or 'never_generated'
|
||||||
|
* but the .avif file exists on disk, update to 'done' with the actual byte size.
|
||||||
|
* Similarly for webp.
|
||||||
|
*
|
||||||
|
* Use this after manual generate-missing-siblings runs OR to fix the stale
|
||||||
|
* 'queued' values from a v0.2.0 era bulk run.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Report counts only.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
* wp h4b-img reconcile-meta --dry-run
|
||||||
|
* wp h4b-img reconcile-meta
|
||||||
|
*/
|
||||||
|
public function reconcile_meta( $args, $assoc ): void {
|
||||||
|
$dry_run = ! empty( $assoc['dry-run'] );
|
||||||
|
global $wpdb;
|
||||||
|
$basedir = trailingslashit( wp_get_upload_dir()['basedir'] );
|
||||||
|
|
||||||
|
$rows = $wpdb->get_results( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key='_h4b_img_optim'" );
|
||||||
|
\WP_CLI::log( sprintf( 'Scanning %d attachments…', count( $rows ) ) );
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'avif_done_marked' => 0,
|
||||||
|
'avif_already_done' => 0,
|
||||||
|
'avif_still_missing' => 0,
|
||||||
|
'webp_done_marked' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $rows as $r ) {
|
||||||
|
$meta = @unserialize( $r->meta_value, [ 'allowed_classes' => false ] );
|
||||||
|
if ( ! is_array( $meta ) || empty( $meta['sizes'] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rel_parent = get_post_meta( $r->post_id, '_wp_attached_file', true );
|
||||||
|
if ( ! $rel_parent ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parent_dir = dirname( $rel_parent );
|
||||||
|
$attached_meta = wp_get_attachment_metadata( $r->post_id );
|
||||||
|
$changed = false;
|
||||||
|
foreach ( $meta['sizes'] as $size_key => &$entry ) {
|
||||||
|
// Resolve the file path for this size key
|
||||||
|
if ( $size_key === 'full' ) {
|
||||||
|
$file_rel = $rel_parent;
|
||||||
|
} elseif ( is_array( $attached_meta ) && ! empty( $attached_meta['sizes'][ $size_key ]['file'] ) ) {
|
||||||
|
$f = $attached_meta['sizes'][ $size_key ]['file'];
|
||||||
|
$file_rel = ( $parent_dir === '.' ) ? $f : "$parent_dir/$f";
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$src = $basedir . $file_rel;
|
||||||
|
$avif = $src . '.avif';
|
||||||
|
$webp = $src . '.webp';
|
||||||
|
|
||||||
|
// AVIF
|
||||||
|
if ( is_readable( $avif ) ) {
|
||||||
|
if ( ( $entry['avif_status'] ?? '' ) !== 'done' ) {
|
||||||
|
$entry['avif'] = filesize( $avif );
|
||||||
|
$entry['avif_status'] = 'done';
|
||||||
|
$updates['avif_done_marked']++;
|
||||||
|
$changed = true;
|
||||||
|
} else {
|
||||||
|
$updates['avif_already_done']++;
|
||||||
|
}
|
||||||
|
} elseif ( ( $entry['avif_status'] ?? '' ) === 'queued' ) {
|
||||||
|
$updates['avif_still_missing']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP — only mark if currently null/missing
|
||||||
|
if ( is_readable( $webp ) && empty( $entry['webp'] ) ) {
|
||||||
|
$entry['webp'] = filesize( $webp );
|
||||||
|
$updates['webp_done_marked']++;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset( $entry );
|
||||||
|
|
||||||
|
if ( $changed && ! $dry_run ) {
|
||||||
|
Attachment_Meta::set( (int) $r->post_id, $meta );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
\WP_CLI::success( sprintf(
|
||||||
|
"Reconciliation %s.\n avif_status 'queued' → 'done' on disk: %d\n avif already correctly 'done': %d\n avif still missing (queue stale): %d\n webp filesize backfilled in meta: %d",
|
||||||
|
$dry_run ? 'dry-run' : 'done',
|
||||||
|
$updates['avif_done_marked'],
|
||||||
|
$updates['avif_already_done'],
|
||||||
|
$updates['avif_still_missing'],
|
||||||
|
$updates['webp_done_marked']
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the set of all JPG/PNG paths registered in wp_attachment_metadata.
|
||||||
|
* Returns rel-path => true.
|
||||||
|
*/
|
||||||
|
private static function collect_registered_paths(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$paths = [];
|
||||||
|
$rows = $wpdb->get_results( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key='_wp_attachment_metadata'" );
|
||||||
|
foreach ( $rows as $r ) {
|
||||||
|
$m = @unserialize( $r->meta_value, [ 'allowed_classes' => false ] );
|
||||||
|
if ( ! is_array( $m ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( ! empty( $m['file'] ) ) {
|
||||||
|
$paths[ $m['file'] ] = true;
|
||||||
|
$parent_dir = dirname( $m['file'] );
|
||||||
|
if ( ! empty( $m['sizes'] ) ) {
|
||||||
|
foreach ( $m['sizes'] as $sz ) {
|
||||||
|
if ( ! empty( $sz['file'] ) ) {
|
||||||
|
$rel = ( $parent_dir === '.' ) ? $sz['file'] : $parent_dir . '/' . $sz['file'];
|
||||||
|
$paths[ $rel ] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the disk and return all .jpg/.jpeg/.png paths under $basedir,
|
||||||
|
* excluding our own working dirs.
|
||||||
|
*
|
||||||
|
* Returns rel-path => true.
|
||||||
|
*/
|
||||||
|
private static function collect_disk_paths( string $basedir ): array {
|
||||||
|
$paths = [];
|
||||||
|
$it = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator( $basedir, \FilesystemIterator::SKIP_DOTS )
|
||||||
|
);
|
||||||
|
foreach ( $it as $entry ) {
|
||||||
|
if ( ! $entry->isFile() ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$p = $entry->getPathname();
|
||||||
|
if ( strpos( $p, '/' . Optimizer::ORIGINALS_DIRNAME . '/' ) !== false ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower( $entry->getExtension() );
|
||||||
|
if ( ! in_array( $ext, [ 'jpg', 'jpeg', 'png' ], true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$paths[ substr( $p, strlen( $basedir ) ) ] = true;
|
||||||
|
}
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,14 @@ final class CLI {
|
|||||||
|
|
||||||
public static function register(): void {
|
public static function register(): void {
|
||||||
\WP_CLI::add_command( 'h4b-img', __CLASS__ );
|
\WP_CLI::add_command( 'h4b-img', __CLASS__ );
|
||||||
\WP_CLI::add_command( 'h4b-img bulk', CLI_Bulk::class );
|
\WP_CLI::add_command( 'h4b-img bulk', CLI_Bulk::class );
|
||||||
\WP_CLI::add_command( 'h4b-img rescue', CLI_Rescue::class );
|
\WP_CLI::add_command( 'h4b-img rescue', CLI_Rescue::class );
|
||||||
|
\WP_CLI::add_command( 'h4b-img migrate-from-smush', CLI_Migrate::class );
|
||||||
|
\WP_CLI::add_command( 'h4b-img generate-missing-siblings', CLI_Siblings::class );
|
||||||
|
\WP_CLI::add_command( 'h4b-img reconcile-meta',
|
||||||
|
function ( $args, $assoc ) {
|
||||||
|
( new CLI_Siblings() )->reconcile_meta( $args, $assoc );
|
||||||
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -115,18 +115,96 @@ final class Format_Generator {
|
|||||||
return $res;
|
return $res;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::encode_avif_now( $source, $settings );
|
// Synchronous path: encode + record outcome in attachment postmeta.
|
||||||
|
$result = self::encode_avif_now( $source, $settings );
|
||||||
|
self::record_avif_outcome( $source, $result );
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron entry point — encodes one queued path.
|
* Cron entry point — encodes one queued path. Recording happens inside
|
||||||
|
* make_avif() now, so this is a thin wrapper that forces synchronous mode.
|
||||||
*/
|
*/
|
||||||
public static function process_avif_job( string $source ): void {
|
public static function process_avif_job( string $source ): void {
|
||||||
if ( ! is_readable( $source ) ) {
|
if ( ! is_readable( $source ) ) {
|
||||||
|
self::record_avif_outcome( $source, [ 'status' => 'error', 'error' => 'source_unreadable' ] );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$settings = Settings::all();
|
$settings = Settings::all();
|
||||||
self::encode_avif_now( $source, $settings );
|
$settings['avif_async'] = false; // we ARE the queue handler — never re-queue
|
||||||
|
self::make_avif( $source, $settings );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the attachment + size_key that owns a given absolute path on disk,
|
||||||
|
* then update its h4b postmeta with the AVIF outcome.
|
||||||
|
*/
|
||||||
|
private static function record_avif_outcome( string $source_path, array $result ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$uploads = wp_get_upload_dir();
|
||||||
|
$basedir = trailingslashit( $uploads['basedir'] );
|
||||||
|
if ( strpos( $source_path, $basedir ) !== 0 ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$rel = substr( $source_path, strlen( $basedir ) );
|
||||||
|
$dir = dirname( $rel );
|
||||||
|
$name = basename( $rel );
|
||||||
|
|
||||||
|
// Reverse the -<W>x<H> filename suffix to find the parent attachment file
|
||||||
|
if ( preg_match( '/^(.+)-\d+x\d+(\.[a-z]+)$/i', $name, $m ) ) {
|
||||||
|
$parent_name = $m[1] . $m[2];
|
||||||
|
} else {
|
||||||
|
$parent_name = $name;
|
||||||
|
}
|
||||||
|
$parent_rel = ( $dir === '.' ) ? $parent_name : "$dir/$parent_name";
|
||||||
|
|
||||||
|
$att_id = (int) $wpdb->get_var( $wpdb->prepare(
|
||||||
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1",
|
||||||
|
'_wp_attached_file', $parent_rel
|
||||||
|
) );
|
||||||
|
if ( $att_id <= 0 ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the size key that matches this file
|
||||||
|
$meta = wp_get_attachment_metadata( $att_id );
|
||||||
|
$size_key = null;
|
||||||
|
if ( $name === $parent_name ) {
|
||||||
|
$size_key = 'full';
|
||||||
|
} elseif ( is_array( $meta ) && ! empty( $meta['sizes'] ) ) {
|
||||||
|
foreach ( $meta['sizes'] as $sk => $sd ) {
|
||||||
|
if ( ( $sd['file'] ?? '' ) === $name ) {
|
||||||
|
$size_key = (string) $sk;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $size_key === null ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$h4b_meta = Attachment_Meta::get( $att_id );
|
||||||
|
if ( ! isset( $h4b_meta['sizes'][ $size_key ] ) ) {
|
||||||
|
$h4b_meta['sizes'][ $size_key ] = [];
|
||||||
|
}
|
||||||
|
$entry = &$h4b_meta['sizes'][ $size_key ];
|
||||||
|
if ( $result['status'] === 'done' ) {
|
||||||
|
$entry['avif'] = $result['size'] ?? null;
|
||||||
|
$entry['avif_status'] = 'done';
|
||||||
|
} elseif ( $result['status'] === 'skipped' ) {
|
||||||
|
$entry['avif_status'] = 'skipped';
|
||||||
|
} else {
|
||||||
|
$entry['avif_status'] = 'error';
|
||||||
|
$entry['avif_error'] = $result['error'] ?? 'unknown';
|
||||||
|
}
|
||||||
|
// Clear from the pending bag too
|
||||||
|
if ( isset( $h4b_meta['avif_pending'][ $size_key ] ) ) {
|
||||||
|
unset( $h4b_meta['avif_pending'][ $size_key ] );
|
||||||
|
if ( empty( $h4b_meta['avif_pending'] ) ) {
|
||||||
|
unset( $h4b_meta['avif_pending'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Attachment_Meta::set( $att_id, $h4b_meta );
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function encode_avif_now( string $source, array $settings ): array {
|
private static function encode_avif_now( string $source, array $settings ): array {
|
||||||
|
|||||||
284
includes/class-picture-tag.php
Normal file
284
includes/class-picture-tag.php
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rewrites <img src="…jpg|png"> tags to <picture> with WebP + AVIF sources.
|
||||||
|
*
|
||||||
|
* Design notes
|
||||||
|
* ============
|
||||||
|
* 1. We hook `the_content` (post body), `post_thumbnail_html` (featured image)
|
||||||
|
* and `wp_get_attachment_image` (most theme/plugin image calls). For Elementor
|
||||||
|
* we register on `elementor/frontend/the_content` as well — Elementor pipes
|
||||||
|
* the_content through its own filter chain in some templates.
|
||||||
|
*
|
||||||
|
* 2. Sibling files MUST exist on disk for us to emit a <source>. If only WebP
|
||||||
|
* exists, we emit just the WebP source; same for AVIF. We never emit a
|
||||||
|
* <source> pointing at a non-existent file (would 404 in browsers).
|
||||||
|
*
|
||||||
|
* 3. Sibling naming convention is `<original>.webp` / `<original>.avif`.
|
||||||
|
* That matches what Format_Generator produces AND what Smush already
|
||||||
|
* produces in `wp-content/smush-webp/<rel>.webp` (we mirror-resolve too).
|
||||||
|
*
|
||||||
|
* 4. We DO NOT rewrite:
|
||||||
|
* - <img> already inside a <picture> (avoid double-wrapping)
|
||||||
|
* - <img> with data-no-h4b attribute
|
||||||
|
* - <img> with no usable src
|
||||||
|
* - srcset URLs (sources list is more semantic; let the browser pick)
|
||||||
|
*
|
||||||
|
* 5. Keep ALL original <img> attributes intact (class, alt, srcset, sizes,
|
||||||
|
* width, height, loading, decoding, fetchpriority, …). The <img> remains
|
||||||
|
* the visible fallback for browsers that don't understand <picture>.
|
||||||
|
*
|
||||||
|
* 6. Performance: build the regex once, iterate once over each filter call.
|
||||||
|
* Cache the "do the siblings exist" check per request (static array).
|
||||||
|
*
|
||||||
|
* @package H4B\ImageOptim
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace H4B\ImageOptim;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Picture_Tag {
|
||||||
|
|
||||||
|
/** @var array<string,array{webp:?string,avif:?string}> path → urls */
|
||||||
|
private static array $sibling_cache = [];
|
||||||
|
|
||||||
|
public static function register(): void {
|
||||||
|
if ( ! Settings::get( 'rewrite_content_images', true ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run LATE so other filters get to manipulate the raw <img> first.
|
||||||
|
add_filter( 'the_content', [ self::class, 'rewrite_html' ], 99 );
|
||||||
|
add_filter( 'post_thumbnail_html', [ self::class, 'rewrite_html' ], 99 );
|
||||||
|
add_filter( 'widget_text', [ self::class, 'rewrite_html' ], 99 );
|
||||||
|
|
||||||
|
// Elementor's frontend content filter
|
||||||
|
add_filter( 'elementor/frontend/the_content', [ self::class, 'rewrite_html' ], 99 );
|
||||||
|
|
||||||
|
// Single attachment_image (commonly used by themes + WooCommerce)
|
||||||
|
add_filter( 'wp_get_attachment_image', [ self::class, 'rewrite_html' ], 99, 5 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite all <img> tags in $html to <picture> wrappers where siblings exist.
|
||||||
|
*
|
||||||
|
* Skips <img> tags that are already inside an existing <picture>…</picture>
|
||||||
|
* (whether the surrounding <picture> existed in the input or was added by
|
||||||
|
* this same filter pass).
|
||||||
|
*/
|
||||||
|
public static function rewrite_html( $html, ...$_extra ): string {
|
||||||
|
// Coerce to string; some filters can pass non-string in edge cases.
|
||||||
|
if ( ! is_string( $html ) || $html === '' ) {
|
||||||
|
return is_string( $html ) ? $html : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick reject: no <img tag?
|
||||||
|
if ( stripos( $html, '<img' ) === false ) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Identify byte-range spans of existing <picture>…</picture> blocks
|
||||||
|
// so we never touch <img> tags inside them.
|
||||||
|
$picture_ranges = self::find_picture_ranges( $html );
|
||||||
|
|
||||||
|
// 2. Single-pass walk: rebuild output with non-img bytes copied through
|
||||||
|
// and <img> tags rewritten if they sit outside any picture range AND
|
||||||
|
// have qualifying siblings.
|
||||||
|
$out = '';
|
||||||
|
$cursor = 0;
|
||||||
|
$pattern = '#<img\\b[^>]*>#i';
|
||||||
|
if ( ! preg_match_all( $pattern, $html, $matches, PREG_OFFSET_CAPTURE ) ) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $matches[0] as $match ) {
|
||||||
|
[ $img_tag, $offset ] = $match;
|
||||||
|
$end = $offset + strlen( $img_tag );
|
||||||
|
|
||||||
|
// Copy through bytes preceding this <img>
|
||||||
|
$out .= substr( $html, $cursor, $offset - $cursor );
|
||||||
|
|
||||||
|
if ( self::is_inside_range( $offset, $picture_ranges ) ) {
|
||||||
|
// Already inside an existing <picture>; leave alone.
|
||||||
|
$out .= $img_tag;
|
||||||
|
} else {
|
||||||
|
$out .= self::maybe_wrap( $img_tag );
|
||||||
|
}
|
||||||
|
$cursor = $end;
|
||||||
|
}
|
||||||
|
$out .= substr( $html, $cursor );
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a list of [start, end_exclusive] byte ranges covering every
|
||||||
|
* <picture>…</picture> block in $html.
|
||||||
|
*
|
||||||
|
* @return array<int, array{0:int,1:int}>
|
||||||
|
*/
|
||||||
|
private static function find_picture_ranges( string $html ): array {
|
||||||
|
$ranges = [];
|
||||||
|
$offset = 0;
|
||||||
|
while ( true ) {
|
||||||
|
$open = stripos( $html, '<picture', $offset );
|
||||||
|
if ( $open === false ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$close = stripos( $html, '</picture>', $open );
|
||||||
|
if ( $close === false ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$ranges[] = [ $open, $close + strlen( '</picture>' ) ];
|
||||||
|
$offset = $close + 1;
|
||||||
|
}
|
||||||
|
return $ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{0:int,1:int}> $ranges
|
||||||
|
*/
|
||||||
|
private static function is_inside_range( int $offset, array $ranges ): bool {
|
||||||
|
foreach ( $ranges as [ $start, $end ] ) {
|
||||||
|
if ( $offset >= $start && $offset < $end ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to wrap a single <img> tag.
|
||||||
|
*/
|
||||||
|
private static function maybe_wrap( string $img_tag ): string {
|
||||||
|
// Skip if explicitly opted out
|
||||||
|
if ( strpos( $img_tag, 'data-no-h4b' ) !== false ) {
|
||||||
|
return $img_tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract src
|
||||||
|
if ( ! preg_match( '#\\bsrc=([\'"])(.+?)\\1#i', $img_tag, $sm ) ) {
|
||||||
|
return $img_tag;
|
||||||
|
}
|
||||||
|
$src = $sm[2];
|
||||||
|
|
||||||
|
// Only handle http(s) / protocol-relative / site-relative URLs
|
||||||
|
$siblings = self::resolve_siblings( $src );
|
||||||
|
if ( ! $siblings['webp'] && ! $siblings['avif'] ) {
|
||||||
|
return $img_tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build srcset for picture sources if we can. For simplicity v0.1
|
||||||
|
// emits a plain source URL (srcset handling for sizes is a v0.2 task).
|
||||||
|
$sources = '';
|
||||||
|
if ( $siblings['avif'] ) {
|
||||||
|
$sources .= sprintf(
|
||||||
|
"<source type=\"image/avif\" srcset=\"%s\">",
|
||||||
|
esc_attr( $siblings['avif'] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ( $siblings['webp'] ) {
|
||||||
|
$sources .= sprintf(
|
||||||
|
"<source type=\"image/webp\" srcset=\"%s\">",
|
||||||
|
esc_attr( $siblings['webp'] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<picture>' . $sources . $img_tag . '</picture>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an image URL, return WebP + AVIF sibling URLs if they exist on disk.
|
||||||
|
*
|
||||||
|
* @return array{webp:?string, avif:?string}
|
||||||
|
*/
|
||||||
|
private static function resolve_siblings( string $url ): array {
|
||||||
|
if ( isset( self::$sibling_cache[ $url ] ) ) {
|
||||||
|
return self::$sibling_cache[ $url ];
|
||||||
|
}
|
||||||
|
$result = [ 'webp' => null, 'avif' => null ];
|
||||||
|
|
||||||
|
// Only act on JPG / JPEG / PNG
|
||||||
|
if ( ! preg_match( '#\\.(jpe?g|png)(\\?.*)?$#i', $url ) ) {
|
||||||
|
return self::$sibling_cache[ $url ] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip query string for filesystem lookup
|
||||||
|
$url_clean = strtok( $url, '?' );
|
||||||
|
|
||||||
|
// Convert URL → absolute path on disk
|
||||||
|
$path = self::url_to_path( $url_clean );
|
||||||
|
if ( $path === null ) {
|
||||||
|
return self::$sibling_cache[ $url ] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate 1: alongside the source (what we generate)
|
||||||
|
$webp_alongside = $path . '.webp';
|
||||||
|
$avif_alongside = $path . '.avif';
|
||||||
|
|
||||||
|
// Candidate 2: Smush's smush-webp/ tree
|
||||||
|
$content_dir = trailingslashit( WP_CONTENT_DIR );
|
||||||
|
$content_url = trailingslashit( WP_CONTENT_URL );
|
||||||
|
$smush_webp_path = null;
|
||||||
|
$smush_webp_url = null;
|
||||||
|
if ( strpos( $path, $content_dir . 'uploads/' ) === 0 ) {
|
||||||
|
$rel = substr( $path, strlen( $content_dir . 'uploads/' ) );
|
||||||
|
$smush_webp_path = $content_dir . 'smush-webp/' . $rel . '.webp';
|
||||||
|
$smush_webp_url = $content_url . 'smush-webp/' . $rel . '.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( is_readable( $webp_alongside ) ) {
|
||||||
|
$result['webp'] = $url_clean . '.webp';
|
||||||
|
} elseif ( $smush_webp_path && is_readable( $smush_webp_path ) ) {
|
||||||
|
$result['webp'] = $smush_webp_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( is_readable( $avif_alongside ) ) {
|
||||||
|
$result['avif'] = $url_clean . '.avif';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$sibling_cache[ $url ] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a URL to its absolute filesystem path, or null if the URL is
|
||||||
|
* external / can't be resolved to a local file.
|
||||||
|
*/
|
||||||
|
private static function url_to_path( string $url ): ?string {
|
||||||
|
$uploads = wp_get_upload_dir();
|
||||||
|
$content_dir = trailingslashit( WP_CONTENT_DIR );
|
||||||
|
$content_url = trailingslashit( WP_CONTENT_URL );
|
||||||
|
|
||||||
|
// Strip protocol-relative
|
||||||
|
if ( strpos( $url, '//' ) === 0 ) {
|
||||||
|
$url = 'https:' . $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site root (handles http vs https mismatches between admin + frontend)
|
||||||
|
$home = home_url();
|
||||||
|
$home_alt = preg_replace( '#^https?://#', '', $home );
|
||||||
|
|
||||||
|
// Match wp-content/ specifically (covers themes + plugins + uploads)
|
||||||
|
if ( strpos( $url, $content_url ) === 0 ) {
|
||||||
|
return $content_dir . substr( $url, strlen( $content_url ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the uploads URL even if served from a CDN-prefixed URL that
|
||||||
|
// rewrites only the uploads part (we don't use a CDN but sites might)
|
||||||
|
if ( strpos( $url, $uploads['baseurl'] ) === 0 ) {
|
||||||
|
return $uploads['basedir'] . substr( $url, strlen( $uploads['baseurl'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-relative
|
||||||
|
if ( $url !== '' && $url[0] === '/' && strpos( $url, '//' ) !== 0 ) {
|
||||||
|
// /wp-content/uploads/2026/02/foo.jpg
|
||||||
|
$content_path = '/' . wp_basename( $content_dir ) . '/';
|
||||||
|
if ( strpos( $url, $content_path ) === 0 ) {
|
||||||
|
return $content_dir . substr( $url, strlen( $content_path ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ require_once H4B_IMG_OPTIM_DIR . 'includes/class-rescue-detector.php';
|
|||||||
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli.php';
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli.php';
|
||||||
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-bulk.php';
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-bulk.php';
|
||||||
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-rescue.php';
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-rescue.php';
|
||||||
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-migrate.php';
|
||||||
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-siblings.php';
|
||||||
|
require_once H4B_IMG_OPTIM_DIR . 'includes/class-picture-tag.php';
|
||||||
|
|
||||||
final class Plugin {
|
final class Plugin {
|
||||||
|
|
||||||
@@ -45,6 +48,9 @@ final class Plugin {
|
|||||||
// Upload pipeline
|
// Upload pipeline
|
||||||
Uploader_Hook::register();
|
Uploader_Hook::register();
|
||||||
|
|
||||||
|
// Front-end <picture> rewriting
|
||||||
|
Picture_Tag::register();
|
||||||
|
|
||||||
// Background AVIF queue (WP-Cron)
|
// Background AVIF queue (WP-Cron)
|
||||||
Format_Generator::register_cron();
|
Format_Generator::register_cron();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user