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,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 );
}
}

View File

@@ -0,0 +1,27 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Status;
use KadenceWP\KadenceBlocks\Optimizer\Status\Meta;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
public function register(): void {
$this->container->singleton( Meta::class, Meta::class );
add_action(
'init',
$this->container->callback( Meta::class, 'register_meta' )
);
add_action(
'updated_post_meta',
function ( $meta_id, $post_id, $meta_key, $value ) {
$this->container->get( Meta::class )->maybe_clear_optimizer_data( (int) $post_id, $meta_key, $value );
},
10,
4
);
}
}

View File

@@ -0,0 +1,160 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Status;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
/**
* Store the status for a post, used mostly to sort the Post List Table.
*
* @note The excluded status is actually the source of truth for this.
*/
final class Status {
/**
* Optimization status values.
*
* Values are chosen to sort naturally:
* - ASC: Excluded (-1) > Not Optimized (0) > Optimized (1) > Stale (2)
* - DESC: Stale (2) > Optimized (1) > Not Optimized (0) > Excluded (-1)
*/
public const EXCLUDED = -1;
public const NOT_OPTIMIZED = 0;
public const OPTIMIZED = 1;
public const STALE = 2;
private const STATUSES = [
self::EXCLUDED => true,
self::NOT_OPTIMIZED => true,
self::OPTIMIZED => true,
self::STALE => true,
];
/**
* Check if a status is valid.
*
* @param int $status The status to check.
*
* @return bool
*/
public function is_valid( int $status ): bool {
return isset( self::STATUSES[ $status ] );
}
/**
* Get the optimization status for a post.
*
* @param int $post_id The post ID.
*
* @return int One of the STATUS_* constants.
*/
public function get( int $post_id ): int {
return (int) get_post_meta( $post_id, Meta::KEY, true );
}
/**
* Set the optimization status for a post.
*
* @param int $post_id The post ID.
* @param int $status One of the STATUS_* constants.
*
* @throws InvalidArgumentException If the wrong status is passed.
*
* @return bool
*/
public function set( int $post_id, int $status = self::OPTIMIZED ): bool {
if ( ! isset( self::STATUSES[ $status ] ) ) {
throw new InvalidArgumentException( 'Invalid status: ' . $status );
}
return (bool) update_post_meta( $post_id, Meta::KEY, $status );
}
/**
* Set the post to optimized.
*
* @param int $post_id The post ID.
*
* @return bool
*/
public function set_optimized( int $post_id ): bool {
return $this->set( $post_id );
}
/**
* Set the post to excluded.
*
* @param int $post_id The post ID.
*
* @return bool
*/
public function set_excluded( int $post_id ): bool {
return $this->set( $post_id, self::EXCLUDED );
}
/**
* Check if a post is excluded from optimization.
*
* @param int $post_id The post ID.
*
* @return bool
*/
public function is_excluded( int $post_id ): bool {
return $this->get( $post_id ) === self::EXCLUDED;
}
/**
* Delete the status meta.
*
* IMPORTANT: Preserves EXCLUDED status since it's user-controlled.
*
* @param int $post_id The post ID.
*
* @return bool
*/
public function delete( int $post_id ): bool {
if ( $this->is_excluded( $post_id ) ) {
return false;
}
return delete_post_meta( $post_id, Meta::KEY );
}
/**
* Derive status from WebsiteAnalysis.
*
* Helper method to determine what status should be based on analysis state.
*
* @param WebsiteAnalysis|null $analysis The analysis data.
*
* @return int One of the STATUS_* constants.
*/
public function from_analysis( ?WebsiteAnalysis $analysis ): int {
if ( ! $analysis ) {
return self::NOT_OPTIMIZED;
}
return $analysis->isStale ? self::STALE : self::OPTIMIZED;
}
/**
* Sync status from analysis data.
*
* Automatically sets the correct status based on the analysis state.
* This is the single source of truth for deriving status from analysis.
*
* @param int $post_id The post ID.
* @param WebsiteAnalysis|null $analysis The analysis data.
*
* @return bool
*/
public function sync_from_analysis( int $post_id, ?WebsiteAnalysis $analysis ): bool {
$status = $this->from_analysis( $analysis );
return $this->set( $post_id, $status );
}
}

View File

@@ -0,0 +1,135 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
/**
* A Store Decorator that caches the results of the underlying concrete
* store using the object cache.
*/
final class Cached_Store_Decorator implements Contracts\Store {
/**
* The cache group.
*/
public const GROUP = 'kb_optimizer';
/**
* The cache time to live in seconds.
*/
public const TTL = 43200;
/**
* The underlying store concrete.
*
* @var Store
*/
private Store $store;
public function __construct( Store $store ) {
$this->store = $store;
}
/**
* Whether a Path has optimization data.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool {
$cached = wp_cache_get( $this->get_has_key( $path ), self::GROUP, false, $found );
if ( $found ) {
return $cached;
}
$has = $this->store->has( $path );
wp_cache_set( $this->get_has_key( $path ), $has, self::GROUP, self::TTL );
return $has;
}
/**
* Only return the optimization if the analysis data is newer than the post's last modified
* date.
*
* @param Path $path The path object associated with the stored data.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis {
$cached = wp_cache_get( $this->get_key( $path ), self::GROUP );
if ( $cached instanceof WebsiteAnalysis ) {
return $cached;
}
$analysis = $this->store->get( $path );
if ( $analysis ) {
wp_cache_set( $this->get_key( $path ), $analysis, self::GROUP, self::TTL );
wp_cache_set( $this->get_has_key( $path ), true, self::GROUP, self::TTL );
}
return $analysis;
}
/**
* Set the optimization data for a post.
*
* @param Path $path The path object associated with the stored data.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool {
$result = $this->store->set( $path, $analysis );
if ( $result ) {
wp_cache_set( $this->get_key( $path ), $analysis, self::GROUP, self::TTL );
}
return $result;
}
/**
* Delete the optimization data for a post.
*
* @param Path $path The path object associated with the stored data.
*
* @return bool
*/
public function delete( Path $path ): bool {
wp_cache_delete( $this->get_key( $path ), self::GROUP );
wp_cache_delete( $this->get_has_key( $path ), self::GROUP );
return $this->store->delete( $path );
}
/**
* Build a cache key.
*
* @param Path $path The path object.
*
* @return string
*/
public function get_key( Path $path ): string {
return sprintf( 'kb_optimizer_url_%s', $path->hash() );
}
/**
* Get the cache key for if optimization data exists.
*
* @param Path $path The path object.
*
* @return string
*/
public function get_has_key( Path $path ): string {
return sprintf( '%s_%s', $this->get_key( $path ), '_has' );
}
}

