feat: initial ACRIB WordPress deployment

- WordPress 6.9.4 (es_ES) with Kadence theme
- Homepage: Hero, La Asociación, Pilares, Beneficios, Eventos, Miembros, Hazte Miembro, Contacto
- Brand identity: #13294b navy, #a12932 burgundy, #c69c48 gold
- Fonts: Raleway (headings) + Source Sans 3 (body) + Lato (UI)
- Plugins: Kadence Blocks, Polylang, Contact Form 7
- Custom CSS with full brand styling and responsive layout
- HTTPS enforced via wp-config.php proxy detection
This commit is contained in:
Malin
2026-05-19 19:25:59 +02:00
commit f3ff7b7186
6119 changed files with 1984255 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Admin;
use KadenceWP\KadenceBlocks\Asset\Asset;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
/**
* Dashboard / wp-admin container definitions and hooks.
*/
final class Admin_Provider extends Provider {
public const HANDLE_POST_SAVED_EVENT = 'post-saved-event';
public function register(): void {
add_action(
'admin_init',
function (): void {
$this->register_post_saved_event();
}
);
}
/**
* Register the post-saved-event action that fires when a Gutenberg post is saved.
*
* @return void
*/
private function register_post_saved_event(): void {
global $pagenow;
// Only load on post edit screens (not widgets.php or site-editor.php).
if ( ! in_array( $pagenow, [ 'post.php', 'post-new.php' ] ) ) {
return;
}
add_action(
'enqueue_block_editor_assets',
function (): void {
$asset = $this->container->get( Asset::class );
$asset->enqueue_script( self::HANDLE_POST_SAVED_EVENT, 'dist/post-saved-event' );
}
);
}
}

View File

@@ -0,0 +1,109 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Adbar\Dot;
use KadenceWP\KadenceBlocks\Admin\Admin_Provider;
use KadenceWP\KadenceBlocks\Asset\Asset_Provider;
use KadenceWP\KadenceBlocks\Cache\Cache_Provider;
use KadenceWP\KadenceBlocks\Database\Database_Provider;
use KadenceWP\KadenceBlocks\Health\Health_Provider;
use KadenceWP\KadenceBlocks\Image_Downloader\Image_Downloader_Provider;
use KadenceWP\KadenceBlocks\Log\Log_Provider;
use KadenceWP\KadenceBlocks\Optimizer\Optimizer_Provider;
use KadenceWP\KadenceBlocks\Shutdown\Shutdown_Provider;
use KadenceWP\KadenceBlocks\StellarWP\ContainerContract\ContainerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Container;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Providable;
use KadenceWP\KadenceBlocks\Harbor\Harbor_Provider;
use KadenceWP\KadenceBlocks\Uplink\Uplink_Provider;
use RuntimeException;
/**
* The Core Kadence Blocks Application, with container support.
*/
final class App {
private static self $instance;
/**
* @var Container
*/
private Container $container;
/**
* Add any custom providers here.
*
* @note The order is important.
*
* @var class-string<Providable>
*/
private $providers = [
Log_Provider::class,
Database_Provider::class,
Asset_Provider::class,
Uplink_Provider::class,
Harbor_Provider::class,
Health_Provider::class,
Admin_Provider::class,
Image_Downloader_Provider::class,
Optimizer_Provider::class,
Cache_Provider::class,
Shutdown_Provider::class,
];
/**
* @param Container $container
*/
private function __construct(
Container $container
) {
$this->container = $container;
$this->init();
}
/**
* @param Container|null $container
*
* @return self
* @throws InvalidArgumentException If no container is provided.
*/
public static function instance( ?Container $container = null ): App {
if ( ! isset( self::$instance ) ) {
if ( ! $container ) {
throw new InvalidArgumentException( 'You need to provide a concrete Contracts\Container instance!' );
}
self::$instance = new self( $container );
}
return self::$instance;
}
public function container(): Container {
return $this->container;
}
private function init(): void {
$this->container->bind( Container::class, $this->container );
$this->container->bind( ContainerInterface::class, $this->container );
$this->container->singleton( Dot::class, new Dot() );
foreach ( $this->providers as $provider ) {
$this->container->register( $provider );
}
}
private function __clone() {
}
public function __wakeup(): void {
throw new RuntimeException( 'method not implemented' );
}
public function __sleep(): array {
throw new RuntimeException( 'method not implemented' );
}
}

View File

@@ -0,0 +1,61 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Asset;
final class Asset {
/**
* @var string The URL to the plugin's main folder.
*/
private string $plugin_url;
/**
* @param string $plugin_url The URL to the plugin's main folder.
*/
public function __construct( string $plugin_url ) {
$this->plugin_url = $plugin_url;
}
/**
* Get an asset URL.
*
* @param string $path A relative path to an asset.
*
* @return string The full URL to the asset.
*/
public function get_url( string $path = '' ): string {
return $this->plugin_url . $path;
}
/**
* Get the asset meta.
*
* @param string $path The filepath without extension, e.g. dist/hello.
*
* @return array{dependencies?: string[], version?: string };
*/
public function get_meta( string $path = '' ): array {
return kadence_blocks_get_asset_file( $path );
}
/**
* Enqueue a script.
*
* @param string $name The name of the script.
* @param string $path The relative path to the script, without extension, e.g. build/settings.
* @param string $ext The file extension without a period.
*
* @return void
*/
public function enqueue_script( string $name, string $path, string $ext = 'js' ): void {
$meta = $this->get_meta( $path );
wp_enqueue_script(
$name,
$this->get_url( "$path.$ext" ),
$meta['dependencies'] ?? [],
$meta['version'] ?? '',
true
);
}
}

View File

@@ -0,0 +1,17 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Asset;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
final class Asset_Provider extends Provider {
/**
* @inheritDoc
*/
public function register(): void {
$this->container->when( Asset::class )
->needs( '$plugin_url' )
->give( static fn(): string => KADENCE_BLOCKS_URL );
}
}

View File

