Files
h4b-image-optim/includes/class-cli.php
Henk 4cd1390a94 feat: migrate-from-smush + Picture-tag rewriter (v0.2.0)
Unblocks production use on sites previously running Smush.

migrate-from-smush:
  - Reads wp-smpro-smush-data postmeta, writes _h4b_img_optim marker
  - --dry-run / --force-rescan / --remove-smush-meta / --limit flags
  - Verified: 100 attachments migrated cleanly on dev.rds.ink,
    bulk count drops from 734 → 634

Picture_Tag rewriter:
  - Hooks the_content + post_thumbnail_html + widget_text + Elementor
    frontend + wp_get_attachment_image at priority 99
  - Wraps <img> in <picture><source avif><source webp><img></picture>
    when sibling files exist
  - Double-wrap protection via byte-range tracking of existing <picture> blocks
  - Per-image opt-out via data-no-h4b attribute
  - Cached sibling lookups per request
  - 8 edge-case tests pass

LOC: 2480 (was 1997). Adds class-cli-migrate.php (193 LOC) and
class-picture-tag.php (284 LOC).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:50:41 +10:00

182 lines
5.3 KiB
PHP

<?php
/**
* WP-CLI command: wp h4b-img <sub>
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class CLI {
public static function register(): void {
\WP_CLI::add_command( 'h4b-img', __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 migrate-from-smush', CLI_Migrate::class );
}
/**
* Show tool detection + plugin status.
*
* ## EXAMPLES
* wp h4b-img status
*/
public function status( $args, $assoc ): void {
$tools = Tools::detect();
\WP_CLI::log( 'h4b-image-optim v' . H4B_IMG_OPTIM_VERSION );
\WP_CLI::log( '' );
\WP_CLI::log( 'External tools:' );
foreach ( $tools as $name => $path ) {
$mark = $path ? '✓' : '✗';
\WP_CLI::log( sprintf( ' %s %-12s %s', $mark, $name, $path ?: '(not found)' ) );
}
$missing = Tools::missing_required();
if ( ! empty( $missing ) ) {
\WP_CLI::warning( 'Missing required tools: ' . implode( ', ', $missing ) );
}
\WP_CLI::log( '' );
\WP_CLI::log( 'Settings:' );
foreach ( Settings::all() as $k => $v ) {
if ( is_array( $v ) ) {
$v = '[' . implode( ',', $v ) . ']';
} elseif ( is_bool( $v ) ) {
$v = $v ? 'true' : 'false';
}
\WP_CLI::log( sprintf( ' %-28s %s', $k, $v ) );
}
\WP_CLI::log( '' );
\WP_CLI::log( 'Originals dir: ' . Optimizer::originals_root() );
\WP_CLI::log( ' exists: ' . ( is_dir( Optimizer::originals_root() ) ? 'yes' : 'no' ) );
}
/**
* Optimise a single attachment by ID.
*
* ## OPTIONS
*
* --id=<id>
* : Attachment post ID.
*
* [--dry-run]
* : Report what would happen without writing.
*
* ## EXAMPLES
* wp h4b-img optimise --id=12345
* wp h4b-img optimise --id=12345 --dry-run
*/
public function optimise( $args, $assoc ): void {
$id = (int) ( $assoc['id'] ?? 0 );
if ( $id <= 0 ) {
\WP_CLI::error( '--id required' );
}
$dry_run = ! empty( $assoc['dry-run'] );
$metadata = wp_get_attachment_metadata( $id );
if ( ! $metadata ) {
\WP_CLI::error( "No metadata for attachment $id" );
}
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
$relative = get_post_meta( $id, '_wp_attached_file', true );
if ( ! $relative ) {
\WP_CLI::error( "Attachment $id has no _wp_attached_file" );
}
$full_path = $basedir . $relative;
if ( ! is_readable( $full_path ) ) {
\WP_CLI::error( "Not readable: $full_path" );
}
\WP_CLI::log( "Attachment $id: $relative" );
\WP_CLI::log( ' full path: ' . $full_path );
$summary = [
'full' => self::run_one( $id, 'full', $full_path, $dry_run ),
];
$dir = dirname( $full_path );
foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) {
if ( empty( $size_data['file'] ) ) {
continue;
}
$path = trailingslashit( $dir ) . $size_data['file'];
if ( ! is_readable( $path ) ) {
continue;
}
$summary[ $size_key ] = self::run_one( $id, (string) $size_key, $path, $dry_run );
}
// Tabular summary
$rows = [];
foreach ( $summary as $size => $r ) {
$rows[] = [
'size' => $size,
'status' => $r['status'] ?? '',
'before' => $r['before'] ?? 0,
'after' => $r['after'] ?? 0,
'pct' => $r['percent'] ?? 0,
'icc' => ! empty( $r['icc_preserved'] ) ? '✓' : '',
'webp' => $r['webp_size'] ?? '',
'avif' => $r['avif_status'] ?? '',
'error' => $r['error'] ?? '',
];
}
\WP_CLI\Utils\format_items( 'table', $rows, [ 'size', 'status', 'before', 'after', 'pct', 'icc', 'webp', 'avif', 'error' ] );
}
private static function run_one( int $id, string $size_key, string $path, bool $dry_run ): array {
if ( $dry_run ) {
return [
'status' => 'dry-run',
'before' => filesize( $path ),
'after' => filesize( $path ),
'percent' => 0,
];
}
$opt = Optimizer::optimise( $path );
$webp_stats = [ 'status' => 'skipped' ];
$avif_stats = [ 'status' => 'skipped' ];
$generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true );
if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) {
$webp_stats = Format_Generator::make_webp( $path );
}
if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) {
$avif_stats = Format_Generator::make_avif( $path );
}
$record = [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'tool_chain' => $opt['tool_chain'] ?? [],
'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null,
'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null,
'avif_status' => $avif_stats['status'],
'backup' => $opt['backup_path'] ?? null,
'error' => $opt['error'] ?? null,
];
Attachment_Meta::record_size( $id, $size_key, $record );
return [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'webp_size' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : '',
'avif_status' => $avif_stats['status'],
'error' => $opt['error'] ?? null,
];
}
}