View File

@@ -0,0 +1,46 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store\Contracts;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
interface Store {
/**
* Whether a Path has optimization data.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool;
/**
* Get the optimization data for a post.
*
* @param Path $path The path object.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis;
/**
* Set the optimization data for a post.
*
* @param Path $path The path object.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool;
/**
* Delete the optimization data for a post.
*
* @param Path $path The path object.
*
* @return bool
*/
public function delete( Path $path ): bool;
}

View File

@@ -0,0 +1,117 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
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\Psr\Log\LoggerInterface;
/**
* Store decorator that blocks optimization operations on excluded posts.
*
* Excluded posts are user-controlled and should never have optimization data.
* This decorator provides infrastructure-level protection.
*
* Responsibility: Prevent optimization of excluded posts.
*/
final class Excluded_Store_Decorator implements Store {
private Store $store;
private Status $status;
private LoggerInterface $logger;
public function __construct(
Store $store,
Status $status,
LoggerInterface $logger
) {
$this->store = $store;
$this->status = $status;
$this->logger = $logger;
}
/**
* An excluded post should return false for if it has data,
* even if it does.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool {
$post_id = $path->post_id();
if ( $post_id && $this->status->is_excluded( $post_id ) ) {
return false;
}
return $this->store->has( $path );
}
/**
* Get the optimization data.
*
* @param Path $path The path object associated with the stored data.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis {
return $this->store->get( $path );
}
/**
* Block optimization of excluded posts.
*
* If post is excluded, silently fail and log.
* This provides defense-in-depth even if application layer checks fail.
*
* @param Path $path The path object.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool {
$post_id = $path->post_id();
if ( ! $post_id ) {
$this->logger->warning(
'Missing post_id in Path: Skipping exclusion check and storing optimization data without post_id',
[ 'path' => $path->path() ]
);
return $this->store->set( $path, $analysis );
}
// Check if post is excluded.
if ( $this->status->is_excluded( $post_id ) ) {
// Silently block - excluded posts should never be optimized.
$this->logger->debug(
'Blocked optimization of excluded post',
[
'post_id' => $post_id,
'path' => $path->path(),
]
);
return false;
}
return $this->store->set( $path, $analysis );
}
/**
* Allow deletion even for excluded posts.
*
* Clearing optimization data is safe for excluded posts.
* Status_Sync_Store_Decorator will preserve the excluded status meta.
*
* @param Path $path The path object.
*
* @return bool
*/
public function delete( Path $path ): bool {
return $this->store->delete( $path );
}
}

View File

@@ -0,0 +1,76 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
/**
* A Store Decorator that checks if the data is expired before
* returning it.
*/
final class Expired_Store_Decorator implements Contracts\Store {
/**
* The underlying store concrete.
*
* @var Store
*/
private Store $store;
public function __construct( Store $store ) {
$this->store = $store;
}
/**
* Whether a Path has optimization data, stale or not.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool {
return $this->store->has( $path );
}
/**
* Only return the optimization if the analysis data is newer than the post's last modified date.
*
* @param Path $path The path object associated with the stored data.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis {
$analysis = $this->store->get( $path );
if ( ! $analysis ) {
return null;
}
return $analysis->isStale ? null : $analysis;
}
/**
* Set the optimization data for a post.
*
* @param Path $path The path object associated with the stored data.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool {
return $this->store->set( $path, $analysis );
}
/**
* Delete the optimization data for a post.
*
* @param Path $path The path object associated with the stored data.
*
* @return bool
*/
public function delete( Path $path ): bool {
return $this->store->delete( $path );
}
}

View File

@@ -0,0 +1,36 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
use KadenceWP\KadenceBlocks\Optimizer\Store\Cached_Store_Decorator;
use KadenceWP\KadenceBlocks\Optimizer\Store\Contracts\Store;
use KadenceWP\KadenceBlocks\Optimizer\Store\Excluded_Store_Decorator;
use KadenceWP\KadenceBlocks\Optimizer\Store\Expired_Store_Decorator;
use KadenceWP\KadenceBlocks\Optimizer\Store\Status_Sync_Store_Decorator;
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 function register(): void {
// Decorator chain (bottom-up execution, each wraps the one below):
// When calling Store methods, execution flows TOP to BOTTOM through decorators.
//
// Reads (get): Expired → Cache → Excluded → Status_Sync → Table
// Writes (set): Expired → Cache → Excluded (blocks excluded) → Status_Sync (pure) → Table
//
// Critical ordering:
// 1. Expired MUST be outermost to filter stale data even from cache.
// 2. Excluded MUST be before Status_Sync so sync logic stays pure.
$this->container->bindDecorators(
Store::class,
[
Expired_Store_Decorator::class,
Cached_Store_Decorator::class,
Excluded_Store_Decorator::class,
Status_Sync_Store_Decorator::class,
Table_Store::class,
]
);
}
}