@@ -0,0 +1,26 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Cache;
/**
* Caches AI Related files.
*/
final class Ai_Cache extends Block_Library_Cache {
/**
* Create a hashed file name from provided identifier.
*
* @param mixed $identifier Unique data to identify this file.
*
* @return string
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
protected function filename( $identifier ): string {
return $this->hasher->hash( [
'kadence-ai-generated-content',
$identifier,
] ) . $this->ext;
}
}

View File

@@ -0,0 +1,184 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Cache;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Hasher;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\Shutdown\Contracts\Terminable;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Storage\Contracts\Storage;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Storage\Exceptions\StorageException;
/**
* Caches Block Library files.
*/
class Block_Library_Cache implements Terminable {
/**
* @var Storage
*/
protected $storage;
/**
* @var Hasher
*/
protected $hasher;
/**
* @var Config
*/
protected $config;
/**
* @var LoggerInterface
*/
protected $logger;
/**
* The filename extension for all files.
*
* @var string
*/
protected $ext;
/**
* The data to be cached on shutdown, indexed by a filename.
*
* @var array<string, mixed>
*/
protected $items;
/**
* @param Storage $storage The file storage library.
* @param Hasher $hasher The hashing library.
* @param Config $config The cache configuration.
* @param LoggerInterface $logger The logger, enabled if WP_DEBUG is set to true.
* @param string $ext The file extension all files will be saved with.
*
* @throws InvalidArgumentException
*/
public function __construct(
Storage $storage,
Hasher $hasher,
Config $config,
LoggerInterface $logger,
string $ext = '.json'
) {
$this->storage = $storage;
$this->hasher = $hasher;
$this->config = $config;
$this->logger = $logger;
$this->ext = $ext;
if ( ! str_starts_with( $ext, '.' ) || str_starts_with( $ext, '..' ) ) {
throw new InvalidArgumentException( 'The $ext must start with a single period' );
}
}
/**
* Prime data to be cached when the WordPress shutdown action occurs.
*
* @param mixed $identifier Unique data to identify this file.
* @param mixed $data The data to store.
*
* @return void
* @throws InvalidArgumentException
* @throws \RuntimeException
*/
public function cache( $identifier, $data ): void {
$identifier = $this->filename( $identifier );
$this->items[ $identifier ] = $data;
}
/**
* Write cache file on shutdown.
*
* @action shutdown
*
* @return void
*/
public function terminate(): void {
$this->write();
}
/**
* Writes the data to the file system cache on WordPress's shutdown action.
*
* @action shutdown
*
* @return void
*/
private function write(): void {
if ( ! isset( $this->items ) ) {
return;
}
foreach ( $this->items as $filename => $data ) {
if ( $this->storage->has( $filename ) ) {
$this->logger->debug( 'Filename already exists, deleting file...', [
'filename' => $filename,
] );
try {
$this->storage->delete( $filename );
} catch ( StorageException $e ) {
$this->logger->error( 'Error deleting existing cache file', [
'filename' => $filename,
'exception' => $e->getMessage(),
] );
}
}
try {
$this->logger->debug( sprintf( 'Writing cache file: %s', $filename ) );
$this->storage->put( $filename, $data );
} catch ( StorageException $e ) {
$this->logger->error( 'Error saving cache file', [
'filename' => $filename,
'exception' => $e->getMessage(),
] );
}
}
unset( $this->items );
}
/**
* Get a file from the cache.
*
* @param mixed $identifier Unique data to identify this file.
*
* @return string The file contents.
* @throws InvalidArgumentException
* @throws \KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Storage\Exceptions\NotFoundException
* @throws \RuntimeException
*/
public function get( $identifier ): string {
$identifier = $this->filename( $identifier );
$content = $this->storage->get( $identifier );
$this->logger->debug( sprintf( 'Found cache file: %s', $identifier ) );
return $content;
}
/**
* Create a hashed file name from provided identifier.
*
* @param mixed $identifier Unique data to identify this file.
*
* @return string
* @throws InvalidArgumentException
* @throws \RuntimeException
*/
protected function filename( $identifier ): string {
return $this->hasher->hash( [
$this->config->base_url(),
$this->config->base_path(),
$identifier,
] ) . $this->ext;
}
}

View File

@@ -0,0 +1,47 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Cache;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Storage\Contracts\Storage;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Storage\Drivers\LocalStorage;
use KadenceWP\KadenceBlocks\Symfony\Component\Filesystem\Filesystem;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
final class Cache_Provider extends Provider {
/**
* @inheritDoc
*/
public function register(): void {
$base_path = apply_filters( 'kadence_block_library_local_data_base_path', trailingslashit( wp_get_upload_dir()['basedir'] ) );
$base_url = apply_filters( 'kadence_block_library_local_data_base_url', content_url() );
$this->container->singleton( Config::class, new Config( $base_path, $base_url ) );
$this->register_block_library_storage();
$this->register_ai_storage();
}
private function register_block_library_storage(): void {
$library_subfolder = apply_filters( 'kadence_block_library_local_data_subfolder_name', 'kadence_blocks_library' );
$path = $this->container->get( Config::class )->base_path() . $library_subfolder;
$this->container->when( Block_Library_Cache::class )
->needs( Storage::class )
->give( new LocalStorage( $this->container->get( Filesystem::class ), $path ) );
$this->container->singleton( Block_Library_Cache::class, Block_Library_Cache::class );
}
private function register_ai_storage(): void {
$ai_subfolder = apply_filters( 'kadence_block_ai_local_data_subfolder_name', 'kadence_ai' );
$path = $this->container->get( Config::class )->base_path() . $ai_subfolder;
$this->container->when( Ai_Cache::class )
->needs( Storage::class )
->give( new LocalStorage( $this->container->get( Filesystem::class ), $path ) );
$this->container->singleton( Ai_Cache::class, Ai_Cache::class );
}
}

View File

@@ -0,0 +1,40 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Cache;
/**
* Cache configuration.
*/
final class Config {
/**
* The base server path to the uploads folder.
*
* @var string
*/
private $base_path;
/**
* The base URL to the uploads folder.
*
* @var string
*/
private $base_url;
public function __construct(
string $base_path,
string $base_url
) {
$this->base_path = $base_path;
$this->base_url = $base_url;
}
public function base_path(): string {
return $this->base_path;
}
public function base_url(): string {
return $this->base_url;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace KadenceWP\KadenceBlocks;
use KadenceWP\KadenceBlocks\lucatume\DI52\Container as DI52Container;
use KadenceWP\KadenceBlocks\StellarWP\ContainerContract\ContainerInterface;
class Container implements ContainerInterface {
/**
* @var DI52Container
*/
protected $container;
/**
* Container constructor.
*/
public function __construct() {
$this->container = new DI52Container();
}
public function container(): DI52Container {
return $this->container;
}
/**
* @inheritDoc
*/
public function bind( string $id, $implementation = null, ?array $afterBuildMethods = null ) {
$this->container->bind( $id, $implementation, $afterBuildMethods );
}
/**
* @inheritDoc
*/
public function get( string $id ) {
return $this->container->get( $id );
}
/**
* @inheritDoc
*/
public function has( string $id ) {
return $this->container->has( $id );
}
/**
* @inheritDoc
*/
public function singleton( string $id, $implementation = null, ?array $afterBuildMethods = null ) {
$this->container->singleton( $id, $implementation, $afterBuildMethods );
}
/**
* Defer all other calls to the container object.
*/
public function __call( $name, $args ) {
return $this->container->{$name}( ...$args );
}
}

View File

@@ -0,0 +1,53 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Database;
use KadenceWP\KadenceBlocks\Optimizer\Database\Optimizer_Table;
use KadenceWP\KadenceBlocks\Optimizer\Database\Viewport_Hash_Table;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\DB\Database\Exceptions\DatabaseQueryException;
use KadenceWP\KadenceBlocks\StellarWP\DB\DB;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\Schema\Config;
use KadenceWP\KadenceBlocks\StellarWP\Schema\Register;
final class Database_Provider extends Provider {
public const SCHEMA_TABLES = 'kadence_blocks.database.schema_tables';
/**
* Configure the stellarwp/schema library.
*
* @throws DatabaseQueryException If we failed to create or update the database tables.
*
* @return void
*/
public function register(): void {
Config::set_container( $this->container );
Config::set_db( DB::class );
// Add all schema tables to be registered here.
$this->container->setVar(
self::SCHEMA_TABLES,
[
Viewport_Hash_Table::class,
Optimizer_Table::class,
]
);
try {
Register::tables( $this->container->getVar( self::SCHEMA_TABLES ) );
} catch ( DatabaseQueryException $e ) {
$this->container->get( LoggerInterface::class )->emergency(
'Unable to create or update database tables',
[
'query_errors' => $e->getQueryErrors(),
'query' => $e->getQuery(),
'exception' => $e,
]
);
throw $e;
}
}
}

View File

@@ -0,0 +1,75 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Database;
use KadenceWP\KadenceBlocks\StellarWP\DB\DB;
use KadenceWP\KadenceBlocks\StellarWP\DB\QueryBuilder\QueryBuilder;
/**
* A query wrapper for the StellarWP DB instance using a specific
* table.
*
* This class should be extended and configured for a specific table
* via a Provider.
*/
abstract class Query {
/**
* The WordPress table name without a prefix.
*
* @var string
*/
protected string $table;
/**
* @param string $table The WordPress table name without a prefix.
*/
public function __construct( string $table ) {
$this->table = $table;
}
/**
* Get the current table being used.
*
* @return string
*/
public function table(): string {
return $this->table;
}
/**
* Get the table name with prefix.
*
* @return string
*/
public function table_with_prefix(): string {
global $wpdb;
return $wpdb->prefix . $this->table;
}
/**
* Get the query builder for this table.
*
* @return QueryBuilder
*/
public function qb(): QueryBuilder {
return DB::table( $this->table );
}
/**
* Wrapper for wpdb::get_var
*
* @see \wpdb::get_var()
*
* @param string|null $query Optional. SQL query. Defaults to null, use the result from the
* previous query.
* @param int $x Optional. Column of value to return. Indexed from 0. Default 0.
* @param int $y Optional. Row of value to return. Indexed from 0. Default 0.
*
* @return string|null
*/
public function get_var( ?string $query = null, int $x = 0, int $y = 0 ): ?string {
return DB::get_var( $query, $x, $y );
}
}

View File

@@ -0,0 +1,69 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Harbor\Actions;
/**
* Provides the list of Kadence plugins that integrate with Harbor.
*
* @since 3.7.0
*/
final class Get_Known_Plugins {
/**
* Returns a map of all known Kadence plugin slugs to their metadata.
*
* page_url should be a full URL and should point to the plugin's
* license settings page.
*
* @since 3.7.0
*
* @return array<string, array{name: string, page_url: string}>
*/
public function __invoke(): array {
$default_page = admin_url( 'admin.php?page=kadence-blocks' );
return [
'kadence-blocks' => [
'name' => 'Kadence Blocks',
'page_url' => $default_page,
],
'kadence-blocks-pro' => [
'name' => 'Kadence Blocks Pro',
'page_url' => $default_page,
],
'kadence-creative-kit' => [
'name' => 'Kadence Creative Kit',
'page_url' => admin_url( 'admin.php?page=kadence-blocks-home&license=show' ),
],
'kadence-insights' => [
'name' => 'Kadence Insights',
'page_url' => admin_url( 'admin.php?page=kadence-insights-settings&license=show' ),
],
'kadence-shop-kit' => [
'name' => 'Kadence Shop Kit',
'page_url' => admin_url( 'admin.php?page=kadence-shop-kit-settings&license=show' ),
],
'kadence-galleries' => [
'name' => 'Kadence Galleries',
'page_url' => admin_url('edit.php?post_type=kt_gallery&page=kadence-galleries-settings'),
],
'kadence-conversions' => [
'name' => 'Kadence Conversions',
'page_url' => admin_url('admin.php?page=kadence-conversion-settings'),
],
'kadence-captcha' => [
'name' => 'Kadence Captcha',
'page_url' => admin_url( 'admin.php?page=kadence-recaptcha-settings&license=show' ),
],
'kadence-pattern-hub' => [
'name' => 'Kadence Pattern Hub',
'page_url' => admin_url('edit.php?post_type=kadence_cloud&page=kadence-cloud-settings&license=show'),
],
'kadence-white-label' => [
'name' => 'Kadence White Label',
'page_url' => admin_url( 'admin.php?page=kadence-white-label-settings' ),
],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Harbor\Actions;
/**
* Renders the Harbor (Liquid Web Software Manager) notice on legacy Uplink license forms.
*
* @since 3.7.0
*/
final class Render_Harbor_License_Notice {
/**
* @var string
*/
private $product_name;
/**
* @param string $product_name
*/
public function __construct( string $product_name ) {
$this->product_name = $product_name;
}
/**
* Renders a Harbor notice below the save button on a Kadence Uplink license field.
*
* @since 3.7.0
*
* @return void
*/
public function __invoke(): void {
$url = lw_harbor_get_license_page_url();
if ( empty( $url ) ) {
return;
}
?>
<hr style="margin: 20px 0;"/>
<h4><span class="dashicons dashicons-info" style="vertical-align: middle; margin-right: 4px;"></span><?php esc_html_e( 'Liquid Web Software Manager', 'kadence-blocks' ); ?></h4>
<p class="tooltip description"><?php echo wp_kses(
sprintf(
/* translators: 1: product name, 2: URL to the Liquid Web Software Manager page. */
__( '%1$s is now part of Liquid Web\'s software offerings. This field is still available for managing legacy licenses from your previous %1$s account. If you purchased a new plan through Liquid Web, your products are managed through the <a href="%2$s">Liquid Web Software Manager</a>.', 'kadence-blocks' ),
esc_html( $this->product_name ),
esc_url( $url )
),
[ 'a' => [ 'href' => [] ] ]
); ?></p>
<?php
}
}

View File

@@ -0,0 +1,58 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Harbor\Actions;
use function KadenceWP\KadenceBlocks\StellarWP\Uplink\get_license_key;
use function KadenceWP\KadenceBlocks\StellarWP\Uplink\get_resource;
/**
* Reports Kadence's legacy Uplink licenses into Harbor's unified license listing.
*
* @since 3.7.0
*/
final class Report_Legacy_Licenses {
/**
* Reports legacy Uplink-managed Kadence licenses to Harbor so they appear
* in the unified license UI.
*
* @since 3.7.0
*
* @param array $licenses Existing legacy licenses from other plugins.
*
* @return array
*/
public function __invoke( array $licenses ): array {
$reported_keys = [];
foreach ( ( new Get_Known_Plugins() )() as $slug => $plugin ) {
$key = get_license_key( $slug );
if ( empty( $key ) || isset( $reported_keys[ $key ] ) ) {
continue;
}
$resource = get_resource( $slug );
if ( ! $resource ) {
continue;
}
$reported_keys[ $key ] = true;
$is_active = $resource->has_valid_license();
$licenses[] = [
'key' => $key,
'slug' => $slug,
'name' => $plugin['name'],
'product' => 'kadence',
'is_active' => $is_active,
'page_url' => esc_url( $plugin['page_url'] ),
];
}
return $licenses;
}
}

View File

@@ -0,0 +1,48 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Harbor\Actions;
/**
* Suppresses legacy Kadence add-on "license not activated" admin notices in favor of Harbor's unified UI.
*
* @since 3.7.0
*/
final class Suppress_Legacy_Inactive_Notices {
/**
* Removes legacy "not activated" admin notices from all Kadence add-ons.
*
* Each add-on registers its inactive_notice callback during plugins_loaded,
* which runs after Harbor_Provider::register(). Hooking to admin_init ensures
* all add-on callbacks are already registered before we remove them.
*
* @since 3.7.0
*
* @return void
*/
public function __invoke(): void {
global $wp_filter;
if ( empty( $wp_filter['admin_notices'] ) ) {
return;
}
foreach ( $wp_filter['admin_notices']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $key => $callback ) {
$function = $callback['function'];
if ( ! is_array( $function )
|| ! is_object( $function[0] )
|| $function[1] !== 'inactive_notice'
) {
continue;
}
if ( strpos( get_class( $function[0] ), 'KadenceWP\\' ) === 0 ) {
unset( $wp_filter['admin_notices']->callbacks[ $priority ][ $key ] );
}
}
}
}
}

View File

@@ -0,0 +1,140 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Harbor;
use KadenceWP\KadenceBlocks\Harbor\Actions\Get_Known_Plugins;
use KadenceWP\KadenceBlocks\Harbor\Actions\Render_Harbor_License_Notice;
use KadenceWP\KadenceBlocks\Harbor\Actions\Report_Legacy_Licenses;
use KadenceWP\KadenceBlocks\Harbor\Actions\Suppress_Legacy_Inactive_Notices;
use KadenceWP\KadenceBlocks\LiquidWeb\Harbor\Config as HarborConfig;
use KadenceWP\KadenceBlocks\LiquidWeb\Harbor\Harbor;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use ITSEC_Core;
/**
* Wires the Harbor (LiquidWeb unified license) integration into Kadence Blocks.
*
* @since 3.7.0
*/
final class Harbor_Provider extends Provider {
/**
* @return void
*/
public function register(): void {
HarborConfig::set_plugin_basename( KADENCE_BLOCKS_PLUGIN_BASENAME );
HarborConfig::set_container( $this->container );
add_filter( 'lw_harbor/premium_plugin_exists', [ $this, 'register_premium_plugin_exists' ] );
Harbor::init();
lw_harbor_register_submenu( 'kadence-blocks' );
add_filter( 'lw-harbor/legacy_licenses', new Report_Legacy_Licenses() );
add_filter( 'kadence_blocks_ai_disabled', [ $this, 'is_ai_disabled' ] );
add_filter( 'kadence_blocks_ai_disabled_message', [ $this, 'ai_disabled_message' ] );
foreach ( ( new Get_Known_Plugins() )() as $slug => $plugin ) {
add_action(
"stellarwp/uplink/{$slug}/license_field_after_form",
new Render_Harbor_License_Notice( $plugin['name'] )
);
add_filter( "stellarwp/uplink/{$slug}/plugin_notices", [ $this, 'suppress_inline_license_notices' ] );
}
add_action( 'admin_init', new Suppress_Legacy_Inactive_Notices() );
}
/**
* Disables Kadence AI for new Harbor customers who don't have legacy AI access.
*
* @param bool $disabled Whether AI is already disabled.
*
* @return bool
*/
public function is_ai_disabled( bool $disabled ): bool {
if ( $disabled ) {
return true;
}
return ! kadence_blocks_is_legacy_license_authorized() && lw_harbor_is_product_license_active( 'kadence' );
}
/**
* Overrides the AI disabled message for Harbor-licensed customers.
*
* @param string $message The default disabled message.
*
* @return string
*/
public function ai_disabled_message( string $message ): string {
if ( kadence_blocks_is_legacy_license_authorized() ) {
return $message;
}
if ( lw_harbor_is_product_license_active( 'kadence' ) ) {
return __( 'We\'re building something new. Kadence AI as you know it is no longer available for new activations — but great things are on the way. Stay tuned for what\'s next.', 'kadence-blocks' );
}
return $message;
}
/**
* Suppresses the StellarWP Uplink inline license notice on the WP plugins
* page for LiquidWeb customers, who manage licensing through the unified key.
*
* This was intentionally kept simple for any unified key instead of Kadence specific so that plugins can continue offloading notices to the Harbor library.
*
* @param array<string, array{slug: string, message_row_html: string}> $notices
*
* @return array<string, array{slug: string, message_row_html: string}>
*/
public function suppress_inline_license_notices( array $notices ): array {
return lw_harbor_has_unified_license_key() ? [] : $notices;
}
/**
* Get the premium plugin existence callbacks.
*
* @since 3.7.2
*
* @param bool $exists Whether a premium plugin exists.
*
* @return bool
*/
public function register_premium_plugin_exists( bool $exists ): bool {
if ( $exists ) {
// It already exists.
return true;
}
$premium_constants = [
'KTP_PLUGIN_FILE',
'KADENCE_WOO_EXTRAS_VERSION',
'SOLIDWP_BACKUPS_PLUGIN_FILE',
'KADENCE_CONVERSIONS_FILE',
'KADENCE_INSIGHTS_FILE',
'KADENCE_WHITE_VERSION',
];
foreach ( $premium_constants as $premium_constant ) {
if ( ! defined( $premium_constant ) ) {
continue;
}
return true;
}
// Kadence Security Pro does not have a constant.
if (
class_exists( ITSEC_Core::class ) &&
ITSEC_Core::get_install_type() === 'pro'
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,52 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks;
use InvalidArgumentException;
use RuntimeException;
final class Hasher {
/**
* The hashing algorithm to use.
*
* If on PHP8.1+, we'll use xxh128.
*
* @var string
*/
private $algo;
/**
* @param string $algo The hashing algorithm to use.
*
* @see \hash_algos()
*/
public function __construct( string $algo = 'md5' ) {
$this->algo = $algo;
}
/**
* Create a hash from different types of data.
*
* @param string|object|array|int|float $data The data to hash.
* @param bool $binary Output in raw binary.
*
* @return string
*
* @throws InvalidArgumentException|RuntimeException
*/
public function hash( $data, bool $binary = false ): string {
if ( $data === null ) {
throw new InvalidArgumentException( '$data cannot be null.' );
}
$data = is_scalar( $data ) ? (string) $data : (string) json_encode( $data );
if ( strlen( $data ) <= 0 ) {
throw new RuntimeException( 'Cannot hash an empty data string. Perhaps JSON encoding failed?' );
}
return hash( $this->algo, $data, $binary );
}
}

View File

@@ -0,0 +1,39 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Health;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\Uplink\Notice\Notice;
/**
* Check the Health aka the status of requirements, dependencies or anything that
* could affect the running of the plugin.
*/
final class Health_Provider extends Provider {
/**
* @inheritDoc
*/
public function register(): void {
/*
* An array indexed by PHP function names to check are enabled and the Notice
* type to render if they aren't.
*
* Adjust as needed.
*/
$this->container->when( Required_Function_Verifier::class )
->needs( '$function_map' )
->give( static function (): array {
return [
'error_log' => Notice::ERROR,
'curl_multi_exec' => Notice::WARNING,
];
} );
add_action(
'admin_notices',
$this->container->callback( Required_Function_Verifier::class, 'verify_functions' )
);
}
}

View File

@@ -0,0 +1,59 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Health;
use KadenceWP\KadenceBlocks\Notice\Notice_Handler;
use KadenceWP\KadenceBlocks\StellarWP\Uplink\Notice\Notice;
final class Required_Function_Verifier {
/**
* An array indexed by PHP function names to check are enabled and the Notice
* type to render if they aren't.
*
* @var array<string, string>
*/
private array $function_map;
private Notice_Handler $notice_handler;
/**
* @param array<string, string> $function_map
*/
public function __construct( array $function_map, Notice_Handler $notice_handler ) {
$this->function_map = $function_map;
$this->notice_handler = $notice_handler;
}
/**
* When on the Kadence Blocks settings page, show notices if any functions are disabled.
*
* @hook admin_notices
*/
public function verify_functions(): void {
$screen = get_current_screen();
if ( $screen && $screen->id !== 'toplevel_page_kadence-blocks' ) {
return;
}
foreach ( $this->function_map as $function => $type ) {
if ( function_exists( $function ) ) {
continue;
}
$this->notice_handler->add( new Notice(
$type,
// translators: %1$s is the function name, %2$s is "required" or "suggested".
sprintf(
__( 'The "%1$s" function is disabled via PHP and is %2$s by Kadence Blocks. Ask your administrator to enable it.', 'kadence-blocks' ),
$function,
$type === Notice::ERROR ? __( 'required', 'kadence-blocks' ) : __( 'suggested', 'kadence-blocks' ),
),
true
) );
}
$this->notice_handler->display();
}
}

View File

@@ -0,0 +1,256 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Home;
use function KadenceWP\KadenceBlocks\StellarWP\Uplink\build_auth_url;
use function KadenceWP\KadenceBlocks\StellarWP\Uplink\get_license_domain;
/**
* View model that produces the kadenceHomeParams.homeContent payload.
*
* All home page copy lives here so changes require no JavaScript deployment.
* The primaryCtaUrl for the authorized banner scenario is intentionally empty —
* the frontend interprets an empty URL as "open the AI wizard".
*
* @since 3.7.0
*/
final class Home_Content_View_Model {
/**
* @since 3.7.0
*/
public function exports(): array {
$is_authorized = ! kadence_blocks_is_ai_disabled() && kadence_blocks_is_legacy_license_authorized();
$is_liquid_web = ! $is_authorized && lw_harbor_is_product_license_active( 'kadence' );
return [
'bannerConfig' => $this->banner_config( $is_authorized, $is_liquid_web ),
'actionCards' => $this->action_cards( $is_authorized ),
'knowledgeBase' => $this->knowledge_base( $is_authorized ),
];
}
/**
* @since 3.7.0
*
* @param bool $is_authorized Whether the current site has an authorized Kadence license.
* @param bool $is_liquid_web Whether the current site is a Liquid Web customer.
*
* @return array{
* heading: string,
* body: string,
* primaryCtaText: string,
* primaryCtaUrl: string,
* secondaryCtaText?: string,
* secondaryCtaUrl?: string,
* }
*/
private function banner_config( bool $is_authorized, bool $is_liquid_web ): array {
// (new) customer scenario (liquid web).
if ( $is_liquid_web ) {
return [
'heading' => __( 'Kadence AI is evolving.', 'kadence-blocks' ),
'body' => __( "We're building something new. Kadence AI as you know it is no longer available for new activations — but great things are on the way. Stay tuned for what's next.", 'kadence-blocks' ),
'primaryCtaText' => __( 'Learn More', 'kadence-blocks' ),
'primaryCtaUrl' => 'https://www.kadencewp.com/kadence-blocks/',
];
}
// legacy customer scenario.
if ( $is_authorized ) {
return [
'heading' => __( 'Kadence is better with AI.', 'kadence-blocks' ),
'body' => __( "Elevate your web development game with Kadence AI. Supercharge your pattern and page library's potential with tailored content — get building pages in no time. You have AI credits remaining on your account.", 'kadence-blocks' ),
'primaryCtaText' => __( 'Get Started with Kadence AI', 'kadence-blocks' ),
'primaryCtaUrl' => '',
'secondaryCtaText' => __( 'Manage AI Credits', 'kadence-blocks' ),
'secondaryCtaUrl' => esc_url( build_auth_url( apply_filters( 'kadence-blocks-auth-slug', 'kadence-blocks' ), get_license_domain() ) ),
];
}
// unknown customer scenario.
return [
'heading' => __( 'Kadence AI is evolving.', 'kadence-blocks' ),
'body' => __("If you're using a legacy Kadence plan, activate your license to access your included features. If you're on a new plan, Kadence AI is currently being reimagined to deliver a more powerful experience. During this transition, it's only available on previous Kadence plans.", 'kadence-blocks' ),
'primaryCtaText' => __( 'Activate License Key', 'kadence-blocks' ),
'primaryCtaUrl' => esc_url( build_auth_url( apply_filters( 'kadence-blocks-auth-slug', 'kadence-blocks' ), get_license_domain() ) ),
'secondaryCtaText' => __( 'Learn More', 'kadence-blocks' ),
'secondaryCtaUrl' => 'https://www.kadencewp.com/kadence-blocks/',
];
}
/**
* @since 3.7.0
*
* @param bool $is_authorized Whether the current site has an authorized Kadence license.
*
* @return array{
* title: string,
* showAiIcon: bool,
* cards: array<int, array{heading: string, content: string, link: string, variant: string}>,
* }
*/
private function action_cards( bool $is_authorized ): array {
if ( $is_authorized ) {
return [
'title' => __( 'Start building with Kadence AI.', 'kadence-blocks' ),
'showAiIcon' => true,
'cards' => [
[
'heading' => __( 'Build a page with AI-powered patterns', 'kadence-blocks' ),
'content' => __( 'Take your site further with hundreds of beautiful patterns filled with custom content developed just for your site.', 'kadence-blocks' ),
'link' => admin_url( 'post-new.php?post_type=page' ),
'variant' => 'blue',
],
[
'heading' => __( 'Get started with full pages', 'kadence-blocks' ),
'content' => __( 'Choose from a variety of pages featuring exclusively tailored content for your site.', 'kadence-blocks' ),
'link' => admin_url( 'post-new.php?post_type=page' ),
'variant' => 'green',
],
[
'heading' => __( 'Fine-tune your content', 'kadence-blocks' ),
'content' => __( 'Write your own prompts to generate AI content from scratch or fine-tune existing copy.', 'kadence-blocks' ),
'link' => '',
'variant' => 'yellow',
],
],
];
}
return [
'title' => __( 'Start building with Kadence', 'kadence-blocks' ),
'showAiIcon' => false,
'cards' => [
[
'heading' => __( 'Build a page using pre-designed patterns', 'kadence-blocks' ),
'content' => __( "Elevate your site's design with hundreds of beautiful patterns, customized with your site's style.", 'kadence-blocks' ),
// TODO: add query param to open Design Library on Patterns tab once URL param is established.
'link' => admin_url( 'post-new.php?post_type=page' ),
'variant' => 'blue',
],
[
'heading' => __( 'Get started with full pages', 'kadence-blocks' ),
'content' => __( "Kickstart your site with a variety of pre-designed page layouts, customized with your site's style.", 'kadence-blocks' ),
// TODO: add query param to open Design Library on Pages tab once URL param is established.
'link' => admin_url( 'post-new.php?post_type=page' ),
'variant' => 'green',
],
[
'heading' => __( 'Start from scratch', 'kadence-blocks' ),
'content' => __( "Build your website from scratch, using Kadence's blocks for layout, content and interactions.", 'kadence-blocks' ),
'link' => admin_url( 'post-new.php?post_type=page' ),
'variant' => 'yellow',
],
],
];
}
/**
* @since 3.7.0
*
* @param bool $is_authorized Whether the current site has an authorized Kadence license.
*
* @return array{
* heading: string,
* articles: array<int, array{category: string, heading: string, description: string, link: string, linkTarget: string}>,
* }
*/
private function knowledge_base( bool $is_authorized ): array {
return [
'heading' => __( 'Need Help Getting Started?', 'kadence-blocks' ),
'articles' => $is_authorized ? $this->knowledge_base_authorized() : $this->knowledge_base_default(),
];
}
/**
* @since 3.7.0
*
* @return array<int, array{category: string, heading: string, description: string, link: string, linkTarget: string}>
*/
private function knowledge_base_authorized(): array {
return [
[
'category' => __( 'Kadence AI', 'kadence-blocks' ),
'heading' => __( 'Update AI Settings', 'kadence-blocks' ),
'description' => __( 'Update Kadence AI settings. Regenerate contexts for patterns and pages to reflect your updated needs.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/design-libary-changing-ai-details/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence AI', 'kadence-blocks' ),
'heading' => __( 'Customize Image Collections', 'kadence-blocks' ),
'description' => __( 'Update your Design Library imagery using premade collections or create and customize your own.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/design-library-changing-ai-image-collections/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence Blocks', 'kadence-blocks' ),
'heading' => __( 'Row Layout Block', 'kadence-blocks' ),
'description' => __( 'Use the Row Layout block to improve the column functionality and create responsive post/page layouts.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/row-layout-block-2/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence Blocks', 'kadence-blocks' ),
'heading' => __( 'Advanced Text Block', 'kadence-blocks' ),
'description' => __( 'Use the Advanced Text block to add text to your page/post with advanced customization - now with AI.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/advanced-heading-block/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Support', 'kadence-blocks' ),
'heading' => __( 'Need more help?', 'kadence-blocks' ),
'description' => __( "Didn't find what you were looking for? Find more articles in our knowledge base.", 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/',
'linkTarget' => '_blank',
],
];
}
/**
* @since 3.7.0
*
* @return array<int, array{category: string, heading: string, description: string, link: string, linkTarget: string}>
*/
private function knowledge_base_default(): array {
return [
[
'category' => __( 'Kadence Blocks', 'kadence-blocks' ),
'heading' => __( 'Using the Design Library', 'kadence-blocks' ),
'description' => __( 'Use fully designed patterns and pages on your site with your own customizer settings - now with AI.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/how-to-control-the-kadence-design-library/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence Blocks', 'kadence-blocks' ),
'heading' => __( 'Row Layout Block', 'kadence-blocks' ),
'description' => __( 'Use the Row Layout block to improve the column functionality and create responsive post/page layouts.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/row-layout-block-2/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence Blocks', 'kadence-blocks' ),
'heading' => __( 'Advanced Text Block', 'kadence-blocks' ),
'description' => __( 'Use the Advanced Text block to add text to your page/post with advanced customization - now with AI.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/advanced-heading-block/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Kadence Blocks Pro', 'kadence-blocks' ),
'heading' => __( 'Kadence Blocks Pro Plugin', 'kadence-blocks' ),
'description' => __( 'Install and activate the Kadence Blocks Pro plugin, and get an overview of the Pro features available.', 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/docs/kadence-blocks/kadence-blocks-pro-plugin/',
'linkTarget' => '_blank',
],
[
'category' => __( 'Support', 'kadence-blocks' ),
'heading' => __( 'Need more help?', 'kadence-blocks' ),
'description' => __( "Didn't find what you were looking for? Find more articles in our knowledge base.", 'kadence-blocks' ),
'link' => 'https://www.kadencewp.com/help-center/',
'linkTarget' => '_blank',
],
];
}
}

View File

@@ -0,0 +1,212 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\Hasher;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\Shutdown\Contracts\Terminable;
use KadenceWP\KadenceBlocks\Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
/**
* Prime Pexels HTTP cache for future image downloading.
*/
final class Cache_Primer implements Terminable {
/**
* @var HttpClientInterface
*/
private $client;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var Hasher
*/
private $hasher;
/**
* How long in seconds to wait until we remotely prime the collection of images again.
*
* @var int Time in seconds.
*/
private $cache_duration;
/**
* How many external cache requests to create before removing them.
*
* @var int
*/
private $batch_size;
/**
* The collections cache, to run when the class is destroyed.
*
* @var array<array{
* collection_slug: string,
* image_type: string,
* images: array<int, array{
* id: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $collections
*/
private $collections;
/**
* @param HttpClientInterface $client The HTTP client.
* @param LoggerInterface $logger The logger.
* @param Hasher $hasher The hasher.
* @param int $cache_duration The cache duration in seconds.
* @param int $batch_size How many external cache requests to create before removing them.
*/
public function __construct(
HttpClientInterface $client,
LoggerInterface $logger,
Hasher $hasher,
int $cache_duration,
int $batch_size = 500
) {
$this->client = $client;
$this->logger = $logger;
$this->hasher = $hasher;
$this->cache_duration = $cache_duration;
$this->batch_size = $batch_size;
}
/**
* Assign which collections will be primed on the WordPress shutdown hook.
*
* @param array<array{
* collection_slug: string,
* image_type: string,
* images: array<int, array{
* id: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $collections
*
* @return void
*/
public function init( array $collections ): void {
if ( empty( $collections ) ) {
return;
}
$this->collections = $collections;
}
/**
* Prime cache on shutdown.
*
* @action shutdown
*
* @return void
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function terminate(): void {
$this->execute();
}
/**
* On shutdown, make asynchronous HEAD requests to all the potential images we'll download
* to ensure that Pexels caches their response to make downloading much quicker.
*
* This allows for 0 blocking.
*
* @action shutdown
*
* @return void
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
private function execute(): void {
if ( ! isset( $this->collections ) ) {
return;
}
$batch = 0;
$cache_key = $this->hasher->hash( $this->collections );
if ( get_transient( $cache_key ) !== false ) {
$this->logger->debug( sprintf( 'Found cache key "%s", skipping image cache priming', $cache_key ) );
unset( $this->collections );
return;
}
// Search results differ slightly from industry collections, reformat.
if ( isset( $this->collections['images'] ) ) {
$this->collections = [ $this->collections ];
}
foreach ( $this->collections as $collection ) {
$this->logger->debug( sprintf( 'Priming image cache for %d images...', count( $collection['images'] ) ) );
foreach ( $collection['images'] as $image ) {
$this->logger->debug( sprintf( 'Priming image cache for: %s', $image['url'] ) );
foreach ( $image['sizes'] as $size ) {
try {
$batch++;
$url = $size['src'];
// These are async requests; We won't wait for the responses.
$promises[ $url ] = $this->client->request( 'HEAD', $url, [
'timeout' => 0.1,
'max_duration' => 0.1,
] );
} catch ( Throwable $e ) {
}
// Remove existing promises when batch size reached.
if ( $batch >= $this->batch_size ) {
$batch = 0;
try {
unset( $promises );
} catch ( Throwable $e ) {
}
}
}
}
}
// Remove any remaining promises.
try {
unset( $promises );
} catch ( Throwable $e ) {
}
// Clear collections state in case this is accessed again in the same request.
unset( $this->collections );
$this->logger->debug( sprintf( 'Caching image priming using key "%s" for %d seconds', $cache_key, $this->cache_duration ) );
set_transient( $cache_key, true, $this->cache_duration );
}
}

View File

@@ -0,0 +1,198 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Exceptions\ImageDownloadException;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\ImageDownloader;
use Throwable;
final class Image_Downloader {
/**
* @var ImageDownloader
*/
private $downloader;
/**
* @var WordPress_Importer
*/
private $importer;
/**
* @var Pexels_ID_Registry
*/
private $registry;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(
ImageDownloader $downloader,
WordPress_Importer $importer,
Pexels_ID_Registry $registry,
LoggerInterface $logger
) {
$this->downloader = $downloader;
$this->importer = $importer;
$this->registry = $registry;
$this->logger = $logger;
}
/**
* Download and import a collection of images to the WordPress library.
*
* @param array<array{
* collection_slug?: string,
* image_type?: string,
* images: array<int, array{
* id: int,
* post_id?: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $images
*
* @return array<array{id: int, url: string}>
* @throws ImageDownloadException
* @throws Throwable
*/
public function download( array $images ): array {
$existing = $this->get_existing_images( $images );
$downloaded = empty( $images['images'] ) ? [] : $this->download_images( $images );
// Merge any imported images with existing images.
return array_merge( $existing, $downloaded );
}
/**
* Based on the incoming request, determine which images have already been
* downloaded.
*
* This is weird because the frontend replaces the id and the URL, which is normally
* the pexels_id and pexels URL with the attachment_id and full URL to the image.
*
* Now local images will pass a post_id, but depending on the image state, we still
* have to check the meta for the pexels_id, because the image may exist in the media
* library.
*
* @param array<array{
* collection_slug?: string,
* image_type?: string,
* images: array<int, array{
* id: int,
* post_id?: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $images
*
* @return array<array{id: int, url: string}>
*/
private function get_existing_images( array &$images ): array {
$existing = [];
$ids = $this->registry->all();
foreach ( $images['images'] as $key => $image ) {
$post_id = $image['post_id'] ?? false;
$pexels_id = $image['id'] ?? false;
if ( $post_id !== false ) {
// We were provided a post_id, but it's not in our list of attachments.
if ( ! isset( $ids['post_ids'][ $post_id ] ) ) {
continue;
}
} elseif ( $pexels_id !== false ) {
$post_id = $ids['pexels_ids'][ $pexels_id ] ?? false;
if ( $post_id === false ) {
continue;
}
}
$existing[] = [
'id' => $post_id,
'url' => wp_get_attachment_image_url( $post_id ),
];
unset( $images['images'][ $key ] );
}
return $existing;
}
/**
* Download and import Pexels images to the WordPress library.
*
* @param array<array{
* collection_slug?: string,
* image_type?: string,
* images: array<int, array{
* id: int,
* post_id?: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $images
*
* @return array<array{id: int, url: string}>
* @throws Throwable
*/
private function download_images( array $images ): array {
if ( ! current_user_can( 'upload_files' ) ) {
$this->logger->warning( 'User doesn\'t have permission to upload files. Aborting image download' );
return [];
}
$path = wp_get_upload_dir()['path'] ?? '';
if ( ! $path ) {
return [];
}
if ( ! isset( $images['image_type'] ) ) {
$images['image_type'] = 'jpg';
}
if ( ! isset( $images['collection_slug'] ) ) {
$images['collection_slug'] = wp_generate_password( 12, false );
}
$collection[] = $images;
try {
$downloaded = $this->downloader->download( $collection, $path );
return $this->importer->import( $downloaded );
} catch ( Throwable $e ) {
$this->logger->error( 'Image download or import error', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
] );
return [];
}
}
}

View File

@@ -0,0 +1,101 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\Hasher;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\FileNameProcessor;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\ImageDownloader;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts\Sanitizer;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Sanitizers\WPFileNameSanitizer;
use KadenceWP\KadenceBlocks\Symfony\Component\HttpClient\HttpClient;
use KadenceWP\KadenceBlocks\Symfony\Component\String\Slugger\AsciiSlugger;
use KadenceWP\KadenceBlocks\Symfony\Component\String\Slugger\SluggerInterface;
use KadenceWP\KadenceBlocks\Symfony\Contracts\HttpClient\HttpClientInterface;
final class Image_Downloader_Provider extends Provider {
/**
* @inheritDoc
*/
public function register(): void {
// Create the HTTP Client used to concurrently download images.
$this->container->bind( HttpClientInterface::class, HttpClient::create() );
$this->register_hasher();
$this->register_cache_primer();
$this->register_image_downloader();
}
private function register_hasher(): void {
$this->container->when( Hasher::class )
->needs( '$algo' )
->give(
static function (): string {
return PHP_VERSION_ID >= 80100 ? 'xxh128' : 'md5';
}
);
}
private function register_cache_primer(): void {
/**
* Filter how many external cache requests we will open at once before discarding them.
*
* This may need to be adjusted depending on the host's limitations.
*
* @param int $batch_size The number of external cache requests per batch.
*/
$batch_size = absint( apply_filters( 'kadence_blocks_cache_primer_batch_size', 500 ) );
/**
* How long in seconds to wait until we remotely prime the collection of images again.
*
* @param int $cache_duration Time in seconds.
*/
$cache_duration = absint( apply_filters( 'kadence_blocks_cache_primer_cache_duration', HOUR_IN_SECONDS ) );
$this->container->when( Cache_Primer::class )
->needs( '$batch_size' )
->give( $batch_size );
$this->container->when( Cache_Primer::class )
->needs( '$cache_duration' )
->give( $cache_duration );
$this->container->singleton( Cache_Primer::class, Cache_Primer::class );
}
private function register_image_downloader(): void {
$this->container->bind( SluggerInterface::class, AsciiSlugger::class );
$this->container->bind( Sanitizer::class, WPFileNameSanitizer::class );
// Ensure we always get the same instance, so the image state is current.
$this->container->singleton( WordPress_Importer::class, WordPress_Importer::class );
// Configure the allowed file extensions that are allowed to be processed.
$this->container->when( FileNameProcessor::class )
->needs( '$allowed_extensions' )
->give(
[
'jpg' => true,
'jpeg' => true,
'webp' => true,
'png' => true,
]
);
/**
* Filter how many concurrent download requests we will open at once before we attempt to save
* the images to disk.
*
* This may need to be adjusted depending on the host's limitations.
*
* @param int $batch_size The number of download requests per batch.
*/
$batch_size = absint( apply_filters( 'kadence_blocks_image_download_batch_size', 200 ) );
$this->container->when( ImageDownloader::class )
->needs( '$batch_size' )
->give( $batch_size );
}
}

View File

@@ -0,0 +1,291 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\FileNameProcessor;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\DownloadedImage;
use KadenceWP\KadenceBlocks\Traits\Image_Size_Trait;
use RuntimeException;
use WP_Error;
use WP_Image_Editor;
/**
* Normally the WP_Image_Editor will create all the different thumbnail sizes on the server,
* however, we simply return the data of the images that have already been downloaded with
* the concurrent image downloader.
*/
final class Image_Editor extends WP_Image_Editor {
use Image_Size_Trait;
/**
* The collection of all downloaded images.
*
* @var array<int, DownloadedImage[]>
*/
private $images = [];
/**
* The current DownloadedImage being processed.
*
* @var DownloadedImage
*/
private $image;
/**
* The current Pexels ID being processed.
*
* @var int
*/
private $id;
/**
* @var LoggerInterface
*/
private $logger;
/**
* Tests if this Image Editor is supported.
*
* @param array $args
*
* @return bool
*/
public static function test( $args = [] ): bool {
return true;
}
/**
* We support all mime types because this editor is only loaded
* when the Image Downloader is executed.
*
* @param string $mime_type
*
* @return true
*/
public static function supports_mime_type( $mime_type ): bool {
return true;
}
/**
* This is always called first, so basically an init method.
*
* @inheritDoc
*/
public function load() {
if ( $this->file === null ) {
return new WP_Error( 'error_loading_image', __( 'File cannot be null.', 'kadence-blocks' ) );
}
$this->logger = kadence_blocks()->get( LoggerInterface::class );
// Fetch the currently downloaded images from the Importer.
$this->images = kadence_blocks()->get( WordPress_Importer::class )->images();
// Find the image WordPress is currently processing by matching the file name.
foreach ( $this->images as $id => $images ) {
// Grab the scaled image, or fallback to the largest size.
$scaled_key = array_search( FileNameProcessor::SCALED_SIZE, array_column( $images, 'size' ), true );
if ( $scaled_key !== false ) {
$scaled = $images[ $scaled_key ] ?? end( $images );
} else {
$scaled = end( $images );
}
if ( $this->file !== $scaled->file ) {
continue;
}
$this->image = $scaled;
$this->id = $id;
break;
}
if ( $this->image === null ) {
$this->logger->error( 'Cannot find downloaded file', [
'file' => $this->file,
] );
return new WP_Error( 'error_loading_image', __( 'Cannot find downloaded file.', 'kadence-blocks' ), $this->file );
}
return true;
}
/**
* @inheritDoc
*
* @return array{path: string, file: string, width: int, height: int, mime-type: string, filesize: int}
*/
public function save( $destfilename = null, $mime_type = null ): array {
$image_size = wp_getimagesize( $this->image->file );
return [
'path' => $this->image->file,
'file' => wp_basename( $this->image->file ),
'width' => $image_size[0],
'height' => $image_size[1],
'mime-type' => $image_size['mime'],
'filesize' => wp_filesize( $this->image->file ),
];
}
/**
* Our images are already resized, just return true.
*
* @inheritDoc
*/
public function resize( $max_w, $max_h, $crop = false ) {
return true;
}
/**
* Create multiple smaller images from a single source.
*
* Attempts to create all sub-sizes and returns the meta data at the end.
*
* As of 5.3.0 the preferred way to do this is with `make_subsize()`. It creates
* the new images one at a time and allows for the meta data to be saved after
* each new image is created.
*
* @param array<string, array{width?: int, height?: int, crop?: bool}> $sizes
*
* @return array<string,array{path: string, file: string, width: int, height: int, mime-type: string, filesize: int}> An array of resized images' metadata by size.
*
* @throws \InvalidArgumentException
*/
public function multi_resize( $sizes ) {
$this->logger->debug( 'Using old multi_resize method' );
$metadata = [];
foreach ( $sizes as $size => $size_data ) {
$meta = $this->make_subsize( $size_data );
if ( ! is_wp_error( $meta ) ) {
$metadata[ $size ] = $meta;
continue;
}
$this->logger->error( 'Unable to make image subsize', [
'file' => $this->image->file,
'errors' => $meta->get_error_messages(),
] );
}
return $metadata;
}
/**
* Our images are already cropped, just return true.
*
* @inheritDoc
*/
public function crop( $src_x, $src_y, $src_w, $src_h, $dst_w = null, $dst_h = null, $src_abs = false ) {
return true;
}
/**
* @inheritDoc
*/
public function rotate( $angle ) {
throw new RuntimeException( 'method not implemented' );
}
/**
* @inheritDoc
*/
public function flip( $horz, $vert ) {
throw new RuntimeException( 'method not implemented' );
}
/**
* @inheritDoc
*/
public function stream( $mime_type = null ) {
throw new RuntimeException( 'method not implemented' );
}
/**
* Find our already made sub-sized images in our image collection.
*
* @param array{width?: int, height?: int, crop?: bool} $size_data
*
* @return WP_Error|array{path: string, file: string, width: int, height: int, mime-type: string, filesize: int}
* @throws \InvalidArgumentException
*/
public function make_subsize( $size_data ) {
if ( ! isset( $size_data['width'] ) && ! isset( $size_data['height'] ) ) {
$this->logger->error( 'Cannot resize the image. Both width and height are not set.', [
'file' => $this->image->file,
] );
return new WP_Error( 'image_subsize_create_error', __( 'Cannot resize the image. Both width and height are not set.' ) );
}
if ( ! isset( $size_data['width'] ) ) {
$size_data['width'] = null;
}
if ( ! isset( $size_data['height'] ) ) {
$size_data['height'] = null;
}
if ( ! isset( $size_data['crop'] ) ) {
$size_data['crop'] = false;
}
$existing_sizes = $this->get_image_sizes();
$thumbnail_id = '';
// Find the thumbnail name based on the requested dimensions.
foreach ( $existing_sizes as $existing_size ) {
if ( $existing_size['width'] === $size_data['width'] && $existing_size['height'] === $size_data['height'] ) {
$thumbnail_id = $existing_size['id'];
break;
}
}
if ( strlen( $thumbnail_id ) === 0 ) {
$this->logger->error( 'Could not find thumbnail size', [
'file' => $this->image->file,
'requested_width' => $size_data['width'],
'requested_height' => $size_data['height'],
] );
return new WP_Error( 'image_subsize_create_error', __( 'Cannot resize the image.', 'kadence-blocks' ) );
}
// Find the matching file for the requested thumbnail size and get its metadata.
foreach ( $this->images[ $this->id ] as $key => $image ) {
if ( $image->size !== $thumbnail_id ) {
continue;
}
$original = $this->image;
$this->image = $image;
$saved = $this->save();
$this->image = $original;
unset( $this->images[ $this->id ][ $key ] );
return $saved;
}
$this->logger->error( 'Cannot match image to size data', [
'file' => $this->image->file,
'file_max_width' => $this->image->width,
'file_max_height' => $this->image->height,
'requested_width' => $size_data['width'],
'requested_height' => $size_data['height'],
] );
return new WP_Error( 'image_subsize_create_error', __( 'Cannot resize the image.', 'kadence-blocks' ) );
}
}

View File

@@ -0,0 +1,61 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\DownloadedImage;
/**
* Manages Kadence/Pexels image meta.
*/
final class Meta {
public const ATTACHMENT_ALT = '_wp_attachment_image_alt';
public const PEXELS_PHOTOGRAPHER = '_pexels_photographer';
public const PEXELS_PHOTOGRAPHER_URL = '_pexels_photographer_url';
public const PEXELS_ID = '_pexels_id';
public const DELETABLE = [
self::PEXELS_PHOTOGRAPHER,
self::PEXELS_PHOTOGRAPHER_URL,
self::PEXELS_ID,
];
/**
* Insert additional image metadata when downloaded images are added.
*
* @param int $attachment_id
* @param DownloadedImage $image
*
* @return void
*/
public function add( int $attachment_id, DownloadedImage $image ): void {
$meta = [
self::ATTACHMENT_ALT => $image->alt,
self::PEXELS_PHOTOGRAPHER => $image->photographer,
self::PEXELS_PHOTOGRAPHER_URL => $image->photographer_url,
self::PEXELS_ID => $image->id,
];
foreach ( $meta as $meta_key => $value ) {
if ( strlen( (string) $value ) <= 0 ) {
continue;
}
update_post_meta( $attachment_id, $meta_key, wp_slash( sanitize_text_field( $value ) ) );
}
}
/**
* Delete image metadata.
*
* @param int $attachment_id
*
* @return void
*/
public function delete( int $attachment_id ): void {
foreach ( self::DELETABLE as $meta_key ) {
delete_post_meta( $attachment_id, $meta_key );
}
}
}

View File

@@ -0,0 +1,45 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
final class Pexels_ID_Registry {
/**
* A reverse map of keyed post_id => pexels_id and pexels_id => post_id.
*
* @note A bit worried about memory issues here, but this is a more efficient
* query than making thousands of meta queries.
*
* @var array{post_ids: array<int, int>, pexels_ids: array<int, int>}
*/
private $ids = [];
/**
* Returns a reverse map of keyed post_id => pexels_id and pexels_id => post_id.
*
* @return array{post_ids: array<int, int>, pexels_ids: array<int, int>}
*/
public function all(): array {
global $wpdb;
if ( ! empty( array_filter( $this->ids ) ) ) {
return $this->ids;
}
/** @var array<int, array{post_id: string, pexels_id: string}> $hashes */
$ids = $wpdb->get_results( $wpdb->prepare( "
SELECT `post_id`, `meta_value` as pexels_id
FROM $wpdb->postmeta
WHERE `meta_key` = %s
",
Meta::PEXELS_ID
), ARRAY_A );
foreach ( $ids as $id ) {
$this->ids['post_ids'][ (int) $id['post_id'] ] = (int) $id['pexels_id'];
$this->ids['pexels_ids'][ (int) $id['pexels_id'] ] = (int) $id['post_id'];
}
return $this->ids;
}
}

View File

@@ -0,0 +1,138 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Image_Downloader;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\FileNameProcessor;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\DownloadedImage;
final class WordPress_Importer {
/**
* The image data, indexed by Pexels ID.
*
* @var array<int, DownloadedImage[]>
*/
private $images = [];
/**
* Manages image metadata.
*
* @var Meta
*/
private $meta;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @param Meta $meta
* @param LoggerInterface $logger
*/
public function __construct( Meta $meta, LoggerInterface $logger ) {
$this->meta = $meta;
$this->logger = $logger;
}
/**
* @return array<int, DownloadedImage[]>
*/
public function images(): array {
return $this->images;
}
/**
* Import already downloaded images into the WordPress media library.
*
* @param array<int, array<string, DownloadedImage>> $collections
*
* @return array<array{id: int, url: string}>
*/
public function import( array $collections ): array {
$upload = wp_get_upload_dir();
$stored = [];
if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
include( ABSPATH . 'wp-admin/includes/image.php' );
}
// Combine thumbnail images under their Pexels ID.
foreach ( $collections as $images ) {
foreach ( $images as $image ) {
$this->images[ $image->id ][] = $image;
}
}
// Override the WP image editor with our custom null editor.
$existing_editors = [];
add_filter( 'wp_image_editors', static function ( $editors ) use ( &$existing_editors ) {
$existing_editors = $editors;
return [ '\\KadenceWP\\KadenceBlocks\\Image_Downloader\\Image_Editor' ];
}, 8, 1 );
foreach ( $this->images as $id => $images ) {
// Grab the scaled image, or fallback to the largest size.
$scaled_key = array_search( FileNameProcessor::SCALED_SIZE, array_column( $images, 'size' ), true );
if ( $scaled_key !== false ) {
$scaled = $this->images[ $id ][ $scaled_key ] ?? end( $this->images[ $id ] );
} else {
$scaled = end( $this->images[ $id ] );
}
$info = wp_check_filetype( $scaled->file );
// Translators: %s is the photographer's name.
$title = sprintf( __( 'Photo by %s', 'kadence-blocks' ), $scaled->photographer );
$filename = $this->get_file_name( $scaled );
$uploaded_url = $upload['url'] . "/$filename";
$attachment = [
'guid' => $uploaded_url,
'post_mime_type' => $info['type'],
'post_title' => $title,
'post_content' => '',
];
$attachment_id = wp_insert_attachment( $attachment, $scaled->file );
if ( $attachment_id <= 0 ) {
$this->logger->error( 'Failed to insert attachment', [
'file' => $scaled->file
] );
continue;
}
wp_generate_attachment_metadata( $attachment_id, $scaled->file );
$this->meta->add( $attachment_id, $scaled );
$stored[] = [
'id' => $attachment_id,
'url' => $uploaded_url,
];
}
// Reset the original WP image editors.
add_filter( 'wp_image_editors', static function () use ( $existing_editors ) {
return $existing_editors;
}, 9, 0 );
return $stored;
}
/**
* Get the file name and extension from a server path.
*
* @param DownloadedImage $image
*
* @return string
*/
private function get_file_name( DownloadedImage $image ): string {
return pathinfo( $image->file, PATHINFO_BASENAME );
}
}

View File

@@ -0,0 +1,74 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Log;
use KadenceWP\KadenceBlocks\Monolog\Handler\AbstractHandler;
use KadenceWP\KadenceBlocks\Monolog\Handler\ErrorLogHandler;
use KadenceWP\KadenceBlocks\Monolog\Handler\NullHandler;
use KadenceWP\KadenceBlocks\Monolog\Logger;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Log\Formatters\ColoredLineFormatter;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Log\LogLevel;
final class Log_Provider extends Provider {
/**
* @inheritDoc
*/
public function register(): void {
// Enable logging to the error log if WP_DEBUG is enabled and error_log is not listed in the php.ini/fpm disable_functions directive.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
/**
* Filter the log level to use when debugging.
*
* @param string $log_level One of: debug, info, notice, warning, error, critical, alert, emergency
*/
$log_level = apply_filters( 'kadence_blocks_image_download_log_level', 'debug' );
$this->container->when( ColoredLineFormatter::class )
->needs( '$dateFormat' )
->give( 'd/M/Y:H:i:s O' );
$this->container->when( AbstractHandler::class )
->needs( '$level' )
->give( LogLevel::fromName( $log_level ) );
$this->container->when( ErrorLogHandler::class )
->needs( '$level' )
->give( LogLevel::fromName( $log_level ) );
$this->container->bind(
LoggerInterface::class,
function () {
$logger = new Logger( 'kadence' );
$handler = $this->container->get( ErrorLogHandler::class );
$handler->setFormatter( $this->container->get( ColoredLineFormatter::class ) );
$logger->pushHandler( $handler );
// Prefix logs.
$logger->pushProcessor(
static function ( array $record ): array {
$record['message'] = '[Kadence Blocks]: ' . $record['message'];
return $record;
}
);
return $logger;
}
);
} else {
// Disable logging.
$this->container->bind(
LoggerInterface::class,
static function () {
$logger = new Logger( 'null' );
$logger->pushHandler( new NullHandler() );
return $logger;
}
);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Notice;
use KadenceWP\KadenceBlocks\StellarWP\Uplink\Notice\Notice;
use KadenceWP\KadenceBlocks\StellarWP\Uplink\Notice\Notice_Controller;
/**
* Handles rendering multiple admin notices at once.
*/
final class Notice_Handler {
/**
* Controller responsible for rendering notices.
*/
private Notice_Controller $controller;
/**
* @var Notice[]
*/
private array $notices = [];
public function __construct( Notice_Controller $controller ) {
$this->controller = $controller;
}
/**
* Add a notice to the stack.
*
* @param Notice $notice
*
* @return self
*/
public function add( Notice $notice ): self {
$this->notices[] = $notice;
return $this;
}
/**
* Display all notices in the stack.
*/
public function display(): void {
if ( count( $this->notices ) <= 0 ) {
return;
}
foreach ( $this->notices as $notice ) {
$this->controller->render( $notice->toArray() );
}
}
/**
* Get all the notices.
*
* @return Notice[]
*/
public function all(): array {
return $this->notices;
}
/**
* Clear all notices in the stack.
*/
public function clear(): self {
$this->notices = [];
return $this;
}
}

View File

@@ -0,0 +1,145 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path_Factory;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
/**
* A registry to fetch custom data from the WebsiteAnalysis DTO.
*
* @phpstan-type HtmlClassHeightMap array<string, float> Class attribute => height in pixels.
*/
class Analysis_Registry {
private const SECTIONS = 'sections';
/**
* Memoization cache.
*
* @var array<string, mixed>
*/
private array $cache = [];
private ?Path $path = null;
private ?WebsiteAnalysis $analysis = null;
private Store $store;
private bool $is_mobile;
public function __construct(
Store $store,
Path_Factory $path_factory,
bool $is_mobile
) {
$this->store = $store;
$this->is_mobile = $is_mobile;
try {
$this->path = $path_factory->make();
} catch ( InvalidArgumentException $e ) {
$this->path = null;
}
}
/**
* Flush the memoization cache.
*
* @return void
*/
public function flush(): void {
$this->analysis = null;
$this->cache = [];
}
/**
* Build the WebsiteAnalysis DTO based on the current path.
*
* @return WebsiteAnalysis|null
*/
public function get_analysis(): ?WebsiteAnalysis {
if ( $this->analysis !== null ) {
return $this->analysis;
}
if ( ! $this->path ) {
return null;
}
$this->analysis = $this->store->get( $this->path );
return $this->analysis;
}
/**
* Check if this request is optimized.
*
* @return bool
*/
public function is_optimized(): bool {
return $this->get_analysis() instanceof WebsiteAnalysis;
}
/**
* Get the above the fold background images for the current viewport.
*
* @return string[]
*/
public function get_background_images(): array {
$analysis = $this->get_analysis();
return $analysis
? ( $this->is_mobile ? $analysis->mobile->backgroundImages : $analysis->desktop->backgroundImages )
: [];
}
/**
* Get the list of the above the fold image URLs.
*
* @return string[]
*/
public function get_critical_images(): array {
$analysis = $this->get_analysis();
return $analysis
? ( $this->is_mobile ? $analysis->mobile->criticalImages : $analysis->desktop->criticalImages )
: [];
}
/**
* For the current viewport, get all the "below the fold" sections that have a height
* greater than 0.
*
* @return HtmlClassHeightMap
*/
public function get_valid_sections(): array {
if ( isset( $this->cache[ self::SECTIONS ] ) ) {
return $this->cache[ self::SECTIONS ];
}
$analysis = $this->get_analysis();
if ( ! $analysis ) {
$this->cache[ self::SECTIONS ] = [];
return [];
}
$sections = $this->is_mobile
? $analysis->mobile->getBelowTheFoldSections()
: $analysis->desktop->getBelowTheFoldSections();
$results = [];
foreach ( $sections as $section ) {
if ( $section->height > 0 ) {
$results[ $section->className ] = $section->height;
}
}
$this->cache[ self::SECTIONS ] = $results;
return $this->cache[ self::SECTIONS ];
}
}

View File

@@ -0,0 +1,25 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Asset;
use KadenceWP\KadenceBlocks\Optimizer\Asset_Loader;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->container->singleton( Asset_Loader::class, Asset_Loader::class );
add_action(
'enqueue_block_editor_assets',
$this->container->callback( Asset_Loader::class, 'enqueue_block_editor_scripts' ),
20,
0
);
add_action(
'admin_enqueue_scripts',
$this->container->callback( Asset_Loader::class, 'enqueue_post_list_table' )
);
}
}

View File

@@ -0,0 +1,97 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer;
use KadenceWP\KadenceBlocks\Asset\Asset;
use KadenceWP\KadenceBlocks\Optimizer\Nonce\Nonce;
use KadenceWP\KadenceBlocks\Optimizer\Translation\Text_Repository;
final class Asset_Loader {
public const OPTIMIZER_SCRIPT_HANDLE = 'kadence-blocks-admin-optimizer';
public const POST_LIST_STYLE_HANDLE = 'kadence-blocks-post-list-table';
private Asset $asset;
private Text_Repository $text_repository;
private Nonce $nonce;
/**
* @param Asset $asset
* @param Text_Repository $text_repository
* @param Nonce $nonce
*/
public function __construct(
Asset $asset,
Text_Repository $text_repository,
Nonce $nonce
) {
$this->asset = $asset;
$this->text_repository = $text_repository;
$this->nonce = $nonce;
}
/**
* Enqueue Optimizer scripts when editing a post in Gutenberg.
*
* @action enqueue_block_editor_assets
*
* @return void
*/
public function enqueue_block_editor_scripts(): void {
$this->enqueue_optimizer_js();
}
/**
* Enqueue Optimizer scripts and the post list table column styles.
*
* @action admin_enqueue_scripts
*
* @param string $hook The current admin screen.
*
* @return void
*/
public function enqueue_post_list_table( string $hook ): void {
if ( 'edit.php' !== $hook ) {
return;
}
wp_register_style(
self::POST_LIST_STYLE_HANDLE,
$this->asset->get_url( 'includes/assets/css/post-list-table.min.css' ),
[],
KADENCE_BLOCKS_VERSION
);
wp_enqueue_style( self::POST_LIST_STYLE_HANDLE );
$this->enqueue_optimizer_js();
}
/**
* Enqueue the Optimizer scripts.
*
* @action admin_enqueue_scripts
* @action enqueue_block_editor_assets
*
* @return void
*/
private function enqueue_optimizer_js(): void {
$this->asset->enqueue_script( self::OPTIMIZER_SCRIPT_HANDLE, 'dist/kadence-optimizer' );
// Add post list table optimizer status translations.
wp_localize_script(
self::OPTIMIZER_SCRIPT_HANDLE,
'kbOptimizerL10n',
$this->text_repository->all()
);
// TODO: probably move this into a new script.
wp_localize_script(
self::OPTIMIZER_SCRIPT_HANDLE,
'kbOptimizer',
[
'token' => $this->nonce->create(),
]
);
}
}

View File

@@ -0,0 +1,15 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Database;
use KadenceWP\KadenceBlocks\Database\Query;
use KadenceWP\KadenceBlocks\Optimizer\Optimizer_Provider;
/**
* A query builder wrapper for the Optimizer table.
*
* @see Optimizer_Provider::register_optimizer_query()
*/
final class Optimizer_Query extends Query {
}

View File

@@ -0,0 +1,71 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Database;
use KadenceWP\KadenceBlocks\StellarWP\DB\Database\Exceptions\DatabaseQueryException;
use KadenceWP\KadenceBlocks\StellarWP\Schema\Tables\Contracts\Table;
/**
* The optimizer database table to store optimizer data.
*/
final class Optimizer_Table extends Table {
public const SCHEMA_VERSION = '2.0.5';
/**
* @var string The base table name.
*/
protected static $base_table_name = 'kb_optimizer';
/**
* @var string The organizational group this table belongs to.
*/
protected static $group = 'kb';
/**
* @var string|null The slug used to identify the custom table.
*/
protected static $schema_slug = 'optimizer';
/**
* @var string The field that uniquely identifies a row in the table.
*/
protected static $uid_column = 'path_hash';
/**
* Overload the update method to first drop the database as this is a temporary table.
*
* @throws DatabaseQueryException If any of the queries fail.
*
* @return string[]
*/
public function update() {
try {
if ( $this->exists() ) {
$this->drop();
}
} catch ( \Throwable $e ) {
function_exists( 'error_log' ) && error_log( '[Kadence Blocks]: Unable to drop optimizer table during schema update: ' . $e->getMessage() );
return [];
}
return parent::update();
}
/**
* @inheritDoc
*/
protected function get_definition(): string {
global $wpdb;
$table_name = self::table_name();
$charset_collate = $wpdb->get_charset_collate();
return "
CREATE TABLE `$table_name` (
path_hash CHAR(64) PRIMARY KEY COMMENT 'SHA-256 hash of the relative path of the URL used as a unique key for fast lookups',
path TEXT NOT NULL COMMENT 'The relative path of the URL, stored for reference and debugging',
analysis LONGTEXT NOT NULL COMMENT 'Serialized or JSON-encoded analysis data associated with the path'
) {$charset_collate};
";
}
}

View File

@@ -0,0 +1,29 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Database;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->register_optimizer_query();
$this->register_viewport_query();
}
private function register_optimizer_query(): void {
$this->container->singleton( Optimizer_Query::class, Optimizer_Query::class );
$this->container->when( Optimizer_Query::class )
->needs( '$table' )
->give( static fn(): string => Optimizer_Table::table_name( false ) );
}
private function register_viewport_query(): void {
$this->container->singleton( Viewport_Query::class, Viewport_Query::class );
$this->container->when( Viewport_Query::class )
->needs( '$table' )
->give( static fn(): string => Viewport_Hash_Table::table_name( false ) );
}
}

View File

@@ -0,0 +1,76 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Database;
use KadenceWP\KadenceBlocks\Optimizer\Enums\Viewport;
use KadenceWP\KadenceBlocks\StellarWP\DB\Database\Exceptions\DatabaseQueryException;
use KadenceWP\KadenceBlocks\StellarWP\Schema\Tables\Contracts\Table;
/**
* Database table to store viewport-specific HTML hashes for each URL path.
*/
final class Viewport_Hash_Table extends Table {
public const SCHEMA_VERSION = '1.0.5';
/**
* @var string The base table name.
*/
protected static $base_table_name = 'kb_optimizer_viewport_hashes';
/**
* @var string The organizational group this table belongs to.
*/
protected static $group = 'kb';
/**
* @var string|null The slug used to identify the custom table.
*/
protected static $schema_slug = 'viewport_hashes';
/**
* @var string[] The fields that uniquely identify a row in the table (composite key).
*/
protected static $uid_column = 'path_hash';
/**
* Overload the update method to drop the database first (optional, like your Optimizer_Table).
*
* @throws DatabaseQueryException If any of the queries fail.
*
* @return string[]
*/
public function update() {
try {
if ( $this->exists() ) {
$this->drop();
}
} catch ( \Throwable $e ) {
function_exists( 'error_log' ) && error_log( '[Kadence Blocks]: Unable to drop viewport_hash table during schema update: ' . $e->getMessage() );
return [];
}
return parent::update();
}
/**
* @inheritDoc
*/
protected function get_definition(): string {
global $wpdb;
$table_name = self::table_name();
$charset_collate = $wpdb->get_charset_collate();
// Build a 'desktop', 'mobile' ENUM input.
$enum_values = sprintf( "'%s', '%s'", Viewport::desktop()->value(), Viewport::mobile()->value() );
return "
CREATE TABLE `$table_name` (
path_hash CHAR(64) NOT NULL COMMENT 'SHA-256 hash of the path (via \$wp->request), identifies the URL',
viewport ENUM($enum_values) NOT NULL COMMENT 'The viewport/device identifier',
html_hash VARCHAR(191) NOT NULL COMMENT 'A composite of hashes separated by | in a key\:MD5 value of the HTML markup for this viewport',
PRIMARY KEY (path_hash, viewport)
) {$charset_collate};
";
}
}

View File

@@ -0,0 +1,15 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Database;
use KadenceWP\KadenceBlocks\Database\Query;
use KadenceWP\KadenceBlocks\Optimizer\Optimizer_Provider;
/**
* A query builder wrapper for the Viewport_Hash table.
*
* @see Optimizer_Provider::register_viewport_query()
*/
final class Viewport_Query extends Query {
}

View File

@@ -0,0 +1,47 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Enums;
/**
* A pseudo Viewport enum.
*/
final class Viewport {
public const DESKTOP = 'desktop';
public const MOBILE = 'mobile';
/**
* The view port.
*
* @var string
*/
private string $value;
private function __construct( string $value ) {
$this->value = $value;
}
public static function desktop(): self {
return new self( self::DESKTOP );
}
public static function mobile(): self {
return new self( self::MOBILE );
}
public static function current( bool $is_mobile ): self {
return $is_mobile ? self::mobile() : self::desktop();
}
public function value(): string {
return $this->value;
}
public function equals( self $other ): bool {
return $this->value === $other->value;
}
public function __toString(): string {
return $this->value;
}
}

View File

@@ -0,0 +1,20 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Hash;
final class Background_Processor {
/**
* Try to return the request early so this can be processed in
* a background thread.
*
* @return void
*/
public function try_finish(): void {
if ( function_exists( 'fastcgi_finish_request' ) ) {
fastcgi_finish_request();
} elseif ( function_exists( 'litespeed_finish_request' ) ) {
litespeed_finish_request();
}
}
}

View File

@@ -0,0 +1,151 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Hash;
/**
* Builds a hash based off specific parts of an HTML document
* that could affect which optimizations are in place.
*
* This is a good compromise for URLs that load random data, so we can
* keep as many optimizations as possible, even if they may not apply
* to some elements.
*/
final class Hash_Builder {
/**
* Build a composite hash string with individual component hashes.
*
* @param string $html The full HTML to be returned to the browser.
*
* @return string The composite hash string.
*/
public function build_hash( string $html ): string {
$components = [
'stylesheet' => hash( 'md5', $this->extract_stylesheet_links( $html ) ),
'inline' => hash( 'md5', $this->extract_inline_styles( $html ) ),
'blocks' => hash( 'md5', $this->extract_block_structure( $html ) ),
'structure' => hash( 'md5', $this->extract_structural_elements( $html ) ),
];
return implode(
'|',
array_map(
static fn( string $key, string $hash ): string => "$key:$hash",
array_keys( $components ),
$components
)
);
}
/**
* Compare two composite hash strings and return which components changed.
*
* @param string $old_hash The previous composite hash.
* @param string $new_hash The new composite hash.
*
* @return string[] List of component names that changed.
*/
public function get_changed_components( string $old_hash, string $new_hash ): array {
if ( $old_hash === $new_hash ) {
return [];
}
$old_parts = $this->parse_composite_hash( $old_hash );
$new_parts = $this->parse_composite_hash( $new_hash );
$changed = [];
foreach ( $new_parts as $key => $new_value ) {
if ( ! isset( $old_parts[ $key ] ) || $old_parts[ $key ] !== $new_value ) {
$changed[] = $key;
}
}
return $changed;
}
/**
* Parse a composite hash string into components.
*
* @param string $composite_hash The composite hash string.
*
* @return array Associative array of component => hash.
*/
private function parse_composite_hash( string $composite_hash ): array {
$parts = explode( '|', $composite_hash );
$result = [];
foreach ( $parts as $part ) {
$split = explode( ':', $part, 2 );
if ( count( $split ) === 2 ) {
$result[ $split[0] ] = $split[1];
}
}
return $result;
}
/**
* Extract stylesheet and preload link tags.
*
* @param string $html The HTML content.
*
* @return string Concatenated link tags.
*/
private function extract_stylesheet_links( string $html ): string {
preg_match_all( '~<link\b[^>]*\brel=["\'](?:stylesheet|preload)["\'][^>]*>~i', $html, $matches );
return implode( '', $matches[0] );
}
/**
* Extract inline style tags.
*
* @param string $html The HTML content.
*
* @return string Concatenated style tags.
*/
private function extract_inline_styles( string $html ): string {
preg_match_all( '~<style\b[^>]*>.*?</style>~is', $html, $matches );
return implode( '', $matches[0] );
}
/**
* Extract block structures.
*
* @param string $html The HTML content.
*
* @return string Pipe-separated block comments.
*/
private function extract_block_structure( string $html ): string {
$matches = [];
preg_match_all( '~<!-- wp:[^>]*-->~i', $html, $matches );
return implode( '|', $matches[0] ?? [] );
}
/**
* Extract structural element counts.
*
* @param string $html The HTML content.
*
* @return string Pipe-separated element counts.
*/
private function extract_structural_elements( string $html ): string {
$matches = [];
preg_match_all( '~<(div|section|article|header|footer|main)[^>]*>~i', $html, $matches );
$counts = array_count_values( array_map( 'strtolower', $matches[1] ?? [] ) );
return implode(
'|',
array_map(
static fn( string $element ): string => "$element:" . ( $counts[ $element ] ?? 0 ),
[ 'div', 'section', 'article', 'header', 'footer', 'main' ]
)
);
}
}

View File

@@ -0,0 +1,258 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Hash;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Optimizer\Enums\Viewport;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path_Factory;
use KadenceWP\KadenceBlocks\Optimizer\Request\Request;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rule_Collection;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
use KadenceWP\KadenceBlocks\Traits\Viewport_Trait;
use Throwable;
/**
* Handles outputbuffer hashing during the WordPress shutdown phase to detect
* whether the rendered HTML for the current request has changed since the last
* optimization pass and invalidates the current optimization data if it's outdated.
*/
final class Hash_Handler {
use Viewport_Trait;
/**
* Captures the final HTML before output buffering is
* flushed.
*
* @var string
*/
private string $html = '';
private Hash_Builder $hasher;
private Store $store;
private Rule_Collection $rules;
private Background_Processor $background_processor;
private Hash_Store $hash_store;
private Path_Factory $path_factory;
private LoggerInterface $logger;
/**
* @param Hash_Builder $hasher
* @param Store $store
* @param Rule_Collection $rules
* @param Background_Processor $background_processor
* @param Hash_Store $hash_store
* @param Path_Factory $path_factory
* @param LoggerInterface $logger
*/
public function __construct(
Hash_Builder $hasher,
Store $store,
Rule_Collection $rules,
Background_Processor $background_processor,
Hash_Store $hash_store,
Path_Factory $path_factory,
LoggerInterface $logger
) {
$this->hasher = $hasher;
$this->store = $store;
$this->rules = $rules;
$this->background_processor = $background_processor;
$this->hash_store = $hash_store;
$this->path_factory = $path_factory;
$this->logger = $logger;
}
/**
* Begin buffering the request.
*
* @action template_redirect
*
* @return void
*/
public function start_buffering(): void {
ob_start( [ $this, 'end_buffering' ] );
}
/**
* Manage the current HTML hash state for the request.
*
* Compares the freshly generated hash of the final output buffer
* against the previously stored hash. If the hash differs, this indicates
* that the page content has changed since the last optimization pass.
*
* This method is intended to be called AS LATE AS POSSIBLE in the `shutdown` phase, after
* output buffering has captured the full HTML content.
*
* If running under fastcgi or litespeed, the request will be returned immediately and the logic
* processed in the background.
*
* @action shutdown
*
* @return void
*/
public function check_hash(): void {
if ( SG::get_get_var( Request::QUERY_OPTIMIZER_PREVIEW ) ) {
$this->logger->debug( 'Skipping hash check due to optimizer preview query variable.' );
return;
}
if ( ! $this->html ) {
if ( ! is_admin() ) {
$this->logger->debug(
'Bypassing Optimizer: No HTML found to check',
[
'request_uri' => SG::get_server_var( 'REQUEST_URI', 'unknown' ),
]
);
}
return;
}
// Don't check hashes on 404 requests.
if ( is_404() ) {
$this->logger->debug(
'Bypassing Optimizer: 404 not found',
[
'request_uri' => SG::get_server_var( 'REQUEST_URI', 'unknown' ),
]
);
return;
}
// Return request early, if possible, so we can process this in the background.
$this->background_processor->try_finish();
$viewport = Viewport::current( $this->is_mobile() );
// Process skip rules and bail if required.
foreach ( $this->rules->all() as $rule ) {
if ( $rule->should_skip() ) {
$this->logger->debug(
'Bypassing Optimizer: skip rule',
[
'rule' => get_class( $rule ),
'viewport' => $viewport->value(),
]
);
return;
}
}
try {
$path = $this->path_factory->make();
} catch ( InvalidArgumentException $e ) {
$this->logger->error(
'Hash handler unable to determine the path',
[
'viewport' => $viewport->value(),
'exception' => $e,
]
);
return;
}
// Generate a hash based on the final HTML markup, note this differs for mobile vs desktop.
$hash = $this->hasher->build_hash( $this->html );
$stored_hash = $this->hash_store->get( $path, $viewport );
// The frontend script will pass this get var as a hash set request.
$maybe_set_hash = (bool) SG::get_get_var( 'kadence_set_optimizer_hash', false );
if ( $maybe_set_hash ) {
$this->logger->debug(
'Attempting to store new optimizer hash',
[
'path' => $path->path(),
'viewport' => $viewport->value(),
'hash' => $hash,
]
);
// Store the hash for the current viewport.
$this->hash_store->set( $path, $viewport, $hash );
do_action( 'kadence_blocks_optimizer_set_hash', $hash, $path, $viewport );
return;
}
// The HTML has been changed somehow, invalidate the optimization data, so that the next request will not have the data.
if ( $stored_hash && $stored_hash !== $hash ) {
$changes = $this->hasher->get_changed_components( $stored_hash, $hash );
$this->logger->debug(
'Optimizer hash does not match...deleting',
[
'path' => $path->path(),
'viewport' => $viewport->value(),
'changes' => $changes,
]
);
// Delete the viewport hash.
$this->hash_store->delete( $path, $viewport );
$analysis = $this->store->get( $path );
// This page isn't optimized or the data is already invalidated.
if ( ! $analysis ) {
return;
}
// Set data to stale to force invalidate data for all viewports.
try {
$this->logger->debug(
'Marking optimizer path as stale to remove on next page load',
[
'path' => $path->path(),
]
);
$analysis->isStale = true;
$this->store->set( $path, $analysis );
do_action( 'kadence_blocks_optimizer_data_invalidated', $analysis->isStale, $path );
} catch ( Throwable $e ) {
// Our DateTimeImmutable should never throw an exception, but this is here just in case.
return;
}
}
$this->html = '';
do_action( 'kadence_blocks_hash_check_complete' );
}
/**
* Get the HTML, which will differ as the request proceeds.
*
* @return string
*/
public function html(): string {
return $this->html;
}
/**
* Callback that receives the buffer's contents. Captures the full page HTML
* in our property for use when we manage the hash state down the line.
*
* @param string $html The final HTML.
* @param int $phase The bitmask of the PHP_OUTPUT_HANDLER_* constants.
*
* @return string
*/
private function end_buffering( string $html, int $phase ): string {
if ( $phase & PHP_OUTPUT_HANDLER_FINAL ) {
$this->html = $html;
}
return $html;
}
}

View File

@@ -0,0 +1,82 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Hash;
use KadenceWP\KadenceBlocks\Optimizer\Database\Viewport_Query;
use KadenceWP\KadenceBlocks\Optimizer\Enums\Viewport;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
/**
* Stores a hash based on the rendered HTML for each viewport
* in order to invalidate optimizer data if it changes.
*/
final class Hash_Store {
private Viewport_Query $q;
public function __construct( Viewport_Query $query ) {
$this->q = $query;
}
/**
* Get the hash for a viewport.
*
* @param Path $path The current path object.
* @param Viewport $vp The viewport enum.
*
* @return string|null
*/
public function get( Path $path, Viewport $vp ): ?string {
$hash = $this->q->get_var(
$this->q->qb()->select( 'html_hash' )
->where( 'path_hash', $path->hash() )
->where( 'viewport', $vp->value() )
->getSQL()
);
return $hash ?: null;
}
/**
* Set the hash for a viewport.
*
* @param Path $path The current path object.
* @param Viewport $vp The viewport enum.
* @param string $hash The hash to store.
*
* @return bool
*/
public function set( Path $path, Viewport $vp, string $hash ): bool {
return (bool) $this->q->qb()->upsert(
[
'path_hash' => $path->hash(),
'viewport' => $vp->value(),
'html_hash' => $hash,
],
[
'path_hash',
'viewport',
],
[
'%s',
'%s',
'%s',
]
);
}
/**
* Delete a viewport hash.
*
* @param Path $path The current path object.
* @param Viewport $vp The viewport enum.
*
* @return bool
*/
public function delete( Path $path, Viewport $vp ): bool {
return (bool) $this->q->qb()
->where( 'path_hash', $path->hash() )
->where( 'viewport', $vp->value() )
->delete();
}
}

View File

@@ -0,0 +1,45 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Hash;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->register_hash_store();
$this->register_hash_handling();
}
private function register_hash_store(): void {
$this->container->singleton( Hash_Store::class, Hash_Store::class );
}
private function register_hash_handling(): void {
$this->container->singleton( Hash_Builder::class, Hash_Builder::class );
$this->container->singleton( Hash_Handler::class, Hash_Handler::class );
// Don't hook in the optimizer under these scenarios for optimal performance.
if (
! defined( 'WP_UNINSTALL_PLUGIN' ) &&
! wp_installing() &&
! wp_doing_ajax() &&
! wp_is_json_request() &&
! wp_doing_cron()
) {
add_action(
'template_redirect',
$this->container->callback( Hash_Handler::class, 'start_buffering' ),
1,
0
);
add_action(
'shutdown',
$this->container->callback( Hash_Handler::class, 'check_hash' ),
PHP_INT_MAX - 1,
0
);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image\Contracts;
use KadenceWP\KadenceBlocks\Optimizer\Response\ImageAnalysis;
use WP_HTML_Tag_Processor;
/**
* Process an img tag to manipulate its markup before the HTML is send back
* to the browser.
*/
interface Processor {
/**
* Manipulates img tags.
*
* @param WP_HTML_Tag_Processor $p The HTML Tag Processor, set on the current image it's processing.
* @param string[] $critical_images The array of critical image src's.
* @param array<string, ImageAnalysis> $images The array of all collected images indexed by a unique key.
*
* @return void
*/
public function process( WP_HTML_Tag_Processor $p, array $critical_images, array $images ): void;
}

View File

@@ -0,0 +1,201 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Optimizer\Enums\Viewport;
use KadenceWP\KadenceBlocks\Optimizer\Image\Contracts\Processor;
use KadenceWP\KadenceBlocks\Optimizer\Image\Traits\Image_Key_Generator_Trait;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path_Factory;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rule_Collection;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
use KadenceWP\KadenceBlocks\Traits\Viewport_Trait;
use WP_HTML_Tag_Processor;
/**
* Modify HTML img tags before the HTML is sent back to the browser:
*
* - 1. Set correct lazy load attributes.
* - 2. Set the optimal sizes attribute.
*/
final class Image_Processor {
use Viewport_Trait;
use Image_Key_Generator_Trait;
private Store $store;
private Rule_Collection $rules;
private Path_Factory $path_factory;
/**
* The list of image processors.
*
* @var Processor[]
*/
private array $processors;
/**
* The logger.
*
* @var LoggerInterface
*/
private LoggerInterface $logger;
/**
* @param Store $store The optimization store.
* @param Rule_Collection $rules The rule collection.
* @param Path_Factory $path_factory The path factory.
* @param LoggerInterface $logger The logger.
* @param Processor[] $processors The list of image processors.
*/
public function __construct(
Store $store,
Rule_Collection $rules,
Path_Factory $path_factory,
LoggerInterface $logger,
array $processors
) {
$this->store = $store;
$this->rules = $rules;
$this->path_factory = $path_factory;
$this->logger = $logger;
$this->processors = $processors;
}
/**
* Begin buffering the request.
*
* @action template_redirect
*
* @return void
*/
public function start_buffering(): void {
// Process skip rules and bypass buffering.
foreach ( $this->rules->all() as $rule ) {
if ( $rule->should_skip() ) {
$this->logger->debug(
'Skipping Image Processor Buffering: skip rule',
[
'rule' => get_class( $rule ),
'request_uri' => SG::get_server_var( 'REQUEST_URI', 'unknown' ),
]
);
return;
}
}
ob_start( [ $this, 'stop_buffering' ] );
}
/**
* Modify each image using our collection of image processors.
*
* @see self::stop_buffering()
*
* @param string $html The HTML WordPress will render.
* @param Path $path The current path object.
*
* @return string
*/
public function process_images( string $html, Path $path ): string {
$analysis = $this->store->get( $path );
if ( ! $analysis ) {
return $html;
}
$device = $analysis->getDevice( Viewport::current( $this->is_mobile() ) );
$critical_images = $device ? $device->criticalImages : [];
$images = $analysis->images;
$images_by_key = [];
// Reshape the array so we can do O(1) lookups.
foreach ( $images as $image ) {
if ( isset( $image->src, $image->sizes ) ) {
$key = $this->generate_image_key( $image->src, $image->sizes );
if ( $key !== null ) {
$images_by_key[ $key ] = $image;
}
}
}
$p = new WP_HTML_Tag_Processor( $html );
// Add the kb-optimized class to the body.
if ( $p->next_tag( 'body' ) ) {
$p->add_class( 'kb-optimized' );
}
// Run each image through all image processors.
while ( $p->next_tag( 'img' ) ) {
$src = $p->get_attribute( 'src' );
$classes = $p->get_attribute( 'class' );
foreach ( $this->processors as $processor ) {
/**
* Allow short-circuiting of processing this image for the
* current processor.
*
* @param bool $should_process Whether we proceed with processing. Defaults to true.
* @param string $src The image src.
* @param string|null $classes The classes assigned to the img tag.
* @param Processor $processor The current processor instance.
* @param Path $post The current path object.
*/
$should_process = apply_filters(
'kadence_blocks_optimizer_image_processor',
true,
$src,
$classes,
$processor,
$path
);
if ( $should_process ) {
$processor->process( $p, $critical_images, $images_by_key );
}
}
}
return $p->get_updated_html();
}
/**
* Callback that receives the buffer's contents.
*
* @see wp_ob_end_flush_all()
* @action shutdown
*
* @param string $html The HTML WordPress will render.
* @param int $phase The bitmask of the PHP_OUTPUT_HANDLER_* constants.
*
* @return string The modified HTML with lazy loaded and optimal image sizes.
*/
private function stop_buffering( string $html, int $phase ): string {
if ( ! ( $phase & PHP_OUTPUT_HANDLER_FINAL ) ) {
return $html;
}
try {
$path = $this->path_factory->make();
} catch ( InvalidArgumentException $e ) {
$this->logger->error(
'Failed to get path in image processor buffering',
[
'exception' => $e,
]
);
return $html;
}
return $this->process_images( $html, $path );
}
}

View File

@@ -0,0 +1,66 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image\Processors;
use KadenceWP\KadenceBlocks\Optimizer\Image\Contracts\Processor;
use KadenceWP\KadenceBlocks\Optimizer\Response\ImageAnalysis;
use WP_HTML_Tag_Processor;
final class Lazy_Load_Processor implements Processor {
/**
* A queue of URLs that are above the fold.
*
* @var string[]|null
*/
private ?array $critical_image_queue = null;
/**
* Add or remove the appropriate lazy loading attributes. It's important to note that WordPress
* may have already added a loading=lazy attribute to an image that shouldn't have it.
*
* @param WP_HTML_Tag_Processor $p The HTML Tag Processor, set on the current image it's processing.
* @param string[] $critical_images The list of the above the fold image URLs.
* @param array<string, ImageAnalysis> $images The array of all collected images indexed by a unique key.
*
* @return void
*/
public function process( WP_HTML_Tag_Processor $p, array $critical_images, array $images ): void {
$src = (string) $p->get_attribute( 'src' );
// Don't lazy load data URLs.
if ( str_starts_with( $src, 'data:' ) ) {
return;
}
// Initialize the queue on first run.
if ( ! isset( $this->critical_image_queue ) ) {
$this->critical_image_queue = $critical_images;
}
// Check if this image is next in our critical queue.
if ( $this->critical_image_queue ) {
$queue_index = array_search( $src, $this->critical_image_queue, true );
if ( $queue_index !== false ) {
// Remove lazy loading for critical image.
if ( 'lazy' === $p->get_attribute( 'loading' ) ) {
$p->remove_attribute( 'loading' );
}
// Remove this occurrence from the queue.
unset( $this->critical_image_queue[ $queue_index ] );
return;
}
}
// Below the fold logic (or after all critical images processed).
$p->set_attribute( 'loading', 'lazy' );
// If WordPress somehow added a high fetch priority, remove it.
if ( 'high' === $p->get_attribute( 'fetchpriority' ) ) {
$p->remove_attribute( 'fetchpriority' );
}
}
}

View File

@@ -0,0 +1,51 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image\Processors;
use KadenceWP\KadenceBlocks\Optimizer\Image\Contracts\Processor;
use KadenceWP\KadenceBlocks\Optimizer\Image\Traits\Image_Key_Generator_Trait;
use KadenceWP\KadenceBlocks\Optimizer\Response\ImageAnalysis;
use WP_HTML_Tag_Processor;
final class Sizes_Attribute_Processor implements Processor {
use Image_Key_Generator_Trait;
/**
* Set the optimal sizes attribute for the image.
*
* @param WP_HTML_Tag_Processor $p The HTML Tag Processor, set on the current image it's processing.
* @param string[] $critical_images The array of critical image src's.
* @param array<string, ImageAnalysis> $images The array of all collected images indexed by a unique key.
*
* @return void
*/
public function process( WP_HTML_Tag_Processor $p, array $critical_images, array $images ): void {
// The analyzer doesn't collect images without a srcset.
$srcset = $p->get_attribute( 'srcset' );
if ( ! $srcset ) {
return;
}
$src = $p->get_attribute( 'src' );
$sizes = $p->get_attribute( 'sizes' );
$key = $this->generate_image_key( $src, $sizes );
if ( null === $key ) {
return;
}
$image = $images[ $key ] ?? false;
if ( ! $image instanceof ImageAnalysis ) {
return;
}
// Update the sizes attribute with our optimal sizes.
if ( $image->optimalSizes ) {
$p->set_attribute( 'sizes', $image->optimalSizes );
}
}
}

View File

@@ -0,0 +1,32 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image;
use KadenceWP\KadenceBlocks\Optimizer\Image\Processors\Lazy_Load_Processor;
use KadenceWP\KadenceBlocks\Optimizer\Image\Processors\Sizes_Attribute_Processor;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->container->singleton( Lazy_Load_Processor::class, Lazy_Load_Processor::class );
$this->container->singleton( Image_Processor::class, Image_Processor::class );
$this->container->when( Image_Processor::class )
->needs( '$processors' )
->give(
fn(): array => [
// Add additional image processors here.
$this->container->get( Lazy_Load_Processor::class ),
$this->container->get( Sizes_Attribute_Processor::class ),
]
);
add_action(
'template_redirect',
$this->container->callback( Image_Processor::class, 'start_buffering' ),
1,
0
);
}
}

View File

@@ -0,0 +1,28 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Image\Traits;
trait Image_Key_Generator_Trait {
/**
* Generate a unique key for indexing image data.
*
* @param string|null|bool $src The image source URL.
* @param string|null|bool $sizes The image sizes attribute.
*
* @return string|null A unique key or null if inputs are missing or non-string.
*/
private function generate_image_key( $src, $sizes ): ?string {
// Only allow actual strings.
if ( ! is_string( $src ) || ! is_string( $sizes ) ) {
return null;
}
// Bypass empty attributes.
if ( $src === '' || $sizes === '' ) {
return null;
}
return md5( $src . '|' . $sizes );
}
}

View File

@@ -0,0 +1,155 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load;
use KadenceWP\KadenceBlocks\Asset\Asset;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
use WP_HTML_Tag_Processor;
/**
* Handles setting up the Kadence Row Layout Block for background image
* lazy loading.
*/
final class Background_Lazy_Loader {
private Asset $asset;
private Analysis_Registry $registry;
public function __construct(
Asset $asset,
Analysis_Registry $registry
) {
$this->asset = $asset;
$this->registry = $registry;
}
/**
* Add lazy loading attributes to the row layout block's wrapper div.
*
* @filter kadence_blocks_row_wrapper_args
*
* @note Video posters come in as a bgImg as well.
*
* @param array<string, mixed> $attrs The wrapper div HTML attributes.
* @param array<string, mixed> $attributes The current block attributes.
*
* @return array<string, mixed>
*/
public function lazy_load_row_background_images( array $attrs, array $attributes ): array {
$bg = $attributes['bgImg'] ?? '';
$classes = (string) ( $attrs['class'] ?? '' );
if ( ! $bg ) {
return $attrs;
}
if ( ! $this->registry->is_optimized() ) {
return $attrs;
}
$background_images = $this->registry->get_background_images();
if ( $background_images ) {
$lookup = array_flip( $background_images );
// Exclude above the fold background images.
if ( isset( $lookup[ $bg ] ) ) {
return $attrs;
}
}
$is_inline = $attributes['backgroundInline'] ?? false;
// Add lazy loading data attributes for CSS backgrounds.
if ( $classes ) {
$attrs['data-kadence-lazy-class'] = $classes;
$attrs['data-kadence-lazy-trigger'] = 'viewport';
$attrs['data-kadence-lazy-attrs'] = 'class';
unset( $attrs['class'] );
}
// Add lazy loading data attributes for inline style background images.
if ( $is_inline ) {
$attrs['data-kadence-lazy-style'] = $attrs['style'] ?? '';
$attrs['data-kadence-lazy-trigger'] ??= 'viewport';
if ( ! empty( $attrs['data-kadence-lazy-attrs'] ) ) {
$attrs['data-kadence-lazy-attrs'] .= ',style';
} else {
$attrs['data-kadence-lazy-attrs'] = 'style';
}
unset( $attrs['style'] );
}
// Enqueue the lazy loader script if we found a bg.
$this->enqueue_scripts();
return $attrs;
}
/**
* Lazy load columns with background images.
*
* @filter kadence_blocks_column_html
*
* @param string $html The kadence/column html.
* @param array $attributes The block attributes.
*
* @return string
*/
public function lazy_load_column_backgrounds( string $html, array $attributes ): string {
$bg = $attributes['backgroundImg'][0]['bgImg'] ?? false;
if ( ! $bg ) {
return $html;
}
if ( ! $this->registry->is_optimized() ) {
return $html;
}
$background_images = $this->registry->get_background_images();
if ( $background_images ) {
$lookup = array_flip( $background_images );
// Exclude above the fold background images.
if ( isset( $lookup[ $bg ] ) ) {
return $html;
}
}
$p = new WP_HTML_Tag_Processor( $html );
if ( ! $p->next_tag( [ 'class_name' => 'wp-block-kadence-column' ] ) ) {
return $html;
}
$class_attr = $p->get_attribute( 'class' );
if ( ! $class_attr ) {
return $html;
}
$p->set_attribute( 'data-kadence-lazy-class', $class_attr );
$p->set_attribute( 'data-kadence-lazy-trigger', 'viewport' );
$p->set_attribute( 'data-kadence-lazy-attrs', 'class' );
$p->remove_attribute( 'class' );
// Enqueue the lazy loader script if we found a bg.
$this->enqueue_scripts();
return $p->get_updated_html();
}
/**
* Enqueue the lazy loading frontend script.
*
* @return void
*/
private function enqueue_scripts(): void {
$this->asset->enqueue_script( 'lazy-loader', 'dist/lazy-loader' );
}
}

View File

@@ -0,0 +1,139 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load;
use KadenceWP\KadenceBlocks\Optimizer\Lazy_Load\Sections\Lazy_Render_Decider;
use WP_HTML_Tag_Processor;
/**
* Process section Optimization data to set content visibility on the Kadence row layout and column
* block.
*
* @phpstan-type HtmlClassHeightMap array<string, float> Class attribute => height in pixels.
*/
final class Element_Lazy_Loader {
private const CONTENT_VISIBILITY = 'content-visibility';
private const CONTAIN_INTRINSIC_SIZE = 'contain-intrinsic-size';
private Lazy_Render_Decider $decider;
public function __construct(
Lazy_Render_Decider $decider
) {
$this->decider = $decider;
}
/**
* Add content-visibility CSS to below the fold Kadence row elements.
*
* @filter kadence_blocks_row_wrapper_args
*
* @param array<string, mixed> $args The wrapper div HTML attributes.
* @param array<string, mixed> $attributes The current block attributes.
*
* @return array<string, mixed>
*/
public function set_content_visibility_for_row( array $args, array $attributes ): array {
$unique_id = $attributes['uniqueID'] ?? false;
if ( ! $unique_id ) {
return $args;
}
$classes = (string) ( $args['class'] ?? '' );
// Explode class string into array for exact matching.
$class_list = array_filter( preg_split( '/\s+/', $classes ) );
// Bypass lazy rendering if we find an excluded class (exact match only).
if ( ! $this->decider->should_lazy_render( $class_list ) ) {
return $args;
}
$height = $this->decider->get_section_height_by_unique_id( $unique_id );
if ( $height <= 0 ) {
return $args;
}
$current_style = (string) ( $args['style'] ?? '' );
if ( $this->has_content_visibility( $current_style ) ) {
return $args;
}
$args['style'] = $this->prepend_style( $height, $current_style );
return $args;
}
/**
* Set content visibility on found kadence/column sections.
*
* @filter kadence_blocks_column_html
*
* @param string $html The kadence/column html.
*
* @return string
*/
public function modify_column_html( string $html ): string {
$p = new WP_HTML_Tag_Processor( $html );
if ( ! $p->next_tag( [ 'class_name' => 'wp-block-kadence-column' ] ) ) {
return $html;
}
$class_attr = $p->get_attribute( 'class' );
if ( ! $class_attr ) {
return $html;
}
$height = $this->decider->get_section_height_by_class_attr( $class_attr );
if ( $height <= 0 ) {
return $html;
}
$current_style = (string) $p->get_attribute( 'style' );
if ( $this->has_content_visibility( $current_style ) ) {
return $html;
}
$p->set_attribute( 'style', $this->prepend_style( $height, $current_style ) );
return $p->get_updated_html();
}
/**
* Check if a style attribute already has content-visibility.
*
* @param string $current_style The current style attribute value.
*
* @return bool
*/
private function has_content_visibility( string $current_style ): bool {
return str_contains( $current_style, self::CONTENT_VISIBILITY ) ||
str_contains( $current_style, self::CONTAIN_INTRINSIC_SIZE );
}
/**
* Prepend the content-visibility styles to an existing style string.
*
* @param float $height The section height.
* @param string $current_style The current style.
*
* @return string
*/
private function prepend_style( float $height, string $current_style ): string {
return sprintf(
'%s: auto;%s: auto %dpx;%s',
self::CONTENT_VISIBILITY,
self::CONTAIN_INTRINSIC_SIZE,
$height,
trim( $current_style )
);
}
}

View File

@@ -0,0 +1,127 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
use KadenceWP\KadenceBlocks\Optimizer\Lazy_Load\Sections\Lazy_Render_Decider;
use KadenceWP\KadenceBlocks\Optimizer\Request\Request;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
use KadenceWP\KadenceBlocks\Traits\Viewport_Trait;
final class Provider extends Provider_Contract {
use Viewport_Trait;
public function register(): void {
$this->register_analysis_registry();
$this->register_element_lazy_loader();
$this->register_slider_lazy_loader();
$this->register_video_poster_lazy_loader();
$this->register_background_lazy_loader();
}
private function register_analysis_registry(): void {
$this->container->singleton( Analysis_Registry::class, Analysis_Registry::class );
$this->container->when( Analysis_Registry::class )
->needs( '$is_mobile' )
->give( fn(): bool => $this->is_mobile() );
}
private function register_element_lazy_loader(): void {
$this->container->singleton( Lazy_Render_Decider::class, Lazy_Render_Decider::class );
$this->container->singleton( Element_Lazy_Loader::class, Element_Lazy_Loader::class );
/**
* Filters the list of CSS classes that should be excluded from lazy loading for
* section elements.
*
* @param string[] $excluded_classes An array of CSS class names. If a row has one
* of these classes, it will be excluded from lazy loading.
*/
$excluded_classes = apply_filters(
'kadence_blocks_optimizer_section_lazy_load_excluded_classes',
[
'kt-jarallax',
]
);
$this->container->when( Lazy_Render_Decider::class )
->needs( '$excluded_classes' )
->give( static fn(): array => $excluded_classes );
// Do not perform element lazy loading on optimizer requests.
if ( $this->container->get( Request::class )->is_optimizer_request() ) {
return;
}
add_filter(
'kadence_blocks_row_wrapper_args',
$this->container->callback( Element_Lazy_Loader::class, 'set_content_visibility_for_row' ),
10,
2
);
add_filter(
'kadence_blocks_column_html',
$this->container->callback( Element_Lazy_Loader::class, 'modify_column_html' ),
10,
1
);
}
private function register_slider_lazy_loader(): void {
$this->container->singleton( Slider_Lazy_Loader::class, Slider_Lazy_Loader::class );
// Do not perform slider lazy loading on optimizer requests.
if ( $this->container->get( Request::class )->is_optimizer_request() ) {
return;
}
add_filter(
'kadence_blocks_row_slider_attrs',
$this->container->callback( Slider_Lazy_Loader::class, 'lazy_load_row_slider' ),
10,
2
);
}
private function register_video_poster_lazy_loader(): void {
$this->container->singleton( Video_Poster_Lazy_Loader::class, Video_Poster_Lazy_Loader::class );
// Do not perform slider lazy loading on optimizer requests.
if ( $this->container->get( Request::class )->is_optimizer_request() ) {
return;
}
add_filter(
'kadence_blocks_row_video_attrs',
$this->container->callback( Video_Poster_Lazy_Loader::class, 'lazy_load_row_video_poster' ),
10,
1
);
}
private function register_background_lazy_loader(): void {
$this->container->singleton( Background_Lazy_Loader::class, Background_Lazy_Loader::class );
// Do not perform background lazy loading on optimizer requests.
if ( $this->container->get( Request::class )->is_optimizer_request() ) {
return;
}
add_filter(
'kadence_blocks_row_wrapper_args',
$this->container->callback( Background_Lazy_Loader::class, 'lazy_load_row_background_images' ),
20,
2
);
add_filter(
'kadence_blocks_column_html',
$this->container->callback( Background_Lazy_Loader::class, 'lazy_load_column_backgrounds' ),
20,
2
);
}
}

View File

@@ -0,0 +1,73 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load\Sections;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
/**
* Decide whether we should lazy render a section or not.
*/
class Lazy_Render_Decider {
private Analysis_Registry $registry;
/**
* A list of CSS classes that will bypass lazy rendering.
*
* @var string[]
*/
private array $excluded_classes;
public function __construct(
Analysis_Registry $registry,
array $excluded_classes
) {
$this->registry = $registry;
$this->excluded_classes = $excluded_classes;
}
/**
* Whether we should lazy load a section.
*
* @param string[] $class_list The class of classes to check.
*
* @return bool
*/
public function should_lazy_render( array $class_list ): bool {
foreach ( $this->excluded_classes as $excluded_class ) {
if ( in_array( $excluded_class, $class_list, true ) ) {
return false;
}
}
return true;
}
/**
* Get a section's height by a Kadence unique ID.
*
* @param string $unique_id The unique ID, e.g. 1722_f1b7a6-ea.
*
* @return float
*/
public function get_section_height_by_unique_id( string $unique_id ): float {
foreach ( $this->registry->get_valid_sections() as $class_attr => $height ) {
if ( str_contains( $class_attr, $unique_id ) ) {
return $height;
}
}
return 0.0;
}
/**
* Get a section's height by matching the complete class attribute value.
*
* @param string $class_attr The class attribute, e.g. `kb-row-layout-wrap kb-row-layout-id1722_ab152a-75 alignfull`.
*
* @return float
*/
public function get_section_height_by_class_attr( string $class_attr ): float {
return $this->registry->get_valid_sections()[ $class_attr ] ?? 0.0;
}
}

View File

@@ -0,0 +1,53 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
final class Slider_Lazy_Loader {
private Analysis_Registry $registry;
public function __construct( Analysis_Registry $registry ) {
$this->registry = $registry;
}
/**
* Lazy load the row slider background image.
*
* @filter kadence_blocks_row_slider_attrs
*
* @param array<string, mixed> $attrs The HTML attributes.
* @param array<string, mixed> $attributes The row block's attributes.
*
* @return array<string, mixed>
*/
public function lazy_load_row_slider( array $attrs, array $attributes ): array {
if ( ! $this->registry->is_optimized() ) {
return $attrs;
}
$sliders = $attributes['backgroundSlider'] ?? [];
// Exclude sliders with above the fold background images.
if ( $sliders ) {
$background_images = $this->registry->get_background_images();
if ( $background_images ) {
$lookup = array_flip( $background_images );
foreach ( $sliders as $slide ) {
$bg = $slide['bgImg'] ?? '';
if ( $bg && isset( $lookup[ $bg ] ) ) {
return $attrs;
}
}
}
}
$attrs['class'] = trim( ( $attrs['class'] ?? '' ) . ' kb-lazy-bg-pending' );
return $attrs;
}
}

View File

@@ -0,0 +1,62 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Lazy_Load;
use KadenceWP\KadenceBlocks\Asset\Asset;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
final class Video_Poster_Lazy_Loader {
private Asset $asset;
private Analysis_Registry $registry;
public function __construct(
Asset $asset,
Analysis_Registry $registry
) {
$this->asset = $asset;
$this->registry = $registry;
}
/**
* Lazy load video posters.
*
* @example `<video poster="https://wordpress.test/some-image.jpg">`
*
* @filter kadence_blocks_row_video_attrs
*
* @param array<string, mixed> $attrs The HTML attributes.
*
* @return array<string, mixed>
*/
public function lazy_load_row_video_poster( array $attrs ): array {
$poster = $attrs['poster'] ?? '';
if ( ! $poster ) {
return $attrs;
}
if ( ! $this->registry->is_optimized() ) {
return $attrs;
}
$critical_images = $this->registry->get_background_images();
$lookup = array_flip( $critical_images );
// Bypass above the fold images.
if ( isset( $lookup[ $poster ] ) ) {
return $attrs;
}
$attrs['data-kadence-lazy-poster'] = $poster;
$attrs['data-kadence-lazy-trigger'] = 'viewport';
$attrs['data-kadence-lazy-attrs'] = 'poster';
unset( $attrs['poster'] );
// Enqueue the frontend lazy loader script.
$this->asset->enqueue_script( 'lazy-loader', 'dist/lazy-loader' );
return $attrs;
}
}

View File

@@ -0,0 +1,4 @@
import { createLazyLoader } from './lazy-loader';
const lazyLoader = createLazyLoader();
lazyLoader.observeElements();

View File

@@ -0,0 +1,60 @@
/**
* Use the IntersectionObserver to replace our lazy load attributes with the originals
* when the element comes into the viewport.
*
* @param {IntersectionObserverInit} options The IntersectionObserver options.
*/
export const createLazyLoader = (options = { rootMargin: '300px 0px' }) => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
observer.unobserve(e.target);
// Get the attributes to replace: data-kadence-lazy-attrs.
const lazyAttributes = e.target.dataset.kadenceLazyAttrs;
// Merge or copy the attribute values back to their original attributes.
if (lazyAttributes) {
lazyAttributes.split(',').forEach((attributeName) => {
const lazyValue = e.target.getAttribute(`data-kadence-lazy-${attributeName}`);
if (lazyValue) {
const existingValue = e.target.getAttribute(attributeName);
if (attributeName === 'class') {
// Merge class names.
e.target.classList.add(...lazyValue.trim().split(/\s+/));
} else {
// Merge or set values if the attribute already exists.
const mergedValue = existingValue
? `${existingValue.trim()} ${lazyValue}`.trim()
: lazyValue;
e.target.setAttribute(attributeName, mergedValue);
}
}
});
// Clean up the lazy data attributes.
[...e.target.attributes]
.filter((attr) => attr.name.startsWith('data-kadence-lazy'))
.forEach((attr) => e.target.removeAttribute(attr.name));
// Fire an event so other JS scripts can hook into this and re-run their initialization.
window.dispatchEvent(
new CustomEvent('kadence-lazy-loaded', {
detail: { element: e.target },
})
);
}
}
});
}, options);
const observeElements = (selector = "[data-kadence-lazy-trigger='viewport']") => {
const elements = document.querySelectorAll(selector);
elements.forEach((e) => observer.observe(e));
};
return { observeElements };
};

