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:
@@ -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 ];
|
||||
}
|
||||
}
|
||||
@@ -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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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};
|
||||
";
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
";
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 output‑buffer 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createLazyLoader } from './lazy-loader';
|
||||
|
||||
const lazyLoader = createLazyLoader();
|
||||
lazyLoader.observeElements();
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' ],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ] )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 ),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' ),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Kadence Optimizer Post List Table Column styles */
|
||||
.fixed {
|
||||
.column-kadence_optimizer {
|
||||
width: 11em;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user