View File

@@ -0,0 +1,132 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
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 WP_Post;
/**
* Store decorator that automatically synchronizes Status post meta.
*
* Pure synchronization logic - no business rules or exclusion checks.
* Exclusion is handled by Excluded_Store_Decorator upstream.
*
* Responsibility: Keep status meta in sync with optimization data.
*
* Benefits:
* - Single source of truth for optimization state
* - Impossible to forget to update status meta
* - Automatic synchronization on all write operations
* - No overhead on read operations
*/
final class Status_Sync_Store_Decorator implements Store {
private Store $store;
private Status $status;
public function __construct( Store $store, Status $status ) {
$this->store = $store;
$this->status = $status;
}
/**
* Whether a Path has optimization data.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool {
return $this->store->has( $path );
}
/**
* Get the optimization data.
*
* @param Path $path The path object associated with the stored data.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis {
return $this->store->get( $path );
}
/**
* Set optimization data and sync status meta.
*
* Pure sync logic - no exclusion checks needed!
* Excluded_Store_Decorator upstream prevents optimization of excluded posts.
*
* @param Path $path The path object.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool {
$result = $this->store->set( $path, $analysis );
if ( ! $result ) {
return false;
}
$post_id = $this->resolve_post_id( $path );
if ( $post_id ) {
$this->status->sync_from_analysis( $post_id, $analysis );
}
return true;
}
/**
* Delete optimization data and sync status meta.
*
* Pure cleanup logic - no exclusion checks needed!
*
* Note: Status::delete() will preserve excluded status as a safety measure.
*
* @param Path $path The path object.
*
* @return bool
*/
public function delete( Path $path ): bool {
$post_id = $this->resolve_post_id( $path );
$result = $this->store->delete( $path );
if ( $post_id ) {
$this->status->delete( $post_id );
}
return $result;
}
/**
* Resolve post ID from path.
*
* Priority order:
* 1. Explicit post_id from Path object (most reliable)
* 2. Current request context via get_post() and $post global
*
* @param Path $path The path object.
*
* @return int|null
*/
private function resolve_post_id( Path $path ): ?int {
// Use explicit post_id if provided (most reliable).
if ( $path->post_id() ) {
return $path->post_id();
}
// Try to get from current request context via get_post().
$current_post = get_post();
if ( ! $current_post instanceof WP_Post ) {
return null;
}
return $current_post->ID;
}
}

View File

@@ -0,0 +1,101 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Store;
use KadenceWP\KadenceBlocks\Optimizer\Database\Optimizer_Query;
use KadenceWP\KadenceBlocks\Optimizer\Path\Path;
use KadenceWP\KadenceBlocks\Optimizer\Response\WebsiteAnalysis;
use Throwable;
/**
* Optimizer table storage.
*/
final class Table_Store implements Contracts\Store {
private Optimizer_Query $q;
public function __construct( Optimizer_Query $query ) {
$this->q = $query;
}
/**
* Whether a Path has optimization data.
*
* @param Path $path The path object.
*
* @return bool
*/
public function has( Path $path ): bool {
return (bool) $this->q->get_var(
$this->q->qb()->select( '1' )
->where( 'path_hash', $path->hash() )
->limit( 1 )->getSQL()
);
}
/**
* Get the optimization data for a path object.
*
* @param Path $path The path object associated with the stored data.
*
* @return WebsiteAnalysis|null
*/
public function get( Path $path ): ?WebsiteAnalysis {
$json = $this->q->get_var(
$this->q->qb()->select( 'analysis' )
->where( 'path_hash', $path->hash() )
->getSQL()
);
if ( ! $json ) {
return null;
}
try {
return WebsiteAnalysis::from( json_decode( $json, true ) );
} catch ( Throwable $e ) {
return null;
}
}
/**
* Set the optimization data for a path object.
*
* @param Path $path The path object associated with the stored data.
* @param WebsiteAnalysis $analysis The website analysis data.
*
* @return bool
*/
public function set( Path $path, WebsiteAnalysis $analysis ): bool {
$json = wp_json_encode( $analysis->toArray(), JSON_UNESCAPED_SLASHES );
return false !== $this->q->qb()->upsert(
[
'path_hash' => $path->hash(),
'path' => $path->path(),
'analysis' => $json,
],
[
'path_hash',
],
[
'%s',
'%s',
'%s',
]
);
}
/**
* Delete the optimization data for a path object.
*
* @param Path $path The path object associated with the stored data.
*
* @return bool
*/
public function delete( Path $path ): bool {
return (bool) $this->q->qb()
->where( 'path_hash', $path->hash() )
->delete();
}
}

View File