View File

@@ -0,0 +1,74 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Nonce;
/**
* Custom nonce implementation for when the optimizer is running.
*/
final class Nonce {
/**
* Random negative ID to never clash with a possible existing
* WordPress user ID.
*/
public const NONCE_ID = -1223685899;
private string $action;
/**
* @param string $action The nonce action for the optimizer.
*/
public function __construct( string $action = 'kb-optimizer' ) {
$this->action = $action;
}
/**
* Create a nonce for use for a logged-out user, even if the user is logged in.
*
* @return string
*/
public function create(): string {
$uid = get_current_user_id();
wp_set_current_user( 0 );
$nonce = wp_create_nonce( $this->action );
wp_set_current_user( $uid );
return $nonce;
}
/**
* Verify the nonce as a logged-out user and then restore the original user.
*
* @param string $nonce The nonce value.
*
* @return false|int
*/
public function verify( string $nonce ) {
$uid = get_current_user_id();
wp_set_current_user( 0 );
$verified = wp_verify_nonce( $nonce, $this->action );
wp_set_current_user( $uid );
return $verified;
}
/**
* Customize the nonce ID in order to generate a unique nonce value for just this action.
*
* This way, it's not shared with all logged-out users.
*
* @filter nonce_user_logged_out
*
* @param int $uid The current user ID.
* @param string|int $action The nonce action.
*
* @return int
*/
public function customize_nonce_id( int $uid, $action ): int {
if ( 0 !== $uid || $action !== $this->action ) {
return $uid;
}
return self::NONCE_ID;
}
}

View File

@@ -0,0 +1,19 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Nonce;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->container->singleton( Nonce::class, Nonce::class );
add_filter(
'nonce_user_logged_out',
$this->container->callback( Nonce::class, 'customize_nonce_id' ),
10,
2
);
}
}

View File

@@ -0,0 +1,75 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals;
final class Optimizer_Provider extends Provider {
/**
* List of Optimizer providers to register, in order.
*
* @var class-string<Provider>[]
*/
private const PROVIDERS = [
Database\Provider::class,
Translation\Provider::class,
Skip_Rules\Provider::class,
Hash\Provider::class,
Nonce\Provider::class,
Request\Provider::class,
Store\Provider::class,
Status\Provider::class,
Asset\Provider::class,
Post_List_Table\Provider::class,
Rest\Provider::class,
Lazy_Load\Provider::class,
Image\Provider::class,
Resource_Hints\Provider::class,
];
public function register(): void {
$this->container->singleton( State::class, State::class );
/**
* Filter the optimizer enabled state.
*
* @param bool $enabled Whether the optimizer is enabled or not.
*/
$enabled = (bool) apply_filters(
'kadence_blocks_optimizer_enabled',
$this->container->get( State::class )->enabled()
);
if ( ! $enabled ) {
return;
}
$this->register_mobile_override();
foreach ( self::PROVIDERS as $provider ) {
$this->container->register( $provider );
}
}
/**
* Allow force overriding wp_is_mobile with a query string variable.
*
* @return void
*/
private function register_mobile_override(): void {
add_filter(
'wp_is_mobile',
static function ( bool $is_mobile ): bool {
if ( SuperGlobals::get_get_var( 'kadence_is_mobile' ) ) {
return true;
}
return $is_mobile;
},
10,
1
);
}
}