@@ -0,0 +1,32 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Translation;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider as Provider_Contract;
final class Provider extends Provider_Contract {
/**
* If you update the text repository translations, ensure to match to the
* assets/js/optimizer/constant.js file.
*/
public function register(): void {
$this->container->singleton( Text_Repository::class, Text_Repository::class );
$this->container->when( Text_Repository::class )
->needs( '$labels' )
->give(
static fn(): array => [
Text_Repository::RUN_OPTIMIZER => __( 'Run Optimizer', 'kadence-blocks' ),
Text_Repository::REMOVE_OPTIMIZATION => __( 'Remove Optimization', 'kadence-blocks' ),
Text_Repository::OPTIMIZED => __( 'Optimized', 'kadence-blocks' ),
Text_Repository::OPTIMIZING => __( 'Optimizing', 'kadence-blocks' ),
Text_Repository::NOT_OPTIMIZED => __( 'Not Optimized', 'kadence-blocks' ),
Text_Repository::NOT_OPTIMIZABLE => __( 'Not Optimizable', 'kadence-blocks' ),
Text_Repository::EXCLUDED => __( 'Excluded', 'kadence-blocks' ),
Text_Repository::OPTIMIZATION_OUTDATED => __( 'Optimization Outdated', 'kadence-blocks' ),
Text_Repository::OPTIMIZATION_OUTDATED_RUN => __( 'Optimization Outdated. Run again?', 'kadence-blocks' ),
]
);
}
}

View File

@@ -0,0 +1,59 @@
<?php declare( strict_types=1 );
namespace KadenceWP\KadenceBlocks\Optimizer\Translation;
use InvalidArgumentException;
/**
* Provides shared translated strings.
*/
final class Text_Repository {
public const RUN_OPTIMIZER = 'runOptimizer';
public const REMOVE_OPTIMIZATION = 'removeOptimization';
public const OPTIMIZED = 'optimized';
public const OPTIMIZING = 'optimizing';
public const NOT_OPTIMIZED = 'notOptimized';
public const NOT_OPTIMIZABLE = 'notOptimizable';
public const EXCLUDED = 'excluded';
public const OPTIMIZATION_OUTDATED = 'outdated';
public const OPTIMIZATION_OUTDATED_RUN = 'outdateRunOptimizer';
/**
* @var array<string, string> $labels Translated labels indexed by the above constants.
*/
private array $labels;
/**
* @param array<string, string> $labels Translated labels indexed by the above constants.
*/
public function __construct( array $labels ) {
$this->labels = $labels;
}
/**
* Get a translated label.
*
* @param string $key
*
* @throws InvalidArgumentException If passed a key that doesn't exist.
*
* @return string
*/
public function get( string $key ): string {
if ( ! isset( $this->labels[ $key ] ) ) {
throw new InvalidArgumentException( "Unknown label key: $key" );
}
return $this->labels[ $key ];
}
/**
* Get all labels.
*
* @return array<string, string>
*/
public function all(): array {
return $this->labels;
}
}

View File

@@ -0,0 +1,6 @@
/* Kadence Optimizer Post List Table Column styles */
.fixed {
.column-kadence_optimizer {
width: 11em;
}
}

View File

@@ -0,0 +1,43 @@
import { animateDots } from '../optimizer/ui-manager.js';
/**
* Manages animation intervals for bulk operations.
*/
export class AnimationManager {
constructor() {
this.intervals = new Map();
}
/**
* Start animation for a post link.
*
* @param {number} postId - Post ID.
* @param {HTMLElement} link - Link element.
*/
start(postId, link) {
const interval = animateDots(link);
this.intervals.set(postId, interval);
}
/**
* Stop animation for a post.
*
* @param {number} postId - Post ID.
*/
stop(postId) {
const interval = this.intervals.get(postId);
if (interval) {
clearInterval(interval);
this.intervals.delete(postId);
}
}
/**
* Stop all animations.
*/
stopAll() {
this.intervals.forEach((interval) => clearInterval(interval));
this.intervals.clear();
}
}

View File

@@ -0,0 +1,36 @@
import apiFetch from '@wordpress/api-fetch';
import { POSTS_METADATA_ROUTE, BULK_DELETE_ROUTE } from './constants.js';
/**
* Fetch metadata for multiple posts.
*
* @param {number[]} postIds - Array of post IDs.
*
* @returns {Promise<{data: Array, errors: Array}>}
*/
export async function fetchPostsMetadata(postIds) {
return await apiFetch({
path: POSTS_METADATA_ROUTE,
method: 'POST',
data: { post_ids: postIds },
});
}
/**
* Bulk delete optimization data for multiple posts.
*
* @param {number[]} postIds - Array of post IDs.
* @param {string[]} postPaths - Array of post paths.
*
* @returns {Promise<{data: {successful: Array, failed: Array}}>}
*/
export async function bulkDeleteOptimization(postIds, postPaths) {
return await apiFetch({
path: BULK_DELETE_ROUTE,
method: 'DELETE',
data: {
post_ids: postIds,
post_paths: postPaths,
},
});
}

View File

@@ -0,0 +1,2 @@
export const POSTS_METADATA_ROUTE = '/kb-optimizer/v1/optimize/posts-metadata';
export const BULK_DELETE_ROUTE = '/kb-optimizer/v1/optimize/bulk/delete';

View File