View File

@@ -0,0 +1,85 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Path;
use InvalidArgumentException;
/**
* The Path Data Transfer Object.
*
* Represents a URL path and optionally its associated post ID.
* The post_id is optional but recommended for reliable status synchronization.
*
* @see Path_Factory
*/
final class Path {
/**
* The path from $wp->request.
*
* @var string
*/
private string $path;
/**
* Optional post ID associated with this path.
*
* @var int|null
*/
private ?int $post_id;
/**
* @param string $path The path from $wp->request.
* @param int|null $post_id Optional post ID for status synchronization.
*
* @throws InvalidArgumentException If the path is empty.
*/
public function __construct( string $path, ?int $post_id = null ) {
if ( ! $path ) {
throw new InvalidArgumentException( 'Cannot hash an empty path. Verify you are using this after the wp hook fired.' );
}
$this->path = $path;
$this->post_id = $post_id;
}
/**
* Get the raw path.
*
* @return string
*/
public function path(): string {
return $this->path;
}
/**
* Create a sha256 hash for the path.
*
* @return string
*/
public function hash(): string {
return hash( 'sha256', $this->path );
}
/**
* Get the associated post ID if available.
*
* @return int|null
*/
public function post_id(): ?int {
return $this->post_id;
}
/**
* Create a new Path with post ID attached (immutable).
*
* Useful for adding post context to an existing Path object.
*
* @param int $post_id The post ID.
*
* @return self
*/
public function with_post_id( int $post_id ): self {
return new self( $this->path, $post_id );
}
}

View File

@@ -0,0 +1,53 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Path;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
use WP_Post;
/**
* A path object to generate a unique key for each URL on a site.
*/
final class Path_Factory {
/**
* Create a path object based on the current relative path and $post global.
*
* @throws \InvalidArgumentException If the path is empty.
*
* @return Path
*/
public function make(): Path {
$uri = SG::get_server_var( 'REQUEST_URI', '' );
// Normalize leading slashes BEFORE wp_parse_url to prevent // being treated as protocol-relative URL.
$uri = preg_replace( '#^/+#', '/', $uri );
if ( $uri === null ) {
return new Path( '' );
}
$path = wp_parse_url( $uri, PHP_URL_PATH ) ?: '';
// Normalize multiple slashes within the path.
$normalized = preg_replace( '#/{2,}#', '/', $path );
if ( $normalized === null || $normalized === '' ) {
return new Path( '' );
}
// Ensure the path starts with a slash.
if ( ! str_starts_with( $normalized, '/' ) ) {
$normalized = '/' . $normalized;
}
// This will get the proper front/posts page ID.
$current_post = get_queried_object();
if ( ! $current_post instanceof WP_Post ) {
return new Path( $normalized, null );
}
return new Path( $normalized, $current_post->ID );
}
}

View File

@@ -0,0 +1,32 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
/**
* Adds actions to the Post List Table's "bulk actions" dropdown.
*/
final class Bulk_Action_Manager {
public const OPTIMIZE_POSTS = 'kb_optimize_posts';
public const REMOVE_OPTIMIZATION = 'kb_optimize_remove';
/**
* Add bulk actions to the post list table. These are then handled by the frontend js
* under Optimizer/assets/js/bulk-actions/index.js
*
* @filter bulk_actions-edit-post
* @filter bulk_actions-edit-page
*
* @param array<string, string|array<string, string>> $actions The existing actions.
*
* @return array<string, string|array<string, string>>
*/
public function register_actions( array $actions ): array {
$actions[ __( 'KB Optimizer', 'kadence-blocks' ) ] = [
self::OPTIMIZE_POSTS => __( 'Optimize', 'kadence-blocks' ),
self::REMOVE_OPTIMIZATION => __( 'Remove Optimization', 'kadence-blocks' ),
];
return $actions;
}
}

View File

@@ -0,0 +1,40 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
use InvalidArgumentException;
/**
* Represents a column in a post list table.
*/
final class Column {
public string $slug;
public string $label;
public string $meta_key;
/**
* @param string $slug The column slug.
* @param string $label The i18n friendly label.
* @param string $meta_key The meta key to sort by.
*
* @throws InvalidArgumentException If slug or label are empty or whitespace-only.
*/
public function __construct(
string $slug,
string $label,
string $meta_key
) {
if ( empty( trim( $slug ) ) ) {
throw new InvalidArgumentException( 'Column slug cannot be empty.' );
}
if ( empty( trim( $label ) ) ) {
throw new InvalidArgumentException( 'Column label cannot be empty.' );
}
$this->slug = $slug;
$this->label = $label;
$this->meta_key = $meta_key;
}
}

View File

@@ -0,0 +1,99 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
/**
* Manages WordPress hooks for post list table columns.
*/
final class Column_Hook_Manager {
/**
* @var Column_Registrar[]
*/
private array $columns;
/**
* @param Column_Registrar[] $columns
*/
public function __construct( array $columns ) {
$this->columns = $columns;
}
/**
* Register hooks for all columns on applicable post types.
*
* @return void
*/
public function register_hooks(): void {
$post_types = $this->get_applicable_post_types();
foreach ( $this->columns as $column ) {
foreach ( $post_types as $post_type ) {
$this->register_column_hooks( $column, $post_type );
}
}
}
/**
* Get post types that should have optimizer columns.
*
* @return string[]
*/
private function get_applicable_post_types(): array {
$post_types = get_post_types(
[
'show_ui' => true,
],
);
$post_types = array_filter( $post_types, static fn( $pt ): bool => is_post_type_viewable( $pt ) );
/**
* @param string[] $excluded_post_types Post type names to exclude.
*/
$excluded = apply_filters(
'kadence_blocks_excluded_optimizer_post_types',
[
'attachment',
'advanced_page',
'kadence_element',
'kt_reviews',
'kt_size_chart',
]
);
return array_diff( $post_types, $excluded );
}
/**
* Register WordPress hooks for a specific column and post type.
*
* @param Column_Registrar $column The column to register.
* @param string $post_type The post type name this will be applied to.
*
* @return void
*/
private function register_column_hooks( Column_Registrar $column, string $post_type ): void {
add_filter(
"manage_{$post_type}_posts_columns",
[ $column, 'add_header' ]
);
add_action(
"manage_{$post_type}_posts_custom_column",
[ $column, 'render' ],
10,
2
);
add_filter(
"manage_edit-{$post_type}_sortable_columns",
[ $column, 'mark_sortable' ]
);
add_action(
'pre_get_posts',
[ $column, 'sort' ],
);
}
}

View File

@@ -0,0 +1,117 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts\Renderable;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts\Sort_Strategy;
use WP_Query;
/**
* Registers Post List Table columns.
*/
final class Column_Registrar {
private Column $column;
private Renderable $renderable;
private ?Sort_Strategy $sort_strategy;
private bool $is_sortable;
public function __construct(
Column $column,
Renderable $renderable,
?Sort_Strategy $sort_strategy = null,
bool $is_sortable = false
) {
$this->column = $column;
$this->renderable = $renderable;
$this->sort_strategy = $sort_strategy;
$this->is_sortable = $is_sortable;
}
/**
* Return the underlying column object.
*
* @return Column
*/
public function column(): Column {
return $this->column;
}
/**
* Add the column header to the table.
*
* @filter manage_{$post_type}_posts_columns
*
* @param string[] $columns The existing columns.
*
* @return string[]
*/
public function add_header( array $columns ): array {
$columns[ $this->column->slug ] = $this->column->label;
return $columns;
}
/**
* Mark the column as sortable.
*
* @filter manage_{$this->screen->id}_sortable_columns
*
* @param array $columns
*
* @return array
*/
public function mark_sortable( array $columns ): array {
if ( ! $this->is_sortable ) {
return $columns;
}
/* translators: %s: The column label */
$ordered_by = sprintf( __( 'Table ordered by %s', 'kadence-blocks' ), $this->column->label );
$columns[ $this->column->slug ] = [
$this->column->slug,
true,
$this->column->label,
$ordered_by,
'asc',
];
return $columns;
}
/**
* Attempt to sort the column by its strategy.
*
* @action pre_get_posts
*
* @param WP_Query $query
*
* @return void
*/
public function sort( WP_Query $query ): void {
if ( ! $this->is_sortable || ! isset( $this->sort_strategy ) ) {
return;
}
$this->sort_strategy->sort( $query, $this->column );
}
/**
* Render the column value.
*
* @filter manage_{$post->post_type}_posts_custom_column
*
* @param string $slug
* @param int $post_id
*
* @return void
*/
public function render( string $slug, int $post_id ): void {
if ( $slug !== $this->column->slug ) {
return;
}
$this->renderable->render( $post_id );
}
}

View File

@@ -0,0 +1,15 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts;
interface Renderable {
/**
* Echo a column value in a post list table.
*
* @param int $post_id The post ID.
*
* @return void
*/
public function render( int $post_id ): void;
}

View File

@@ -0,0 +1,24 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Column;
use WP_Query;
/**
* The sort strategy to use for when a column is sorted.
*/
interface Sort_Strategy {
/**
* Modify WP_Query to sort by this column.
*
* @action pre_get_posts
*
* @param WP_Query $query
* @param Column $column
*
* @return void
*/
public function sort( WP_Query $query, Column $column ): void;
}

View File

@@ -0,0 +1,25 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts;
use WP_Post;
/**
* Sort a Post List Table by a custom table.
*
* @internal
*/
interface Table_Sorter {
/**
* Reorder the posts for the Post List Table before they returned.
*
* @filter the_posts
*
* @param string $order The sort order: ASC|DESC.
* @param WP_Post[] $posts The posts being listed.
*
* @return WP_Post[] The sorted posts, if applicable.
*/
public function sort( string $order, array $posts ): array;
}

View File

@@ -0,0 +1,109 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
use KadenceWP\KadenceBlocks\Optimizer\Status\Meta;
use KadenceWP\KadenceBlocks\Optimizer\Status\Status;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals;
/**
* Post List Table filter.
*/
final class Filter {
public const STATUS = 'kb_optimizer_status';
/**
* Add a filter dropdown to the Post List Table.
*
* @action restrict_manage_posts
*
* @param string $post_type The current post type being viewed.
*
* @return void
*/
public function render_filter( string $post_type ): void {
if ( ! is_post_type_viewable( $post_type ) ) {
return;
}
$current = SuperGlobals::get_get_var( self::STATUS, '' );
?>
<label for="kb-optimizer-filter" class="screen-reader-text"><?php echo esc_html__( 'Filter by Optimizer Status', 'kadence-blocks' ); ?></label>
<select name="<?php echo esc_attr( self::STATUS ); ?>" id="kb-optimizer-filter">
<option value="">
<?php echo esc_html__( 'All Optimizer Statuses', 'kadence-blocks' ); ?>
</option>
<option
value="<?php echo esc_attr( Status::STALE ); ?>"
<?php selected( $current, Status::STALE ); ?>>
<?php echo esc_html__( 'Outdated', 'kadence-blocks' ); ?>
</option>
<option
value="<?php echo esc_attr( Status::EXCLUDED ); ?>"
<?php selected( $current, Status::EXCLUDED ); ?>>
<?php echo esc_html__( 'Excluded', 'kadence-blocks' ); ?>
</option>
<option
value="<?php echo esc_attr( Status::OPTIMIZED ); ?>"
<?php selected( $current, Status::OPTIMIZED ); ?>>
<?php echo esc_html__( 'Optimized', 'kadence-blocks' ); ?>
</option>
<option
value="<?php echo esc_attr( Status::NOT_OPTIMIZED ); ?>"
<?php selected( $current, Status::NOT_OPTIMIZED ); ?>>
<?php echo esc_html__( 'Not Optimized', 'kadence-blocks' ); ?>
</option>
</select>
<?php
}
/**
* Filter posts on the Post List Table by their optimization status.
*
* @action pre_get_posts
*
* @param \WP_Query $query
*
* @return void
*/
public function filter_posts( \WP_Query $query ) {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
$status = SuperGlobals::get_get_var( self::STATUS );
if ( $status === null || $status === '' ) {
return;
}
// Filter by everything besides "not optimized".
$meta_query = [
[
'key' => Meta::KEY,
'compare' => '=',
'value' => $status,
'type' => 'NUMERIC',
],
];
if ( Status::NOT_OPTIMIZED === (int) $status ) {
$meta_query = [
'relation' => 'OR',
[
'key' => Meta::KEY,
'compare' => 'NOT EXISTS',
],
[
'key' => Meta::KEY,
'compare' => '=',
'value' => $status,
'type' => 'NUMERIC',
],
];
}
$query->set( 'meta_query', $meta_query );
}
}

View File

@@ -0,0 +1,115 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Renderers\Optimizer_Renderer;
use KadenceWP\KadenceBlocks\Optimizer\Status\Meta;
use KadenceWP\KadenceBlocks\Optimizer\Store\Cached_Store_Decorator;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Optimizer\Store\Table_Store;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public const OPTIMIZER_COLUMN = 'kadence_blocks.optimizer.optimizer_column';
public const OPTIMIZER_COLUMN_REGISTRAR = 'kadence_blocks.optimizer.optimizer_column_registrar';
public function register(): void {
$this->container->singleton( Optimizer_Renderer::class, Optimizer_Renderer::class );
// Register the renderer.
$this->container->when( Optimizer_Renderer::class )
->needs( Store::class )
->give(
function () {
$store = $this->container->get( Table_Store::class );
return new Cached_Store_Decorator( $store );
}
);
// Register the column.
$this->container->singleton(
self::OPTIMIZER_COLUMN,
static fn(): Column => new Column(
'kadence_optimizer',
__( 'Kadence Optimizer', 'kadence-blocks' ),
Meta::KEY
)
);
// Register the column registrar for this column.
$this->container->singleton(
self::OPTIMIZER_COLUMN_REGISTRAR,
function (): Column_Registrar {
$column = $this->container->get( self::OPTIMIZER_COLUMN );
$renderer = $this->container->get( Optimizer_Renderer::class );
return new Column_Registrar( $column, $renderer );
}
);
// Register the hook manager for all configured columns.
$this->container->singleton( Column_Hook_Manager::class, Column_Hook_Manager::class );
$this->container
->when( Column_Hook_Manager::class )
->needs( '$columns' )
->give(
fn(): array => [
$this->container->get( self::OPTIMIZER_COLUMN_REGISTRAR ),
]
);
// Add post list table columns.
add_action(
'admin_init',
$this->container->callback( Column_Hook_Manager::class, 'register_hooks' )
);
// Bulk action dropdown for bulk optimization/remove optimization functionality.
$this->container->singleton( Bulk_Action_Manager::class, Bulk_Action_Manager::class );
add_filter(
'bulk_actions-edit-post',
$this->container->callback( Bulk_Action_Manager::class, 'register_actions' )
);
add_filter(
'bulk_actions-edit-page',
$this->container->callback( Bulk_Action_Manager::class, 'register_actions' )
);
$this->container->singleton( Filter::class, Filter::class );
add_action(
'restrict_manage_posts',
$this->container->callback( Filter::class, 'render_filter' )
);
add_action(
'pre_get_posts',
$this->container->callback( Filter::class, 'filter_posts' )
);
$this->register_row_actions();
}
private function register_row_actions(): void {
$this->container->singleton( Row_Action::class, Row_Action::class );
add_filter(
'post_row_actions',
$this->container->callback( Row_Action::class, 'add_view_optimized_link' ),
10,
2
);
add_filter(
'page_row_actions',
$this->container->callback( Row_Action::class, 'add_view_optimized_link' ),
10,
2
);
}
}

View File

@@ -0,0 +1,158 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Renderers;
use KadenceWP\KadenceBlocks\Optimizer\Nonce\Nonce;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts\Renderable;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use KadenceWP\KadenceBlocks\Optimizer\Status\Status;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Optimizer\Translation\Text_Repository;
use KadenceWP\KadenceBlocks\Traits\Permalink_Trait;
final class Optimizer_Renderer implements Renderable {
use Permalink_Trait;
private const ACTION_CLASS = 'action_class';
private const ACTION_TEXT = 'action_text';
private const STATUS_TEXT = 'status_text';
private Store $store;
private Text_Repository $text_repository;
private Nonce $nonce;
private Status $status;
public function __construct(
Store $store,
Text_Repository $text_repository,
Nonce $nonce,
Status $status
) {
$this->store = $store;
$this->text_repository = $text_repository;
$this->nonce = $nonce;
$this->status = $status;
}
/**
* Display a link to run/remove optimization data if the current user has the correct
* permissions.
*
* @param int $post_id The post ID.
*
* @return void
*/
public function render( int $post_id ): void {
if ( $this->status->is_excluded( $post_id ) ) {
echo esc_html( $this->text_repository->get( Text_Repository::EXCLUDED ) );
return;
}
if ( ! $this->is_post_optimizable( $post_id ) ) {
echo esc_html( $this->text_repository->get( Text_Repository::NOT_OPTIMIZABLE ) );
return;
}
$post_path = $this->get_post_path( $post_id );
$path = new Path( $post_path, $post_id );
$analysis = $this->store->get( $path );
$render_data = $this->get_render_data( $analysis );
// User can't perform an action, just show them the status.
if ( ! $this->user_can_manage_optimization( $post_id, $analysis ) ) {
echo esc_html( $render_data[ self::STATUS_TEXT ] );
return;
}
$this->render_action_link( $post_id, $render_data );
}
/**
* Check if the post can be optimized.
*
* @param int $post_id The post ID.
*
* @return bool
*/
private function is_post_optimizable( int $post_id ): bool {
$post_url = get_permalink( $post_id );
$status = get_post_status( $post_id );
return $post_url && ! is_wp_error( $post_url ) && $status === 'publish';
}
/**
* Get the render data for the optimization action.
*
* @param WebsiteAnalysis|null $analysis The optimization analysis data.
*
* @return array{action_class: string, action_text: string, status_text: string}
*/
private function get_render_data( ?WebsiteAnalysis $analysis ): array {
if ( ! $analysis ) {
return [
self::ACTION_CLASS => 'kb-optimize-post',
self::ACTION_TEXT => $this->text_repository->get( Text_Repository::RUN_OPTIMIZER ),
self::STATUS_TEXT => $this->text_repository->get( Text_Repository::NOT_OPTIMIZED ),
];
}
if ( $analysis->isStale ) {
return [
self::ACTION_CLASS => 'kb-optimize-post',
self::ACTION_TEXT => $this->text_repository->get( Text_Repository::OPTIMIZATION_OUTDATED_RUN ),
self::STATUS_TEXT => $this->text_repository->get( Text_Repository::OPTIMIZATION_OUTDATED ),
];
}
return [
self::ACTION_CLASS => 'kb-remove-post-optimization',
self::ACTION_TEXT => $this->text_repository->get( Text_Repository::REMOVE_OPTIMIZATION ),
self::STATUS_TEXT => $this->text_repository->get( Text_Repository::OPTIMIZED ),
];
}
/**
* Check if the current user can manage optimization for this post.
*
* @param int $post_id The post ID.
* @param WebsiteAnalysis|null $analysis The optimization analysis data.
*
* @return bool
*/
private function user_can_manage_optimization( int $post_id, ?WebsiteAnalysis $analysis ): bool {
return $analysis
? current_user_can( 'delete_post', $post_id )
: current_user_can( 'edit_post', $post_id );
}
/**
* Render the action link for optimization management.
*
* @param int $post_id The post ID.
* @param array $render_data The render data.
*
* @return void
*/
private function render_action_link( int $post_id, array $render_data ): void {
$post_url = get_permalink( $post_id );
$post_path = $this->get_post_path( $post_id );
printf(
'<a href="#" class="%s" data-post-id="%d" data-post-url="%s" data-post-path="%s" data-nonce="%s">%s</a>',
esc_attr( $render_data[ self::ACTION_CLASS ] ),
esc_attr( $post_id ),
esc_attr( esc_url( $post_url ) ),
esc_attr( $post_path ),
esc_attr( $this->nonce->create() ),
esc_html( $render_data[ self::ACTION_TEXT ] )
);
}
}

View File

@@ -0,0 +1,78 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Optimizer\Nonce\Nonce;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Request\Request;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Traits\Permalink_Trait;
use WP_Post;
final class Row_Action {
use Permalink_Trait;
public const ACTION = 'kb_optimizer_view_optimized';
private Nonce $nonce;
private Store $store;
public function __construct(
Nonce $nonce,
Store $store
) {
$this->nonce = $nonce;
$this->store = $store;
}
/**
* Add a "View Optimized" link to the post/page row actions.
*
* @filter post_row_actions
* @filter page_row_actions
*
* @param string[] $actions An array of row actions links.
* @param WP_Post $post The post object.
*
* @return string[]
*/
public function add_view_optimized_link( array $actions, WP_Post $post ): array {
$permalink = get_permalink( $post->ID );
if ( ! $permalink ) {
return $actions;
}
$post_path = $this->get_post_path( $post->ID );
try {
$path = new Path( $post_path, $post->ID );
} catch ( InvalidArgumentException $e ) {
return $actions;
}
$analysis = $this->store->get( $path );
if ( ! $analysis || $analysis->isStale ) {
return $actions;
}
$url = add_query_arg(
[
Request::QUERY_TOKEN => $this->nonce->create(),
Request::QUERY_OPTIMIZER_PREVIEW => 1,
],
$permalink
);
$actions[ self::ACTION ] = sprintf(
'<a href="%s" target="_blank">%s</a>',
esc_url( $url ),
esc_html__( 'View Optimized', 'kadence-blocks' )
);
return $actions;
}
}

View File

@@ -0,0 +1,76 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Sorters;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Column;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts\Sort_Strategy;
use WP_Query;
/**
* Sort by a meta key, including if the key does not or does exist.
*/
final class Meta_Sort_Exists implements Sort_Strategy {
/**
* @var string[]
*/
private array $secondary_order_fields;
/**
* @param string[] $secondary_order_fields The field names to order by after the meta value.
*/
public function __construct(
array $secondary_order_fields = [
'title',
]
) {
$this->secondary_order_fields = $secondary_order_fields;
}
/**
* Sort a column by whether a meta key exists or not.
*
* @filter pre_get_posts
*
* @param WP_Query $query
* @param Column $column
*
* @return void
*/
public function sort( WP_Query $query, Column $column ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->get( 'orderby' ) !== $column->slug ) {
return;
}
// Ensure we don't filter out all posts that don't have the value.
$query->set(
'meta_query',
[
'relation' => 'OR',
[
'key' => $column->meta_key,
'compare' => 'NOT EXISTS',
],
[
'key' => $column->meta_key,
'compare' => 'EXISTS',
],
]
);
// Order by meta_value and tack on any secondary fields.
$query->set(
'orderby',
trim(
sprintf(
'meta_value %s',
implode( ' ', $this->secondary_order_fields )
)
)
);
}
}

View File

@@ -0,0 +1,69 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Sorters;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Column;
use KadenceWP\KadenceBlocks\Optimizer\Post_List_Table\Contracts\Sort_Strategy;
use KadenceWP\KadenceBlocks\Optimizer\Status\Status;
use WP_Query;
/**
* Custom column sorting strategy for the Kadence Optimizer.
*/
final class Optimizer_Sorter implements Sort_Strategy {
/**
* Custom column sorter for the Kadence Optimizer.
*
* Uses integer status values that sort naturally:
* - ASC: Excluded (-1) > Not Optimized (0) > Optimized (1) > Stale (2)
* - DESC: Stale (2) > Optimized (1) > Not Optimized (0) > Excluded (-1)
*
* @filter pre_get_posts
*
* @see Status
*
* @param WP_Query $query
* @param Column $column
*
* @return void
*/
public function sort( WP_Query $query, Column $column ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->get( 'orderby' ) !== $column->slug ) {
return;
}
$order = strtoupper( (string) $query->get( 'order' ) ?: 'DESC' );
// Only published posts can be worked with, so hide the rest when sorting.
$query->set( 'post_status', 'publish' );
add_filter(
'posts_clauses',
static function ( array $clauses ) use ( $order, $column ): array {
global $wpdb;
// Join optimizer status meta - single meta key for all states.
$clauses['join'] .= $wpdb->prepare(
" LEFT JOIN $wpdb->postmeta AS pm_optimizer_status
ON ($wpdb->posts.ID = pm_optimizer_status.post_id AND pm_optimizer_status.meta_key = %s)",
$column->meta_key
);
// Sort by status value (defaults to 0 for unoptimized posts).
$clauses['orderby'] = sprintf(
'CAST(IFNULL(pm_optimizer_status.meta_value, 0) AS SIGNED) %s',
$order
);
$clauses['distinct'] = 'DISTINCT';
return $clauses;
}
);
}
}

View File