@@ -0,0 +1,126 @@
import { __, _n, sprintf } from '@wordpress/i18n';
import { analyzeSite } from '../../optimizer/analyzer.js';
import { OPTIMIZER_DATA, UI_STATES } from '../../optimizer/constants.js';
import { createNotice, createDismissibleNotice, NOTICE_TYPES } from '@kadence-bundled/admin-notices';
import { AnimationManager } from '../animation-manager.js';
import { updatePostLinkState } from '../link-manager.js';
import { fetchPostsMetadata } from '../api.js';
import { initializeBulkUI } from '../ui.js';
/**
* Handle errors from metadata fetch.
*
* @param {Array} errors - Array of error objects.
* @param {AnimationManager} animationManager - Animation manager instance.
*/
function handleMetadataErrors(errors, animationManager) {
if (!errors || errors.length === 0) {
return;
}
errors.forEach((error) => {
createNotice(`Post ID ${error.post_id}: ${error.message}`, NOTICE_TYPES.ERROR, true);
animationManager.stop(error.post_id);
updatePostLinkState(error.post_id, UI_STATES.OPTIMIZE);
});
}
/**
* Handle bulk optimization action.
*
* @param {number[]} postIds - Array of post IDs to optimize.
*
* @returns {Promise<void>}
*/
export async function handleBulkOptimize(postIds) {
console.log('🚀 Starting bulk optimization for posts:', postIds);
const animationManager = new AnimationManager();
initializeBulkUI(postIds, animationManager);
const processingNotice = createDismissibleNotice(
sprintf(
// translators: %d: Number of posts being optimized.
_n('Optimizing %d post…', 'Optimizing %d posts…', postIds.length, 'kadence-blocks'),
postIds.length
),
NOTICE_TYPES.INFO
);
try {
const response = await fetchPostsMetadata(postIds);
const { data: postsData, errors } = response;
handleMetadataErrors(errors, animationManager);
const results = {
successful: [],
failed: [],
};
for (const postData of postsData) {
console.log(`🎯 Optimizing post ${postData.post_id}...`);
try {
await analyzeSite(postData.url, postData.post_id, postData.post_path, OPTIMIZER_DATA.token);
results.successful.push(postData.post_id);
animationManager.stop(postData.post_id);
updatePostLinkState(postData.post_id, UI_STATES.REMOVE);
} catch (error) {
console.error(`❌ Failed to optimize post ${postData.post_id}:`, error);
results.failed.push(postData.post_id);
createNotice(`Post ID ${postData.post_id}: ${error.message}`, NOTICE_TYPES.ERROR, true);
animationManager.stop(postData.post_id);
updatePostLinkState(postData.post_id, UI_STATES.OPTIMIZE);
}
}
if (results.successful.length > 0) {
createNotice(
sprintf(
// translators: %d: Number of posts optimized.
_n(
'🎉 %d post optimized successfully!',
'🎉 %d posts optimized successfully!',
results.successful.length,
'kadence-blocks'
),
results.successful.length
),
NOTICE_TYPES.SUCCESS
);
}
if (results.failed.length > 0) {
createNotice(
sprintf(
// translators: %d: Number of posts that failed.
_n(
'Failed to optimize %d post.',
'Failed to optimize %d posts.',
results.failed.length,
'kadence-blocks'
),
results.failed.length
),
NOTICE_TYPES.ERROR,
true
);
}
processingNotice?.remove();
} catch (error) {
console.error('❌ Bulk optimization failed:', error);
processingNotice?.remove();
createNotice(
__('Failed to fetch post data for bulk optimization.', 'kadence-blocks'),
NOTICE_TYPES.ERROR,
true,
error
);
}
}

View File

@@ -0,0 +1,117 @@
import { __, _n, sprintf } from '@wordpress/i18n';
import { UI_STATES } from '../../optimizer/constants.js';
import { createNotice, createDismissibleNotice, NOTICE_TYPES } from '@kadence-bundled/admin-notices';
import { AnimationManager } from '../animation-manager.js';
import { updatePostLinkState } from '../link-manager.js';
import { fetchPostsMetadata, bulkDeleteOptimization } from '../api.js';
import { initializeBulkUI } from '../ui.js';
/**
* Handle bulk remove optimization action.
*
* @param {number[]} postIds - Array of post IDs to remove optimization from.
*
* @returns {Promise<void>}
*/
export async function handleBulkRemoveOptimization(postIds) {
console.log('🗑️ Removing optimization for posts:', postIds);
const animationManager = new AnimationManager();
initializeBulkUI(postIds, animationManager);
const processingNotice = createDismissibleNotice(
sprintf(
// translators: %d: Number of posts having optimization removed.
_n(
'Removing optimization for %d post…',
'Removing optimization for %d posts…',
postIds.length,
'kadence-blocks'
),
postIds.length
),
NOTICE_TYPES.INFO
);
try {
const metadataResponse = await fetchPostsMetadata(postIds);
const { data: postsData, errors: metadataErrors } = metadataResponse;
if (metadataErrors && metadataErrors.length > 0) {
metadataErrors.forEach((error) => {
createNotice(`Post ID ${error.post_id}: ${error.message}`, NOTICE_TYPES.ERROR, true);
animationManager.stop(error.post_id);
updatePostLinkState(error.post_id, UI_STATES.REMOVE);
});
}
if (postsData.length > 0) {
const postIdsToDelete = postsData.map((post) => post.post_id);
const postPathsToDelete = postsData.map((post) => post.post_path);
console.log(`Removing optimization for ${postIdsToDelete.length} posts...`);
const deleteResponse = await bulkDeleteOptimization(postIdsToDelete, postPathsToDelete);
const { data: results } = deleteResponse;
results.successful.forEach((postId) => {
animationManager.stop(postId);
updatePostLinkState(postId, UI_STATES.OPTIMIZE);
});
if (results.failed && results.failed.length > 0) {
results.failed.forEach((failure) => {
createNotice(`Post ID ${failure.post_id}: ${failure.message}`, NOTICE_TYPES.ERROR, true);
animationManager.stop(failure.post_id);
updatePostLinkState(failure.post_id, UI_STATES.REMOVE);
});
}
if (results.successful.length > 0) {
createNotice(
sprintf(
// translators: %d: Number of posts.
_n(
'Removed optimization for %d post.',
'Removed optimization for %d posts.',
results.successful.length,
'kadence-blocks'
),
results.successful.length
),
NOTICE_TYPES.SUCCESS
);
}
if (results.failed && results.failed.length > 0) {
createNotice(
sprintf(
// translators: %d: Number of posts that failed.
_n(
'Failed to remove optimization for %d post.',
'Failed to remove optimization for %d posts.',
results.failed.length,
'kadence-blocks'
),
results.failed.length
),
NOTICE_TYPES.ERROR,
true
);
}
}
processingNotice?.remove();
} catch (error) {
console.error('❌ Bulk remove optimization failed:', error);
processingNotice?.remove();
createNotice(
__('Failed to fetch post data for bulk operation.', 'kadence-blocks'),
NOTICE_TYPES.ERROR,
true,
error
);
}
}

View File

@@ -0,0 +1,40 @@
import { handleBulkOptimize } from './handlers/optimize.js';
import { handleBulkRemoveOptimization } from './handlers/remove-optimization.js';
import { getSelectedPostIds } from './link-manager.js';
/**
* Initialize bulk action handlers.
*/
export function initBulkActions() {
const bulkActionForm = document.querySelector('#posts-filter');
if (!bulkActionForm) {
return;
}
bulkActionForm.addEventListener('submit', async (event) => {
// Only handle bulk actions when the Apply button (doaction) is clicked.
if (!event.submitter || event.submitter.id !== 'doaction') {
return;
}
const actionSelect = document.querySelector('#bulk-action-selector-top');
const action = actionSelect ? actionSelect.value : null;
if (action === 'kb_optimize_posts' || action === 'kb_optimize_remove') {
event.preventDefault();
const postIds = getSelectedPostIds();
if (postIds.length === 0) {
return;
}
if (action === 'kb_optimize_posts') {
await handleBulkOptimize(postIds);
} else if (action === 'kb_optimize_remove') {
await handleBulkRemoveOptimization(postIds);
}
}
});
}

View File

@@ -0,0 +1,30 @@
import { updateLinkState } from '../optimizer/ui-manager.js';
/**
* Find and update link state for a post.
*
* @param {number} postId - Post ID.
* @param {{class: string, text: string}} state - UI state to set (e.g., UI_STATES.OPTIMIZE).
*
* @returns {HTMLElement|null} The link element if found.
*/
export function updatePostLinkState(postId, state) {
const link = document.querySelector(`a[data-post-id="${postId}"]`);
if (link) {
updateLinkState(link, state);
}
return link;
}
/**
* Get all selected post IDs from checkboxes.
*
* @returns {number[]} Array of selected post IDs.
*/
export function getSelectedPostIds() {
const checkboxes = document.querySelectorAll('input[name="post[]"]:checked');
return Array.from(checkboxes).map((checkbox) => parseInt(checkbox.value, 10));
}

View File

@@ -0,0 +1,19 @@
import { UI_STATES } from '../optimizer/constants.js';
import { AnimationManager } from './animation-manager.js';
import { updatePostLinkState } from './link-manager.js';
/**
* Initialize UI state for bulk operation.
*
* @param {number[]} postIds - Array of post IDs.
* @param {AnimationManager} animationManager - Animation manager instance.
*/
export function initializeBulkUI(postIds, animationManager) {
postIds.forEach((postId) => {
const link = updatePostLinkState(postId, UI_STATES.OPTIMIZING);
if (link) {
animationManager.start(postId, link);
}
});
}

View File

@@ -0,0 +1,9 @@
export const NAME = 'kadence-optimizer';
// If you change this, the PHP version must match.
export const META_KEY = '_kb_optimizer_status';
// An excluded post is stored in post meta as -1.
export const STATUS_EXCLUDED = -1;
export const STATUS_UNOPTIMIZED = 0;
export const STATUS_OPTIMIZED = 1;

View File

@@ -0,0 +1,65 @@
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { external } from '@wordpress/icons';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { store as preferencesStore } from '@wordpress/preferences';
import { store as editorStore } from '@wordpress/editor';
import { addQueryArgs } from '@wordpress/url';
import { META_KEY, STATUS_OPTIMIZED } from '../meta/constants';
/**
* Generate a link to view a post in its optimized state even if logged in.
*/
export default function OptimizedViewLink() {
const { hasLoaded, permalink, isPublished, label, meta, showIconLabels } = useSelect((select) => {
const editor = select(editorStore);
const { get } = select(preferencesStore);
// Get post type for label.
const postTypeSlug = editor.getCurrentPostType();
const postType = select(coreStore).getPostType(postTypeSlug);
const postTypeLabel = postType?.labels?.singular_name || 'Post';
const dynamicLabel = __('View Optimized', 'kadence-blocks') + ' ' + postTypeLabel;
return {
permalink: editor.getPermalink(),
isPublished: editor.isCurrentPostPublished(),
label: dynamicLabel,
hasLoaded: !!postType,
meta: editor.getEditedPostAttribute('meta'),
showIconLabels: get('core', 'showIconLabels'),
};
}, []);
if (!isPublished || !permalink || !hasLoaded) {
return null;
}
if (meta !== undefined && meta[META_KEY] !== STATUS_OPTIMIZED) {
return null;
}
const optimizerData = window.kbOptimizer || {};
const nonce = optimizerData.token;
const url = addQueryArgs(permalink, {
perf_token: nonce,
kb_optimizer_preview: 1,
});
return (
<Button
style={{ paddingLeft: 0 }}
icon={external}
iconPosition={'right'}
label={label}
href={url}
target="_blank"
showTooltip={!showIconLabels}
size="compact"
>
{label}
</Button>
);
}

View File