@@ -0,0 +1,247 @@
# Kadence Performance Optimizer
A client-side analysis and server-side optimization system that improves Core Web Vitals (LCP, CLS, INP) by intelligently managing image loading, section rendering, and resource hints.
## Overview
The Optimizer works in two phases:
1. **Analysis Phase** (client-side): When a user triggers optimization in wp-admin, JavaScript analyzes the page using `@stellarwp/perf-analyzer-client` to detect critical images, section heights, and optimal image sizes for both desktop and mobile viewports.
2. **Application Phase** (server-side): On subsequent requests, the stored analysis data is used to apply optimizations like proper lazy loading, `content-visibility` for below-the-fold sections, and optimal image `sizes` attributes.
## Architecture
```
Optimizer/
├── Database/ # Custom tables (kb_optimizer, kb_optimizer_viewport_hash)
├── Store/ # Decorator-based storage layer
├── Hash/ # HTML hash comparison for invalidation
├── Image/ # Image processing pipeline
├── Lazy_Load/ # Element & background lazy loading
├── Skip_Rules/ # Conditions to bypass optimization
├── Rest/ # REST API endpoints
├── Response/ # DTOs (WebsiteAnalysis, DeviceAnalysis, ImageAnalysis, etc.)
├── Status/ # Post optimization status tracking
├── Post_List_Table/ # Admin column & bulk actions
└── assets/js/ # Frontend analyzer scripts
```
### Key Design Patterns
- **Provider Pattern**: Each subdomain has a `Provider.php` that registers dependencies
- **Decorator Pattern**: Store layer uses decorators for caching, expiration, exclusion, and status sync
- **Strategy Pattern**: Image processors and skip rules are pluggable
## Enabling the Optimizer
Users enable the optimizer through the **Kadence Blocks Controls** sidebar in the Gutenberg editor:
1. Open any post/page in the block editor
2. Click the Kadence "K" icon in the top toolbar to open **Kadence Blocks Controls**
3. Expand the **Performance Optimizer** panel
4. Toggle **"Globally Enable The Performance Optimizer"**
This saves the `performance_optimizer_enabled` setting to `kadence_blocks_settings` option.
## Store Decorator Chain
The `Store` interface is wrapped in decorators that execute top-to-bottom:
```
Expired_Store_Decorator ← Filters stale data (isStale = true)
└─ Cached_Store_Decorator ← Request-level memoization
└─ Excluded_Store_Decorator ← Blocks excluded posts
└─ Status_Sync_Store_Decorator ← Syncs post meta status
└─ Table_Store ← Database operations
```
This means:
- Expired data is filtered even from cache
- Excluded posts never receive optimization data
- Post meta status stays in sync with database state
## REST API Endpoints
Base: `/wp-json/kb-optimizer/v1/optimize`
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/optimize` | Store analysis data for a post |
| GET | `/optimize` | Retrieve analysis data |
| DELETE | `/optimize` | Remove optimization for a post |
| POST | `/optimize/posts-metadata` | Get metadata for bulk optimization |
| DELETE | `/optimize/bulk/delete` | Bulk remove optimizations |
All endpoints require `edit_post` capability for the target post(s).
## Skip Rules
Optimization is bypassed when any skip rule returns `true`:
| Rule | Condition |
|------|-----------|
| `Optimizer_Request_Rule` | Request has optimizer query params |
| `Ignored_Query_Var_Rule` | Has preview or filtered query vars |
| `Not_Found_Rule` | 404 response |
| `Logged_In_Rule` | User is logged in |
| `Post_Excluded_Rule` | Post marked as excluded |
### Adding Ignored Query Variables
The `Ignored_Query_Var_Rule` skips optimization when specific query parameters are present. Add custom query vars to ignore:
```php
add_filter( 'kadence_blocks_optimizer_rule_skip_query_vars', static fn( array $vars ): array => [
...$vars,
'my_custom_preview',
]);
```
## Image Processing
The `Image_Processor` uses output buffering to modify `<img>` tags:
1. **Lazy_Load_Processor**: Removes `loading="lazy"` from critical (above-fold) images, adds it to below-fold images
2. **Sizes_Attribute_Processor**: Sets optimal `sizes` attribute based on actual rendered dimensions
### Filtering Image Processing
```php
add_filter(
'kadence_blocks_optimizer_image_processor',
static fn( bool $should, string $src ): bool => $should && ! str_contains( $src, 'logo.png' ),
10,
5
);
```
## Section Lazy Loading
Below-the-fold Kadence Row and Column blocks receive `content-visibility: auto` with calculated `contain-intrinsic-size`:
```php
// Exclude specific classes from section lazy loading.
add_filter( 'kadence_blocks_optimizer_section_lazy_load_excluded_classes', static fn( array $classes ): array => [
...$classes,
'my-parallax-section',
]);
```
**Note**: `kt-jarallax` is excluded by default since parallax sections need immediate rendering.
## Hash-Based Invalidation
The system compares HTML hashes on each request to detect content changes:
1. After optimization, hashes are stored for desktop and mobile viewports
2. On subsequent requests, current HTML is hashed and compared
3. If hashes differ, analysis is marked `isStale = true`
4. Stale data is filtered by `Expired_Store_Decorator`, forcing re-optimization
### Triggering Hash Storage
```
?kadence_set_optimizer_hash=1 # Desktop hash
?kadence_set_optimizer_hash=1&kadence_is_mobile=1 # Mobile hash
```
## Post Status Values
Status is stored in post meta (`_kb_optimizer_status`):
| Value | Constant | Description |
|-------|----------|-------------|
| -1 | `EXCLUDED` | User excluded from optimization |
| 0 | `NOT_OPTIMIZED` | No optimization data |
| 1 | `OPTIMIZED` | Active optimization |
| 2 | `STALE` | Outdated, pending re-optimization |
## Actions & Filters Reference
### Actions
```php
// Fired after hash is stored.
do_action( 'kadence_blocks_optimizer_set_hash', $hash, $path, $viewport );
// Fired when optimization data is invalidated.
do_action( 'kadence_blocks_optimizer_data_invalidated', $is_stale, $path );
// Fired after hash check completes.
do_action( 'kadence_blocks_hash_check_complete' );
```
### Filters
```php
// Force enable/disable the entire Optimizer feature, overidding the user's setting.
// Force enable.
add_filter( 'kadence_blocks_optimizer_enabled', '__return_true' );
// Force disable.
add_filter( 'kadence_blocks_optimizer_enabled', '__return_false' );
// Customize skip query vars/
add_filter( 'kadence_blocks_optimizer_rule_skip_query_vars', fn( array $vars ) => $vars );
// Exclude sections from lazy loading.
add_filter( 'kadence_blocks_optimizer_section_lazy_load_excluded_classes', fn( array $classes ) => $classes );
// Control image processing per-image.
add_filter( 'kadence_blocks_optimizer_image_processor', fn( bool $should, $src, $classes, $processor, $path ) => $should, 10, 5 );
```
## Debugging
### Enable Debug Logging
Set `WP_DEBUG` to `true` in `wp-config.php` to enable comprehensive logging via PHP's `error_log()`:
```php
define( 'WP_DEBUG', true );
```
Logs are prefixed with `[Kadence Blocks]:` and written to your PHP error log (location depends on server configuration).
### Check if a page is optimized
Look for the `kb-optimized` class on the `<body>` tag.
### View optimization data
```php
$store = KadenceWP\KadenceBlocks\Container::get(
\KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store::class
);
$path = new \KadenceWP\KadenceBlocks\Optimizer\Path\Path( '/my-page/', $post_id );
$analysis = $store->get( $path );
```
### Preview optimized view
Add `?kadence_optimizer_preview=1` to any URL to see the optimized version regardless of skip rules (logged-in users, etc.).
## Common Issues
### Optimization not applying
1. Check skip rules - logged-in users are skipped by default
2. Verify post is published (drafts can't be optimized)
3. Check if post is excluded (`Status::EXCLUDED`)
4. Look for `isStale = true` in stored analysis
### Content-visibility causing layout shifts
Exclude problematic sections via the filter:
```php
add_filter( 'kadence_blocks_optimizer_section_lazy_load_excluded_classes', static fn( array $classes ): array => [
...$classes,
'my-problematic-section',
]);
```
### Images loading incorrectly
The system uses a queue-based approach for critical images. If the same image appears multiple times, only the first N occurrences (matching the critical images count) are treated as critical.

View File

@@ -0,0 +1,29 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Request;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->register_request();
$this->register_request_anonymizer();
}
private function register_request(): void {
$this->container->singleton( Request::class, Request::class );
}
private function register_request_anonymizer(): void {
$this->container->singleton( Request_Anonymizer::class, Request_Anonymizer::class );
add_action(
'plugins_loaded',
function () {
$this->container->get( Request_Anonymizer::class )->force_anonymous_request();
},
2
);
}
}

View File

@@ -0,0 +1,36 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Request;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
final class Request {
public const QUERY_NOCACHE = 'nocache';
public const QUERY_TOKEN = 'perf_token';
public const QUERY_OPTIMIZER_PREVIEW = 'kb_optimizer_preview';
public const MIN_LENGTH = 5;
/**
* Check if this request is an optimizer request.
*
* @return bool
*/
public function is_optimizer_request(): bool {
$no_cache = SG::get_get_var( self::QUERY_NOCACHE );
$token = SG::get_get_var( self::QUERY_TOKEN );
return $this->is_valid_param( $no_cache ) && $this->is_valid_param( $token );
}
/**
* Validate a request parameter: must be an alphanumeric string and a minimum length.
*
* @param mixed $value The query parameter value.
*
* @return bool
*/
private function is_valid_param( $value ): bool {
return is_string( $value ) && strlen( $value ) >= self::MIN_LENGTH && ctype_alnum( $value );
}
}

View File

@@ -0,0 +1,51 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Request;
use KadenceWP\KadenceBlocks\Optimizer\Nonce\Nonce;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
/**
* Handles the anonymization of authenticated requests for performance optimization.
*
* This class detects optimizer requests using a nonce-based query parameter and
* forces them to run as anonymous (logged-out) requests. This ensures that
* performance testing through iframes captures the public-facing version of
* pages without any user-specific customizations or cached content.
*/
final class Request_Anonymizer {
private Nonce $nonce;
public function __construct( Nonce $nonce ) {
$this->nonce = $nonce;
}
/**
* Detect if this is an optimizer request, and log the user out for that request so
* we're loading the public version of the URL through the iframe.
*
* @filter plugins_loaded
*
* @return void
*/
public function force_anonymous_request(): void {
$nonce = SG::get_get_var( Request::QUERY_TOKEN );
if ( ! $nonce ) {
return;
}
// If using a browser that supports IFrame credentialless, they will be logged out already.
if ( ! is_user_logged_in() ) {
return;
}
if ( ! $this->nonce->verify( $nonce ) ) {
wp_die( 'Invalid optimizer nonce.', 403 );
}
// Override the current user for the remainder of this request.
wp_set_current_user( 0 );
}
}

View File

@@ -0,0 +1,54 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Resource_Hints;
use KadenceWP\KadenceBlocks\Optimizer\Analysis_Registry;
/**
* Adds the following to the frontend head section, to allow faster loading
* Google Fonts:
*
* <link rel="preconnect" href="https://fonts.googleapis.com">
* <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
*/
final class Google_Font_Preconnector {
public const PRECONNECT = 'preconnect';
private Analysis_Registry $registry;
public function __construct(
Analysis_Registry $registry
) {
$this->registry = $registry;
}
/**
* Add preconnect links for Google Fonts. We actually have no way of knowing that Kadence is using
* them as the style enqueuing happens later in the execution.
*
* @filter wp_resource_hints
*
* @param array<array|string> $urls Array of resources and their attributes, or URLs to print for resource hints.
* @param string $relation_type The relation type the URLs are printed for. One of 'dns-prefetch', 'preconnect', 'prefetch', or 'prerender'.
*
* @return array<array|string>
*/
public function preconnect( array $urls, string $relation_type ): array {
if ( ! $this->registry->is_optimized() ) {
return $urls;
}
if ( self::PRECONNECT !== $relation_type ) {
return $urls;
}
$urls[] = 'https://fonts.googleapis.com';
$urls[] = [
'href' => 'https://fonts.gstatic.com',
true => 'crossorigin',
];
return $urls;
}
}

View File

@@ -0,0 +1,30 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Resource_Hints;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
/**
* Allow resource hints to be disabled.
*
* @param bool $enabled Whether this feature is enabled.
*/
$enabled = (bool) apply_filters( 'kadence_blocks_enable_resource_hits', true );
if ( ! $enabled ) {
return;
}
$this->container->singleton( Google_Font_Preconnector::class, Google_Font_Preconnector::class );
add_filter(
'wp_resource_hints',
$this->container->callback( Google_Font_Preconnector::class, 'preconnect' ),
10,
2
);
}
}

View File

@@ -0,0 +1,53 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
/**
* Data Transfer Object representing CSS computed style properties for an element.
*
* Contains the computed CSS values for width, height, object-fit, and object-position
* properties as extracted from the browser's computed styles.
*/
final class ComputedStyle {
public string $width;
public string $height;
public string $objectFit;
public string $objectPosition;
private function __construct(
string $width,
string $height,
string $objectFit,
string $objectPosition
) {
$this->width = $width;
$this->height = $height;
$this->objectFit = $objectFit;
$this->objectPosition = $objectPosition;
}
/**
* @param array{width: string, height: string, objectFit: string, objectPosition: string} $attributes
*/
public static function from( array $attributes ): self {
return new self(
$attributes['width'],
$attributes['height'],
$attributes['objectFit'],
$attributes['objectPosition']
);
}
/**
* @return array{width: string, height: string, objectFit: string, objectPosition: string}
*/
public function toArray(): array {
return [
'width' => $this->width,
'height' => $this->height,
'objectFit' => $this->objectFit,
'objectPosition' => $this->objectPosition,
];
}
}

View File

@@ -0,0 +1,87 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
/**
* Data Transfer Object representing analysis data for a specific device viewport.
*
* Contains device-specific optimization data including critical image counts,
* background images, and page sections with their layout information.
*/
final class DeviceAnalysis {
/**
* A list of image src URLs found above the fold.
*
* @var string[]
*/
public array $criticalImages;
/**
* @var string[]
*/
public array $backgroundImages;
/**
* @var Section[]
*/
public array $sections;
/**
* @param string[] $criticalImages A list of image src URLs found above the fold.
* @param string[] $backgroundImages The list of URLs of background images found.
* @param Section[] $sections The collection of sections.
*/
private function __construct(
array $criticalImages = [],
array $backgroundImages = [],
array $sections = []
) {
$this->criticalImages = $criticalImages;
$this->backgroundImages = $backgroundImages;
$this->sections = $sections;
}
/**
* @param array{criticalImages: string[], backgroundImages?: string[], sections?: array} $attributes
*/
public static function from( array $attributes ): self {
$sections = array_map(
static function ( array $result ): Section {
return Section::from( $result );
},
$attributes['sections'] ?? []
);
return new self(
$attributes['criticalImages'],
$attributes['backgroundImages'] ?? [],
$sections
);
}
/**
* @return array{criticalImages: string[], backgroundImages?: string[], sections?: array}
*/
public function toArray(): array {
return [
'criticalImages' => $this->criticalImages,
'backgroundImages' => $this->backgroundImages,
'sections' => array_map(
static function ( Section $section ): array {
return $section->toArray();
},
$this->sections
),
];
}
/**
* Get all the sections that are below the fold.
*
* @return Section[]
*/
public function getBelowTheFoldSections(): array {
return array_filter( $this->sections, static fn( Section $section ): bool => ! $section->isAboveFold );
}
}

View File

@@ -0,0 +1,137 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
/**
* Data Transfer Object representing comprehensive analysis data for an image element.
*
* Contains detailed information about an image including dimensions, loading attributes,
* computed styles, srcset entries, and optimization recommendations such as optimal sizes.
*/
final class ImageAnalysis {
public string $path;
public string $src;
/**
* @var SrcsetEntry[]
*/
public array $srcset;
public int $width;
public int $height;
public string $widthAttr;
public string $heightAttr;
public int $naturalWidth;
public int $naturalHeight;
public ?float $aspectRatio;
public ?string $alt;
public string $className;
public string $loading;
public string $decoding;
public ?string $sizes;
public ComputedStyle $computedStyle;
public string $optimalSizes;
/**
* @param SrcsetEntry[] $srcset The srcset entry.
*/
private function __construct(
string $path,
string $src,
array $srcset,
int $width,
int $height,
string $widthAttr,
string $heightAttr,
int $naturalWidth,
int $naturalHeight,
?float $aspectRatio,
?string $alt,
string $className,
string $loading,
string $decoding,
?string $sizes,
ComputedStyle $computedStyle,
string $optimalSizes
) {
$this->path = $path;
$this->src = $src;
$this->srcset = $srcset;
$this->width = $width;
$this->height = $height;
$this->widthAttr = $widthAttr;
$this->heightAttr = $heightAttr;
$this->naturalWidth = $naturalWidth;
$this->naturalHeight = $naturalHeight;
$this->aspectRatio = $aspectRatio;
$this->alt = $alt;
$this->className = $className;
$this->loading = $loading;
$this->decoding = $decoding;
$this->sizes = $sizes;
$this->computedStyle = $computedStyle;
$this->optimalSizes = $optimalSizes;
}
/**
* @param array{path: string, src: string, srcset: array, width: int, height: int, widthAttr: string, heightAttr: string, naturalWidth: int, naturalHeight: int, aspectRatio: float, alt: ?string, class: ?string, loading: string, decoding: string, sizes: ?string, computedStyle: array, optimalSizes: string} $attributes
*/
public static function from( array $attributes ): self {
$srcset = array_map(
static function ( array $entry ): SrcsetEntry {
return SrcsetEntry::from( $entry );
},
$attributes['srcset'] ?? []
);
return new self(
$attributes['path'] ?? '',
$attributes['src'] ?? '',
$srcset,
$attributes['width'] ?? 0,
$attributes['height'] ?? 0,
$attributes['widthAttr'] ?? '',
$attributes['heightAttr'] ?? '',
$attributes['naturalWidth'] ?? 0,
$attributes['naturalHeight'] ?? 0,
$attributes['aspectRatio'] ?? null,
$attributes['alt'],
$attributes['class'] ?? '',
$attributes['loading'],
$attributes['decoding'],
$attributes['sizes'],
ComputedStyle::from( $attributes['computedStyle'] ),
$attributes['optimalSizes']
);
}
/**
* @return array{path: string, src: string, srcset: array, width: int, height: int, widthAttr: string, heightAttr: string, naturalWidth: int, naturalHeight: int, aspectRatio: float, alt: ?string, class: string, loading: string, decoding: string, sizes: ?string, computedStyle: array, optimalSizes: string}
*/
public function toArray(): array {
return [
'path' => $this->path,
'src' => $this->src,
'srcset' => array_map(
static function ( SrcsetEntry $entry ): array {
return $entry->toArray();
},
$this->srcset
),
'width' => $this->width,
'height' => $this->height,
'widthAttr' => $this->widthAttr,
'heightAttr' => $this->heightAttr,
'naturalWidth' => $this->naturalWidth,
'naturalHeight' => $this->naturalHeight,
'aspectRatio' => $this->aspectRatio,
'alt' => $this->alt,
'class' => $this->className,
'loading' => $this->loading,
'decoding' => $this->decoding,
'sizes' => $this->sizes,
'computedStyle' => $this->computedStyle->toArray(),
'optimalSizes' => $this->optimalSizes,
];
}
}

View File

@@ -0,0 +1,73 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
/**
* Data Transfer Object representing a page section or element with layout information.
*
* Contains section metadata including dimensions, DOM path, fold position,
* and content flags for images and background elements.
*/
final class Section {
public string $id;
public float $height;
public string $tagName;
public string $className;
public string $path;
public bool $isAboveFold;
public bool $hasImages;
public bool $hasBackground;
private function __construct(
string $id,
float $height,
string $tagName,
string $className,
string $path,
bool $isAboveFold,
bool $hasImages,
bool $hasBackground
) {
$this->id = $id;
$this->height = $height;
$this->tagName = $tagName;
$this->className = $className;
$this->path = $path;
$this->isAboveFold = $isAboveFold;
$this->hasImages = $hasImages;
$this->hasBackground = $hasBackground;
}
/**
* @param array{id: string, height: float, tagName: string, className: string, path: string, isAboveFold: bool, hasImages: bool, hasBackground: bool} $attributes
*/
public static function from( array $attributes ): self {
return new self(
$attributes['id'],
$attributes['height'],
$attributes['tagName'],
$attributes['className'],
$attributes['path'],
$attributes['isAboveFold'],
$attributes['hasImages'],
$attributes['hasBackground']
);
}
/**
* @return array{id: string, height: float, tagName: string, className: string, path: string, isAboveFold: bool, hasImages: bool, hasBackground: bool}
*/
public function toArray(): array {
return [
'id' => $this->id,
'height' => $this->height,
'tagName' => $this->tagName,
'className' => $this->className,
'path' => $this->path,
'isAboveFold' => $this->isAboveFold,
'hasImages' => $this->hasImages,
'hasBackground' => $this->hasBackground,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
/**
* Data Transfer Object representing a single entry in an image srcset attribute.
*
* Contains the URL and width descriptor for a responsive image source,
* used to build complete srcset attributes for optimized image delivery.
*/
final class SrcsetEntry {
public string $url;
public int $width;
private function __construct( string $url, int $width ) {
$this->url = $url;
$this->width = $width;
}
/**
* @param array{url: string, width: int} $attributes
*/
public static function from( array $attributes ): self {
return new self(
$attributes['url'],
$attributes['width']
);
}
/**
* @return array{url: string, width: int}
*/
public function toArray(): array {
return [
'url' => $this->url,
'width' => $this->width,
];
}
}

View File

@@ -0,0 +1,89 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Response;
use KadenceWP\KadenceBlocks\Optimizer\Enums\Viewport;
/**
* Data Transfer Object representing comprehensive website performance analysis.
*
* Contains the complete optimization analysis including device-specific data
* for desktop and mobile viewports, plus detailed analysis for all images found.
*/
final class WebsiteAnalysis {
public bool $isStale;
public DeviceAnalysis $desktop;
public DeviceAnalysis $mobile;
/**
* @var ImageAnalysis[]
*/
public array $images;
/**
* @param ImageAnalysis[] $images The image analysis collection.
*/
private function __construct(
bool $isStale,
DeviceAnalysis $desktop,
DeviceAnalysis $mobile,
array $images = []
) {
$this->isStale = $isStale;
$this->desktop = $desktop;
$this->mobile = $mobile;
$this->images = $images;
}
/**
* @param array{isStale?: bool, desktop: array, mobile: array, images: array} $attributes
*
* @throws \Exception If we fail to create a DateTimeImmutable object.
*/
public static function from( array $attributes ): self {
$images = array_map(
static function ( array $result ): ImageAnalysis {
return ImageAnalysis::from( $result );
},
$attributes['images'] ?? []
);
$isStale = (bool) ( $attributes['isStale'] ?? false );
return new self(
$isStale,
DeviceAnalysis::from( $attributes['desktop'] ),
DeviceAnalysis::from( $attributes['mobile'] ),
$images
);
}
/**
* @return array{isStale: bool, desktop: array, mobile: array, images: array}
*/
public function toArray(): array {
return [
'isStale' => $this->isStale,
'desktop' => $this->desktop->toArray(),
'mobile' => $this->mobile->toArray(),
'images' => array_map(
static function ( ImageAnalysis $image ): array {
return $image->toArray();
},
$this->images
),
];
}
/**
* Get the device analysis for a specific viewport.
*
* @param Viewport $vp The viewport to fetch data for.
*
* @return DeviceAnalysis|null
*/
public function getDevice( Viewport $vp ): ?DeviceAnalysis {
return $this->{$vp} ?? null;
}
}

View File

@@ -0,0 +1,687 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Rest;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use KadenceWP\KadenceBlocks\Optimizer\Status\Status;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Traits\Permalink_Trait;
use WP_Error;
use WP_Http;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* REST API controller for managing Kadence Blocks optimizer data.
*
* This controller provides endpoints for storing, retrieving, and deleting
* performance optimization analysis data for posts. It handles website analysis
* results that can be used to optimize page performance.
*
* Endpoints:
* - POST /kb-optimizer/v1/optimize - Store optimizer data for a post
* - GET /kb-optimizer/v1/optimize - Retrieve optimizer data for a post
* - DELETE /kb-optimizer/v1/optimize - Delete optimizer data for a post
* - POST /kb-optimizer/v1/optimize/posts-metadata - Get metadata for multiple posts
* - DELETE /kb-optimizer/v1/optimize/bulk/delete - Delete optimizer data for multiple posts
*
* All endpoints require the user to have edit permissions for the specified post.
*/
final class Optimize_Rest_Controller extends WP_REST_Controller {
use Permalink_Trait;
public const ROUTE = '/kb-optimizer/v1/optimize';
public const POST_PATH = 'post_path';
public const POST_ID = 'post_id';
public const POST_IDS = 'post_ids';
public const POST_PATHS = 'post_paths';
public const RESULTS = 'results';
private Store $store;
private Schema $schema_loader;
private Status $status;
public function __construct(
Store $store,
Schema $schema_loader,
Status $status
) {
$this->namespace = 'kb-optimizer/v1';
$this->rest_base = 'optimize';
$this->store = $store;
$this->schema_loader = $schema_loader;
$this->status = $status;
}
public function register_routes(): void {
register_rest_route(
$this->namespace,
"/$this->rest_base",
[
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->get_create_params(),
],
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_item' ],
'permission_callback' => [ $this, 'get_item_permissions_check' ],
'args' => $this->get_default_params(),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'delete_item_permissions_check' ],
'args' => $this->get_default_params(),
],
'schema' => [ $this->schema_loader, 'get_item_schema' ],
]
);
register_rest_route(
$this->namespace,
"/$this->rest_base/posts-metadata",
[
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_posts_metadata' ],
'permission_callback' => [ $this, 'get_posts_metadata_permissions_check' ],
'args' => $this->get_posts_metadata_params(),
],
]
);
register_rest_route(
$this->namespace,
"/$this->rest_base/bulk/delete",
[
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'bulk_delete_items' ],
'permission_callback' => [ $this, 'bulk_delete_items_permissions_check' ],
'args' => $this->get_bulk_delete_params(),
],
]
);
}
/**
* Save optimizer data for a post.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_item( $request ) {
$post_id = $request->get_param( self::POST_ID );
$post_path = $request->get_param( self::POST_PATH );
$results = $request->get_param( self::RESULTS );
$analysis = WebsiteAnalysis::from( $results );
if ( $this->status->is_excluded( $post_id ) ) {
return new WP_Error(
'rest_kb_optimizer_create_failed_excluded',
__( 'This post is excluded from optimization.', 'kadence-blocks' ),
[
'status' => WP_Http::INTERNAL_SERVER_ERROR,
'post_id' => $post_id,
'post_path' => $post_path,
]
);
}
if ( get_post_status( $post_id ) !== 'publish' ) {
return new WP_Error(
'rest_kb_optimizer_create_failed_not_published',
__( 'The post must be published to be optimized.', 'kadence-blocks' ),
[
'status' => WP_Http::INTERNAL_SERVER_ERROR,
'post_id' => $post_id,
'post_path' => $post_path,
]
);
}
$path = new Path( $post_path, $post_id );
if ( $this->store->set( $path, $analysis ) ) {
$ref = wp_get_referer();
// If we're coming from the edit post page, don't double update the post.
if ( $ref && ! str_contains( $ref, 'post.php' ) ) {
$this->update_post( $post_id );
}
return new WP_REST_Response(
[
'data' => [
'success' => true,
'post_id' => $post_id,
'post_path' => $post_path,
],
],
WP_Http::CREATED
);
}
return new WP_Error(
'rest_kb_optimizer_create_failed',
__( 'Failed to store optimizer data.', 'kadence-blocks' ),
[
'status' => WP_Http::INTERNAL_SERVER_ERROR,
'post_id' => $post_id,
'post_path' => $post_path,
]
);
}
/**
* Get optimizer data for a post.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) {
$post_path = $request->get_param( self::POST_PATH );
$post_id = $request->get_param( self::POST_ID );
$path = new Path( $post_path, $post_id );
$analysis = $this->store->get( $path );
if ( ! $analysis ) {
return new WP_Error(
'rest_kb_optimizer_read_not_found',
__( 'Sorry, we couldn\'t find optimizer data.', 'kadence-blocks' ),
[
'status' => WP_Http::NOT_FOUND,
'post_path' => $post_path,
]
);
}
return new WP_REST_Response(
[
'data' => $analysis->toArray(),
],
WP_Http::OK
);
}
/**
* Delete optimizer data for a post.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function delete_item( $request ) {
$post_id = $request->get_param( self::POST_ID );
$post_path = $request->get_param( self::POST_PATH );
// Include post_id in Path for reliable status synchronization.
$path = new Path( $post_path, $post_id );
if ( $this->store->delete( $path ) ) {
// Run wp_update post to signal to caching plugins to update their cache.
$this->update_post( $post_id );
return new WP_REST_Response(
[
'data' => [
'success' => true,
'post_id' => $post_id,
'post_path' => $post_path,
],
],
WP_Http::OK
);
}
return new WP_Error(
'rest_kb_optimizer_delete_failed',
__( 'Failed to delete optimizer data.', 'kadence-blocks' ),
[
'status' => WP_Http::INTERNAL_SERVER_ERROR,
'post_id' => $post_id,
'post_path' => $post_path,
]
);
}
/**
* Get metadata for multiple posts to prepare for bulk optimization.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response
*/
public function get_posts_metadata( WP_REST_Request $request ): WP_REST_Response {
$post_ids = $request->get_param( self::POST_IDS );
$posts_data = [];
$errors = [];
foreach ( $post_ids as $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
$errors[] = [
'post_id' => $post_id,
'message' => __( 'Post not found.', 'kadence-blocks' ),
];
continue;
}
$post_url = get_permalink( $post_id );
$post_path = $this->get_post_path( $post_id );
// Skip posts without pretty permalinks.
if ( ! $post_url || empty( $post_path ) || str_contains( $post_path, '?' ) ) {
$errors[] = [
'post_id' => $post_id,
'message' => __( 'Post has no rewrite rules or is not public.', 'kadence-blocks' ),
];
continue;
}
$posts_data[] = [
'post_id' => $post_id,
'url' => $post_url,
'post_path' => $post_path,
];
}
return new WP_REST_Response(
[
'data' => $posts_data,
'errors' => $errors,
],
WP_Http::OK
);
}
/**
* Delete optimizer data for multiple posts at once.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response
*/
public function bulk_delete_items( WP_REST_Request $request ): WP_REST_Response {
$post_ids = $request->get_param( self::POST_IDS );
$post_paths = $request->get_param( self::POST_PATHS );
$results = [
'successful' => [],
'failed' => [],
];
foreach ( $post_ids as $index => $post_id ) {
$post_path = $post_paths[ $index ] ?? null;
if ( ! $post_path ) {
$results['failed'][] = [
'post_id' => $post_id,
'message' => __( 'Missing post path.', 'kadence-blocks' ),
];
continue;
}
$path = new Path( $post_path, $post_id );
// Check if optimization data exists.
if ( ! $this->store->has( $path ) ) {
// No data to delete - consider this a success.
$this->store->delete( $path );
$results['successful'][] = $post_id;
continue;
}
if ( $this->store->delete( $path ) ) {
// Run wp_update_post to signal to caching plugins to update their cache.
$this->update_post( $post_id );
$results['successful'][] = $post_id;
} else {
$results['failed'][] = [
'post_id' => $post_id,
'message' => __( 'Failed to delete optimizer data.', 'kadence-blocks' ),
];
}
}
return new WP_REST_Response(
[
'data' => $results,
],
WP_Http::OK
);
}
/**
* Checks if a given request has access to create items.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has access to create items, WP_Error object
* otherwise.
*/
public function create_item_permissions_check( $request ) {
$post_id = $request->get_param( self::POST_ID );
if ( $post_id && current_user_can( 'edit_post', $post_id ) ) {
return true;
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'rest_kb_optimizer_post_does_not_exist',
__( 'Sorry, we could not find that post_id.', 'kadence-blocks' ),
[
'status' => WP_Http::NOT_FOUND,
'post_id' => $post_id,
]
);
}
return new WP_Error(
'rest_kb_optimizer_create_forbidden',
__( 'Sorry, you are not allowed to store optimizer data.', 'kadence-blocks' ),
[
'status' => rest_authorization_required_code(),
'post_id' => $post_id,
]
);
}
/**
* Checks if a given request has access to get a specific item.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) {
$post_id = $request->get_param( self::POST_ID );
if ( $post_id && current_user_can( 'edit_post', $post_id ) ) {
return true;
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'rest_kb_optimizer_post_does_not_exist',
__( 'Sorry, we could not find that post_id.', 'kadence-blocks' ),
[
'status' => WP_Http::NOT_FOUND,
'post_id' => $post_id,
]
);
}
return new WP_Error(
'rest_kb_optimizer_read_forbidden',
__( 'Sorry, you are not allowed to access optimizer data.', 'kadence-blocks' ),
[
'status' => rest_authorization_required_code(),
'post_id' => $post_id,
]
);
}
/**
* Checks if a given request has access to delete optimization data.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has access to delete the item, WP_Error object
* otherwise.
*/
public function delete_item_permissions_check( $request ) {
$post_id = $request->get_param( self::POST_ID );
if ( $post_id && current_user_can( 'delete_post', $post_id ) ) {
return true;
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'rest_kb_optimizer_post_does_not_exist',
__( 'Sorry, we could not find that post_id.', 'kadence-blocks' ),
[
'status' => WP_Http::NOT_FOUND,
'post_id' => $post_id,
]
);
}
return new WP_Error(
'rest_kb_optimizer_delete_forbidden',
__( 'Sorry, you do not have permission to delete data for this post.', 'kadence-blocks' ),
[
'status' => rest_authorization_required_code(),
'post_id' => $post_id,
]
);
}
/**
* Checks if a given request has access to get posts metadata.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has access, WP_Error object otherwise.
*/
public function get_posts_metadata_permissions_check( $request ) {
$post_ids = $request->get_param( self::POST_IDS );
// Admins and editors can edit all posts - skip individual checks.
if ( current_user_can( 'edit_others_posts' ) ) {
return true;
}
// For contributors/authors, verify they can edit each post.
foreach ( $post_ids as $post_id ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'rest_kb_optimizer_bulk_forbidden',
__( 'Sorry, you are not allowed to access optimizer data for all selected posts.', 'kadence-blocks' ),
[
'status' => rest_authorization_required_code(),
'post_id' => $post_id,
]
);
}
}
return true;
}
/**
* Checks if a given request has access to bulk delete optimization data.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has access, WP_Error object otherwise.
*/
public function bulk_delete_items_permissions_check( WP_REST_Request $request ) {
$post_ids = $request->get_param( self::POST_IDS );
// Admins and editors can delete all posts - skip individual checks.
if ( current_user_can( 'edit_others_posts' ) ) {
return true;
}
// For contributors/authors, verify they can delete each post.
foreach ( $post_ids as $post_id ) {
if ( ! current_user_can( 'delete_post', $post_id ) ) {
return new WP_Error(
'rest_kb_optimizer_bulk_delete_forbidden',
__( 'Sorry, you are not allowed to delete optimizer data for all selected posts.', 'kadence-blocks' ),
[
'status' => rest_authorization_required_code(),
'post_id' => $post_id,
]
);
}
}
return true;
}
/**
* Get the default arguments for write methods (POST, DELETE).
*
* Requires both post_path and post_id for reliable status synchronization.
*
* @return array<string, array{
* type: string,
* description: string,
* minimum: positive-int,
* required: true,
* sanitize_callback: callable,
* }>
*/
private function get_default_params(): array {
return [
self::POST_PATH => [
'type' => 'string',
'description' => __( 'The relative post path', 'kadence-blocks' ),
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
self::POST_ID => [
'type' => 'integer',
'description' => __( 'The post ID associated with the optimization data.', 'kadence-blocks' ),
'minimum' => 1,
'required' => true,
'sanitize_callback' => static fn( $value ) => (int) $value,
],
];
}
/**
* Get the arguments for the create method.
*
* @return array<string, array{
* type: string,
* description: string,
* minimum: positive-int,
* required: true,
* sanitize_callback: callable,
* properties: array<string, mixed>
* }>
*/
private function get_create_params(): array {
$params = $this->get_default_params();
$params[ self::RESULTS ] = [
'type' => 'object',
'properties' => rest_get_endpoint_args_for_schema( $this->schema_loader->get_item_schema() ),
];
return $params;
}
/**
* Get the arguments for the posts metadata endpoint.
*
* @return array<string, array{
* type: string,
* description: string,
* required: true,
* items: array<string, mixed>,
* sanitize_callback: callable
* }>
*/
private function get_posts_metadata_params(): array {
return [
self::POST_IDS => [
'type' => 'array',
'description' => __( 'Array of post IDs to get metadata for.', 'kadence-blocks' ),
'required' => true,
'items' => [
'type' => 'integer',
'minimum' => 1,
],
'sanitize_callback' => static fn( $value ) => array_map( 'intval', $value ),
],
];
}
/**
* Get the arguments for the bulk delete endpoint.
*
* @return array<string, array{
* type: string,
* description: string,
* required: true,
* items: array<string, mixed>,
* sanitize_callback: callable
* }>
*/
private function get_bulk_delete_params(): array {
return [
self::POST_IDS => [
'type' => 'array',
'description' => __( 'Array of post IDs to delete optimization data for.', 'kadence-blocks' ),
'required' => true,
'items' => [
'type' => 'integer',
'minimum' => 1,
],
'sanitize_callback' => static fn( $value ) => array_map( 'intval', $value ),
],
self::POST_PATHS => [
'type' => 'array',
'description' => __( 'Array of post paths corresponding to the post IDs.', 'kadence-blocks' ),
'required' => true,
'items' => [
'type' => 'string',
],
'sanitize_callback' => static fn( $value ) => array_map( 'sanitize_text_field', $value ),
],
];
}
/**
* Update a post to hopefully force caching plugins to refresh their cache.
*
* @param int $post_id The post ID.
*
* @return int|WP_Error
*/
private function update_post( int $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'rest_kb_optimizer_update_post_failed',
__( 'Unable to find post to update', 'kadence-blocks' ),
[
'status' => WP_Http::INTERNAL_SERVER_ERROR,
'post_id' => $post_id,
]
);
}
return wp_update_post( $post );
}
}

View File

@@ -0,0 +1,18 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Rest;
use KadenceWP\KadenceBlocks\Optimizer\Rest\Optimize_Rest_Controller;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
add_action(
'rest_api_init',
function (): void {
$this->container->get( Optimize_Rest_Controller::class )->register_routes();
}
);
}
}

View File

@@ -0,0 +1,233 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Rest;
/**
* Schema definition class for the Performance Optimizer REST API endpoints.
*/
final class Schema {
/**
* Get the parameter schema definitions for the REST API response.
*
* Defines the structure and validation rules for desktop/mobile viewport data,
* including sections, images, and performance metrics used by the optimizer.
*
* @return array Schema parameter definitions with type validation and descriptions.
*/
public function get_params(): array {
// Definition for the 'section' object, reused for desktop and mobile.
$section_properties = [
'id' => [
'description' => __( 'The ID attribute of the section element.', 'kadence-blocks' ),
'type' => 'string',
],
'height' => [
'description' => __( 'The height of the section in pixels.', 'kadence-blocks' ),
'type' => 'number',
],
'tagName' => [
'description' => __( 'The HTML tag name of the section element.', 'kadence-blocks' ),
'type' => 'string',
],
'className' => [
'description' => __( 'The CSS classes assigned to this section.', 'kadence-blocks' ),
'type' => 'string',
],
'path' => [
'description' => __( 'The CSS selector path to the element.', 'kadence-blocks' ),
'type' => 'string',
],
'isAboveFold' => [
'description' => __( 'Whether the section is located above the fold.', 'kadence-blocks' ),
'type' => 'boolean',
],
'hasImages' => [
'description' => __( 'Whether the section contains any image elements.', 'kadence-blocks' ),
'type' => 'boolean',
],
'hasBackground' => [
'description' => __( 'Whether the section has a background image.', 'kadence-blocks' ),
'type' => 'boolean',
],
];
// Definition for the 'image' object.
$image_properties = [
'path' => [
'description' => __( 'The CSS selector path to the image element.', 'kadence-blocks' ),
'type' => 'string',
'required' => true,
],
'src' => [
'description' => __( 'The source URL of the image.', 'kadence-blocks' ),
'type' => 'string',
'format' => 'uri',
'required' => true,
],
'srcset' => [
'description' => __( 'An array of sources for different resolutions.', 'kadence-blocks' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'url' => [
'type' => 'string',
'format' => 'uri',
'required' => true,
],
'width' => [
'type' => 'integer',
'required' => true,
],
],
],
],
'width' => [
'description' => __( 'The rendered width of the image in pixels.', 'kadence-blocks' ),
'type' => 'integer',
'required' => true,
],
'height' => [
'description' => __( 'The rendered height of the image in pixels.', 'kadence-blocks' ),
'type' => 'integer',
'required' => true,
],
'widthAttr' => [
'description' => __( 'The img element width attribute value.', 'kadence-blocks' ),
'type' => 'string',
'required' => false,
],
'heightAttr' => [
'description' => __( 'The img element height attribute value.', 'kadence-blocks' ),
'type' => 'string',
'required' => false,
],
'naturalWidth' => [
'description' => __( 'The intrinsic width of the image file in pixels.', 'kadence-blocks' ),
'type' => 'integer',
],
'naturalHeight' => [
'description' => __( 'The intrinsic height of the image file in pixels.', 'kadence-blocks' ),
'type' => 'integer',
],
'aspectRatio' => [
'description' => __( 'The aspect ratio of the image.', 'kadence-blocks' ),
'type' => 'number',
],
'alt' => [
'description' => __( 'The alt text of the image.', 'kadence-blocks' ),
'type' => [ 'string', 'null' ],
],
'class' => [
'description' => __( 'The class attribute of the image element.', 'kadence-blocks' ),
'type' => [ 'string', 'null' ],
],
'loading' => [
'description' => __( 'The loading attribute of the image (e.g., \'lazy\', \'eager\', \'auto\').', 'kadence-blocks' ),
'type' => 'string',
],
'decoding' => [
'description' => __( 'The decoding hint for the image (e.g., \'async\', \'sync\', \'auto\').', 'kadence-blocks' ),
'type' => 'string',
],
'sizes' => [
'description' => __( 'The sizes attribute of the image.', 'kadence-blocks' ),
'type' => [ 'string', 'null' ],
],
'computedStyle' => [
'description' => __( 'The computed CSS styles of the image element.', 'kadence-blocks' ),
'type' => 'object',
'properties' => [
'width' => [ 'type' => 'string' ],
'height' => [ 'type' => 'string' ],
'objectFit' => [ 'type' => 'string' ],
'objectPosition' => [ 'type' => 'string' ],
],
],
'optimalSizes' => [
'description' => __( 'A calculated optimal sizes string.', 'kadence-blocks' ),
'type' => 'string',
],
];
// Definition for the viewport-specific data (desktop/mobile).
$viewport_data_properties = [
'criticalImages' => [
'description' => __( 'A list of above the fold image URLs.', 'kadence-blocks' ),
'type' => 'array',
'items' => [
'type' => 'string',
'format' => 'uri',
],
'required' => true,
],
'backgroundImages' => [
'description' => __( 'A list of URLs for background images.', 'kadence-blocks' ),
'type' => 'array',
'items' => [
'type' => 'string',
'format' => 'uri',
],
'required' => true,
],
'sections' => [
'description' => __( 'An array of layout sections.', 'kadence-blocks' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => $section_properties,
],
'required' => true,
],
];
// The main schema structure.
return [
'isStale' => [
'description' => __( 'Whether this data is stale or expired.', 'kadence-blocks' ),
'type' => 'boolean',
'required' => false,
'default' => false,
],
'desktop' => [
'description' => __( 'Performance and layout data for the desktop viewport.', 'kadence-blocks' ),
'type' => 'object',
'properties' => $viewport_data_properties,
'required' => true,
],
'mobile' => [
'description' => __( 'Performance and layout data for the mobile viewport.', 'kadence-blocks' ),
'type' => 'object',
'properties' => $viewport_data_properties,
'required' => true,
],
'images' => [
'description' => __( 'An array of all image objects found on the page.', 'kadence-blocks' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => $image_properties,
],
'required' => true,
],
];
}
/**
* Get the complete JSON Schema item definition.
*
* Returns a full JSON Schema (draft-07) compliant structure that can be used
* for REST API response validation and documentation generation.
*
* @return array Complete JSON Schema definition with metadata and property specifications.
*/
public function get_item_schema(): array {
return [
'$schema' => 'http://json-schema.org/draft-07/schema#',
'title' => __( 'Page Analysis Data', 'kadence-blocks' ),
'type' => 'object',
'properties' => $this->get_params(),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules\Ignored_Query_Var_Rule;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules\Logged_In_Rule;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules\Not_Found_Rule;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules\Optimizer_Request_Rule;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules\Post_Excluded_Rule;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
/**
* Filter the list of query variables, that if present will bypass
* the hash check.
*
* @param string[] $query_vars The query variable names.
*/
$query_vars = apply_filters(
'kadence_blocks_optimizer_rule_skip_query_vars',
[
'preview',
]
);
$this->container->when( Ignored_Query_Var_Rule::class )
->needs( '$query_vars' )
->give(
static fn(): array => $query_vars
);
$this->container->when( Rule_Collection::class )
->needs( '$rules' )
->give(
fn(): array => [
$this->container->get( Optimizer_Request_Rule::class ),
$this->container->get( Ignored_Query_Var_Rule::class ),
$this->container->get( Not_Found_Rule::class ),
$this->container->get( Logged_In_Rule::class ),
$this->container->get( Post_Excluded_Rule::class ),
]
);
}
}

View File

@@ -0,0 +1,30 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules;
/**
* Holds the collection of rules that determine whether to skip operations.
*/
final class Rule_Collection {
/**
* @var Skip_Rule[]
*/
private array $rules;
/**
* @param Skip_Rule[] $rules
*/
public function __construct( array $rules ) {
$this->rules = $rules;
}
/**
* Get all the rules in the collection.
*
* @return Skip_Rule[]
*/
public function all(): array {
return $this->rules;
}
}

View File

@@ -0,0 +1,36 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Skip_Rule;
use KadenceWP\KadenceBlocks\StellarWP\SuperGlobals\SuperGlobals as SG;
final class Ignored_Query_Var_Rule implements Skip_Rule {
/**
* @var string[]
*/
private array $query_vars;
/**
* @filter kadence_blocks_optimizer_rule_skip_query_vars
*
* @param string[] $query_vars The query variable names.
*/
public function __construct( array $query_vars = [] ) {
$this->query_vars = $query_vars;
}
/**
* @inheritDoc
*/
public function should_skip(): bool {
foreach ( $this->query_vars as $query_var ) {
if ( SG::get_get_var( $query_var ) !== null ) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,15 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Skip_Rule;
final class Logged_In_Rule implements Skip_Rule {
/**
* @inheritDoc
*/
public function should_skip(): bool {
return is_user_logged_in();
}
}

View File

@@ -0,0 +1,15 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Skip_Rule;
final class Not_Found_Rule implements Skip_Rule {
/**
* @inheritDoc
*/
public function should_skip(): bool {
return is_404();
}
}

View File

@@ -0,0 +1,22 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules;
use KadenceWP\KadenceBlocks\Optimizer\Request\Request;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Skip_Rule;
final class Optimizer_Request_Rule implements Skip_Rule {
private Request $request;
public function __construct( Request $request ) {
$this->request = $request;
}
/**
* @inheritDoc
*/
public function should_skip(): bool {
return $this->request->is_optimizer_request();
}
}

View File

@@ -0,0 +1,33 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Rules;
use KadenceWP\KadenceBlocks\Optimizer\Skip_Rules\Skip_Rule;
use KadenceWP\KadenceBlocks\Optimizer\Status\Status;
use WP_Post;
/**
* Skip Optimization if the post was excluded via the
* Gutenberg plugins sidebar which stores to post meta.
*/
final class Post_Excluded_Rule implements Skip_Rule {
private Status $status;
public function __construct( Status $status ) {
$this->status = $status;
}
/**
* @inheritDoc
*/
public function should_skip(): bool {
$current_post = get_post();
if ( ! $current_post instanceof WP_Post ) {
return false;
}
return $this->status->is_excluded( $current_post->ID );
}
}

View File

@@ -0,0 +1,17 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Skip_Rules;
/**
* Defines a rule for determining whether to skip a specific operation.
*/
interface Skip_Rule {
/**
* Whether the result of this rule will skip
* the associated operation.
*
* @return bool
*/
public function should_skip(): bool;
}

View File

@@ -0,0 +1,39 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer;
class State {
public const SETTINGS_KEY = 'performance_optimizer_enabled';
/**
* The default state of the Optimizer if no
* settings have been saved.
*/
public const DEFAULT_STATE = false;
/**
* Whether the Optimizer is enabled.
*
* @return bool
*/
public function enabled(): bool {
$settings_json = (string) get_option( 'kadence_blocks_settings', '' );
if ( ! $settings_json ) {
return self::DEFAULT_STATE;
}
$settings = json_decode( $settings_json, true );
if ( ! is_array( $settings ) ) {
return self::DEFAULT_STATE;
}
return filter_var(
$settings[ self::SETTINGS_KEY ] ?? self::DEFAULT_STATE,
FILTER_VALIDATE_BOOL,
FILTER_NULL_ON_FAILURE
) ?? self::DEFAULT_STATE;
}
}

View File

@@ -0,0 +1,98 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Status;
use KadenceWP\KadenceBlocks\Asset\Asset;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Traits\Permalink_Trait;
/**
* Post Meta to control excluding posts from being optimized set
* via a Gutenberg plugin.
*
* @see kadence-control-plugin.js
*/
final class Meta {
use Permalink_Trait;
public const KEY = '_kb_optimizer_status';
private Asset $asset;
private Status $status;
private Store $store;
public function __construct(
Asset $asset,
Status $status,
Store $store
) {
$this->asset = $asset;
$this->status = $status;
$this->store = $store;
}
/**
* Register post meta for the Gutenberg sidebar toggle to exclude a post from optimization.
*
* @action init
*
* @return void
*/
public function register_meta(): void {
register_post_meta(
'',
self::KEY,
[
'single' => true,
'type' => 'integer',
// Excluded is stored a -1.
'sanitize_callback' => function ( $value ): int {
$value = (int) $value;
return $this->status->is_valid( $value ) ? $value : 0;
},
'auth_callback' => static fn(): bool => current_user_can( 'edit_posts' ),
'show_in_rest' => [
'schema' => [
'type' => 'integer',
'description' => __( 'Exclude this post from optimization.', 'kadence-blocks' ),
],
],
]
);
}
/**
* Clear the optimizer data if this post is excluded.
*
* @action updated_post_meta
*
* @param int $post_id The post ID this meta is for.
* @param string $meta_key The current meta key.
* @param mixed $value The meta value.
*
* @return void
*/
public function maybe_clear_optimizer_data( int $post_id, string $meta_key, $value ): void {
if ( $meta_key !== self::KEY || $value !== Status::EXCLUDED ) {
return;
}
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
$post_path = $this->get_post_path( $post_id );
if ( ! $post_path ) {
return;
}
// Include post_id for reliable status synchronization.
$path = new Path( $post_path, $post_id );
$this->store->delete( $path );
}
}

Some files were not shown because too many files have changed in this diff Show More