@@ -0,0 +1,89 @@
import { analyzeWebsite, isSupported } from '@stellarwp/perf-analyzer-client';
import { OPTIMIZE_ROUTE } from './constants';
import apiFetch from '@wordpress/api-fetch';
/**
* Analyze a specific website URL and collect optimization data.
*
* @param {string} url - The URL to analyze.
* @param {number} postId - The post ID for reference.
* @param {string} postPath - The post path.
* @param {string} nonce - The nonce value.
*
* @returns {Promise<*>}
*/
export async function analyzeSite(url, postId, postPath, nonce) {
console.log(`🎯 Analyzing post ${postId}: ${url}`);
if (!isSupported()) {
console.log('❌ Performance analysis not supported in this browser');
throw new Error('Performance analysis not supported in this browser.');
}
try {
let results;
try {
results = await analyzeWebsite(url, true, '[Kadence]', nonce);
} catch (analysisError) {
console.error('❌ Website analysis failed:', analysisError);
throw analysisError;
}
const res = await apiFetch({
path: OPTIMIZE_ROUTE,
method: 'POST',
data: {
post_path: postPath,
post_id: postId,
results,
},
});
console.log(res);
console.log(`✅ Analysis complete for post ID ${postId}:`, results);
// Send a request to generate a hash of the optimized page and don't wait for the results.
// Log any network errors for debugging.
void fetch(url + '?kadence_set_optimizer_hash=1', { credentials: 'omit', keepalive: true }).catch((error) => {
console.error('❌ Failed to generate desktop hash:', error);
});
// Send a request as a fake mobile device to generate the mobile hash.
void fetch(url + '?kadence_set_optimizer_hash=1&kadence_is_mobile=1', {
credentials: 'omit',
keepalive: true,
}).catch((error) => {
console.error('❌ Failed to generate mobile hash:', error);
});
return res;
} catch (error) {
console.error('❌ Analysis failed:', error);
throw error;
}
}
/**
* Delete a post's optimization data.
*
* @param {number} postId The post ID to delete the optimization data for.
* @param {string} postPath The relative path for the post.
*
* @returns {Promise<*>}
*/
export async function removeOptimization(postId, postPath) {
try {
return await apiFetch({
path: OPTIMIZE_ROUTE,
method: 'DELETE',
data: {
post_path: postPath,
post_id: postId,
},
});
} catch (error) {
console.error(`❌ Failed to remove optimization for post ID ${postId}:`, error);
throw error;
}
}

View File

@@ -0,0 +1,35 @@
export const OPTIMIZE_ROUTE = '/kb-optimizer/v1/optimize';
// Get localized strings from WordPress.
const l10n = window.kbOptimizerL10n || {};
// Contains the perf_token.
export const OPTIMIZER_DATA = window.kbOptimizer || {};
// UI State Constants.
// If you update this, match to the Optimizer_Provider.php file.
export const UI_STATES = {
OPTIMIZE: {
class: 'kb-optimize-post',
text: l10n.runOptimizer || 'Run Optimizer',
},
REMOVE: {
class: 'kb-remove-post-optimization',
text: l10n.removeOptimization || 'Remove Optimization',
},
OPTIMIZING: {
class: 'kb-optimizing',
text: l10n.optimizing || 'Optimizing',
},
OPTIMIZED: {
text: l10n.optimized || 'Optimized',
},
NOT_OPTIMIZED: {
text: l10n.notOptimized || 'Not Optimized',
},
EXCLUDED: {
text: l10n.excluded || 'Excluded',
},
NOT_OPTIMIZABLE: {
text: l10n.notOptimizable || 'Not Optimizable',
},
};

View File

@@ -0,0 +1,178 @@
import { __, sprintf } from '@wordpress/i18n';
import { addAction } from '@wordpress/hooks';
import { dispatch, select } from '@wordpress/data';
import { getPathAndQueryString } from '@wordpress/url';
import { analyzeSite, removeOptimization } from './analyzer.js';
import { OPTIMIZER_DATA, UI_STATES } from './constants.js';
import { createNotice, NOTICE_TYPES } from '@kadence-bundled/admin-notices';
import { POST_SAVED_HOOK } from '../../../../../../src/extension/post-saved-event/constants';
import { updateLinkState, getPostTitle, animateDots } from './ui-manager.js';
import { META_KEY, STATUS_EXCLUDED } from '../meta/constants';
/**
* Handle the post-save hook for automatic optimization.
*/
export function setupPostSaveHandler() {
addAction(POST_SAVED_HOOK, 'kadence', async ({ post, permalink, suffix = '' }) => {
console.log('Post Saved', post, permalink, suffix);
if (!permalink) {
console.error('❌ No URL found for optimization. This is likely not a public post.');
return;
}
const postId = post.id;
const postUrl = permalink;
const postPath = getPathAndQueryString(permalink);
const nonce = OPTIMIZER_DATA.token;
// This is a public post without a pretty rewrite rule.
if (postPath.includes('?')) {
console.error('❌ Unable to optimize. This is a public post, but has no rewrite rules.');
return;
}
const meta = select('core/editor').getEditedPostAttribute('meta');
if (meta !== undefined && meta[META_KEY] === STATUS_EXCLUDED) {
console.warn(`⚠️ Post ID ${postId} was excluded from optimization via post meta.`);
return;
}
console.log('🚀 Starting optimization...', { postId, postUrl, postPath });
console.log(
'%c' + 'Warnings are expected!',
'color: #edd144; -webkit-text-stroke: 0.5px black; font-size: 28px; font-weight: bold;'
);
try {
const response = await analyzeSite(postUrl, postId, postPath, nonce);
console.log(response);
dispatch('core/notices').createSuccessNotice(__('Kadence Optimization Data Updated.', 'kadence-blocks'), {
type: 'snackbar',
});
// Force Gutenberg to reload the post + meta from the server so the View Optimized Page component can display in real-time.
dispatch('core').invalidateResolution('getEntityRecord', ['postType', post.type, post.id]);
} catch (error) {
console.error('❌ Optimization failed:', error);
dispatch('core/notices').createErrorNotice(
__('Failed to update Kadence Optimization Data.', 'kadence-blocks'),
{
type: 'snackbar',
}
);
}
});
}
/**
* Handle optimize link click from the post list table.
*
* @param {Event} event - The click event
*/
export async function handleOptimizeClick(event) {
event.preventDefault();
const postUrl = event.target.dataset.postUrl;
const postPath = event.target.dataset.postPath;
if (!postUrl) {
console.error('❌ No URL found for optimization');
return;
}
if (!postPath) {
console.error('❌ No Post Path found for optimization');
return;
}
const postId = parseInt(event.target.dataset.postId, 10);
const nonce = event.target.dataset.nonce;
console.log('🚀 Starting optimization...', { postId, postUrl });
console.log(
'%c' + 'Warnings are expected!',
'color: #edd144; -webkit-text-stroke: 0.5px black; font-size: 28px; font-weight: bold;'
);
// Show "Optimizing..." state with animated dots.
updateLinkState(event.target, UI_STATES.OPTIMIZING);
const animationInterval = animateDots(event.target);
const postTitle = getPostTitle(event.target, postId);
try {
const response = await analyzeSite(postUrl, postId, postPath, nonce);
console.log(response);
// Clear the animation interval.
clearInterval(animationInterval);
// Update link state to show "Remove Optimization".
updateLinkState(event.target, UI_STATES.REMOVE);
createNotice(
sprintf(
// translators: %s: The post title or post ID.
__('🎉 "%s" is now Optimized!', 'kadence-blocks'),
postTitle
)
);
} catch (error) {
console.error('❌ Optimization failed:', error);
// Clear the animation interval.
clearInterval(animationInterval);
// Reset to original state.
updateLinkState(event.target, UI_STATES.OPTIMIZE);
createNotice(
// translators: %s: The post title or post ID.
sprintf(__('Optimization failed for "%s"', 'kadence-blocks'), postTitle),
NOTICE_TYPES.ERROR,
true,
error
);
}
}
/**
* Handle remove optimization link click in the post list table.
*
* @param {Event} event - The click event
*/
export async function handleRemoveOptimizationClick(event) {
event.preventDefault();
const postId = parseInt(event.target.dataset.postId, 10);
const postPath = event.target.dataset.postPath;
console.log('Removing optimization...', { postId, postPath });
try {
const response = await removeOptimization(postId, postPath);
console.log(response);
// Update link state to show "Run Optimizer".
updateLinkState(event.target, UI_STATES.OPTIMIZE);
const postTitle = getPostTitle(event.target, postId);
createNotice(
sprintf(
// translators: %s: The post title or post ID.
__('Optimization data removed for "%s".', 'kadence-blocks'),
postTitle
)
);
} catch (error) {
console.error('❌ Failed to remove optimization:', error);
createNotice(__('An error occurred', 'kadence-blocks'), NOTICE_TYPES.ERROR, true, error);
}
}

View File

@@ -0,0 +1,13 @@
import { initOptimizer } from './initializer.js';
import { initBulkActions } from '../bulk-actions';
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initOptimizer();
initBulkActions();
});
} else {
initOptimizer();
initBulkActions();
}

View File

@@ -0,0 +1,19 @@
import { setupPostSaveHandler, handleOptimizeClick, handleRemoveOptimizationClick } from './event-handlers.js';
/**
* Initialize the optimizer.
*/
export function initOptimizer() {
// Set up the post-save handler for automatic optimization.
setupPostSaveHandler();
// Add click event listeners to all optimize links on the post list table.
document.addEventListener('click', async function (event) {
// Check if the clicked element is an optimize link.
if (event.target.classList.contains('kb-optimize-post')) {
await handleOptimizeClick(event);
} else if (event.target.classList.contains('kb-remove-post-optimization')) {
await handleRemoveOptimizationClick(event);
}
});
}

View File

@@ -0,0 +1,53 @@
import { UI_STATES } from './constants.js';
/**
* Update the optimizer link state in the post list table.
*
* @param {HTMLElement} link - The link element to update.
* @param {{class: string, text: string}} state - The state to set (OPTIMIZE, REMOVE, or OPTIMIZING).
*/
export function updateLinkState(link, state) {
// Remove all state classes.
link.className = link.className
.replace(UI_STATES.OPTIMIZE.class, '')
.replace(UI_STATES.REMOVE.class, '')
.replace(UI_STATES.OPTIMIZING.class, '')
.trim();
// Add the new state class.
link.className += ' ' + state.class;
// Update text content.
link.textContent = state.text;
}
/**
* Get the post title from the row containing the given element in the post list table.
*
* @param {HTMLElement} element - The element to find the row for.
* @param {number} postId - The post ID as fallback.
*
* @returns {string} The post title or fallback text.
*/
export function getPostTitle(element, postId) {
const row = element.closest('tr');
const rowTitle = row ? row.querySelector('.row-title')?.textContent?.trim() : null;
return rowTitle || `Post ID: ${postId}`;
}
/**
* Animate the dots in the "Optimizing" text.
*
* @param {HTMLElement} link - The link element to animate.
*/
export function animateDots(link) {
let dots = 0;
const baseText = link.textContent;
return setInterval(() => {
dots = (dots + 1) % 4;
const dotsText = '.'.repeat(dots);
link.textContent = baseText + dotsText;
}, 500);
}