diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index f6d6c710e..9a713c00f 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -27,3 +27,7 @@ jobs: npm run lint env: CI: true + - name: Run unit tests + run: npm run test:unit + env: + CI: true diff --git a/composer.json b/composer.json index 6e11b3148..e1e5fb532 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "phpunit/phpunit": "9.6.5", "yoast/phpunit-polyfills": "^4.0", "phpstan/phpstan": "^2.1", - "php-stubs/woocommerce-stubs": "^10.8", + "php-stubs/woocommerce-stubs": "^11.0", "php-stubs/acf-pro-stubs": "^6.8", "wpackagist-plugin/woocommerce": "*", "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index f81925300..439b236c2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "556f279ce85f25f708f60329f1f7067c", + "content-hash": "5a247aa373965f7dae85a8a3c4b3cb01", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.58", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-29T08:38:52+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "enshrined/svg-sanitize", @@ -1044,24 +1044,24 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v10.9.1", + "version": "v11.0.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "e6d583b9fa05b3f5ff67f86c939b45a0c35b8444" + "reference": "923dd29055713886efd7f85ea2510b9c6955a714" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/e6d583b9fa05b3f5ff67f86c939b45a0c35b8444", - "reference": "e6d583b9fa05b3f5ff67f86c939b45a0c35b8444", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/923dd29055713886efd7f85ea2510b9c6955a714", + "reference": "923dd29055713886efd7f85ea2510b9c6955a714", "shasum": "" }, "require": { - "php-stubs/wordpress-stubs": "^5.3 || ^6.0" + "php-stubs/wordpress-stubs": "^5.3 || ^6.0 || ^7.0" }, "require-dev": { "php": "~7.1 || ~8.0", - "php-stubs/generator": "^0.8.0" + "php-stubs/generator": "^0.9.0" }, "suggest": { "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", @@ -1082,9 +1082,9 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v10.9.1" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.0.0" }, - "time": "2026-06-25T10:47:57+00:00" + "time": "2026-08-04T19:31:58+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -1202,27 +1202,27 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.4.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/fa4b8d051e278072928e32d817456a7fdb57b6ca", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.1.6", + "phpcsstandards/phpcsdevcs": "^1.2.0", "phpcsstandards/phpcsdevtools": "^1.2.1", "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, @@ -1280,32 +1280,32 @@ "type": "thanks_dev" } ], - "time": "2025-06-14T07:40:39+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.1.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd" + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "ext-filter": "*", "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.1.6", + "phpcsstandards/phpcsdevcs": "^1.2.0", "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" }, "type": "phpcodesniffer-standard", @@ -1373,7 +1373,7 @@ "type": "thanks_dev" } ], - "time": "2025-08-10T01:04:45+00:00" + "time": "2026-07-27T10:28:41+00:00" }, { "name": "phpstan/phpstan", @@ -3047,16 +3047,16 @@ }, { "name": "wp-coding-standards/wpcs", - "version": "3.2.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af" + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/d2421de7cec3274ae622c22c744de9a62c7925af", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", "shasum": "" }, "require": { @@ -3064,17 +3064,17 @@ "ext-libxml": "*", "ext-tokenizer": "*", "ext-xmlreader": "*", - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.4.0", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0" + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^8.0 || ^9.0" }, "suggest": { "ext-iconv": "For improved results", @@ -3109,7 +3109,7 @@ "type": "custom" } ], - "time": "2025-07-24T20:08:31+00:00" + "time": "2026-07-27T11:53:23+00:00" }, { "name": "wpackagist-plugin/woocommerce", diff --git a/inc/class-base-css.php b/inc/class-base-css.php index 0739628ba..aa16c163e 100644 --- a/inc/class-base-css.php +++ b/inc/class-base-css.php @@ -683,10 +683,10 @@ public function get_animation_classes( $blocks ) { ); foreach ( $blocks as $block ) { - if ( isset( $block['attrs']['className'] ) && ! empty( $block['attrs']['className'] ) ) { - if ( preg_match( '/\banimated\b/', $block['attrs']['className'] ) ) { - $classes = array_merge( $classes, explode( ' ', trim( $block['attrs']['className'] ) ) ); - } + $block_classes = Registration::get_class_name( isset( $block['attrs'] ) ? $block['attrs'] : array() ); + + if ( ! empty( $block_classes ) && preg_match( '/\banimated\b/', $block_classes ) ) { + $classes = array_merge( $classes, explode( ' ', trim( $block_classes ) ) ); } if ( isset( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { diff --git a/inc/class-main.php b/inc/class-main.php index 39d1aed7d..ca415d8a3 100644 --- a/inc/class-main.php +++ b/inc/class-main.php @@ -529,11 +529,29 @@ public function generate_svg_attachment_metadata( $metadata, $attachment_id ) { } $svg_path = get_attached_file( $attachment_id ); + + if ( empty( $svg_path ) || ! file_exists( $svg_path ) || ! is_readable( $svg_path ) ) { + return $metadata; + } + $filename = basename( $svg_path ); - $svg = simplexml_load_file( $svg_path ); + // Keep malformed SVG errors internal instead of emitting PHP warnings. + $previous_state = libxml_use_internal_errors( true ); + $svg = simplexml_load_file( $svg_path ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous_state ); + + if ( false === $svg ) { + return $metadata; + } + $attributes = $svg->attributes(); + if ( ! isset( $attributes->width, $attributes->height ) ) { + return $metadata; + } + // Update metadata with SVG dimensions. $metadata['width'] = intval( (string) $attributes->width ); $metadata['height'] = intval( (string) $attributes->height ); diff --git a/inc/class-registration.php b/inc/class-registration.php index fff5da32a..5ae5a2b0d 100644 --- a/inc/class-registration.php +++ b/inc/class-registration.php @@ -97,6 +97,38 @@ public static function get_editor_global_defaults() { return is_object( $defaults ) ? $defaults : new \stdClass(); } + /** + * Get the `className` attribute of a block as a string. + * + * @param mixed $attributes Block attributes. + * @return string + */ + public static function get_class_name( $attributes ) { + if ( ! is_array( $attributes ) || ! isset( $attributes['className'] ) ) { + return ''; + } + + $class_name = $attributes['className']; + + if ( is_array( $class_name ) ) { + // Flatten nested arrays and drop anything that is not printable. + $flat = array(); + + array_walk_recursive( + $class_name, + function ( $value ) use ( &$flat ) { + if ( is_scalar( $value ) ) { + $flat[] = (string) $value; + } + } + ); + + return implode( ' ', $flat ); + } + + return is_scalar( $class_name ) ? (string) $class_name : ''; + } + /** * Initialize the class */ @@ -633,6 +665,7 @@ function () { 'nonce' => wp_create_nonce( 'wp_rest' ), 'messages' => array( 'submission' => __( 'Form submission from', 'otter-blocks' ), + 'success' => __( 'Success', 'otter-blocks' ), 'captcha-not-loaded' => __( 'Captcha is not loaded. Please check your browser plugins to allow it.', 'otter-blocks' ), 'check-captcha' => __( 'Please check the captcha.', 'otter-blocks' ), 'invalid-email' => __( 'The email address is invalid!', 'otter-blocks' ), @@ -968,26 +1001,80 @@ public function register_blocks() { ); } - if ( isset( $dynamic_blocks[ $block ] ) && class_exists( $dynamic_blocks[ $block ] ) ) { - $classname = $dynamic_blocks[ $block ]; - $renderer = new $classname(); + $renderer = isset( $dynamic_blocks[ $block ] ) ? self::instantiate_safely( $dynamic_blocks[ $block ] ) : null; - if ( method_exists( $renderer, 'render' ) ) { - register_block_type_from_metadata( - $metadata_file, - array( - 'render_callback' => array( $renderer, 'render' ), - ) - ); + if ( null !== $renderer && method_exists( $renderer, 'render' ) ) { + register_block_type_from_metadata( + $metadata_file, + array( + 'render_callback' => array( $renderer, 'render' ), + ) + ); - continue; - } + continue; } register_block_type_from_metadata( $metadata_file ); } } + /** + * Instantiate a class without ever fataling the request. + * + * @param mixed $classname Class name to instantiate. + * @return object|null The instance, or null when it cannot be built. + */ + private static function instantiate_safely( $classname ) { + if ( ! is_string( $classname ) || '' === trim( $classname ) ) { + self::log_skipped_class( $classname, 'is not a class name' ); + + return null; + } + + try { + // An autoloader can throw or fatal on its own; keep it inside the try. + if ( ! class_exists( $classname ) ) { + self::log_skipped_class( $classname, 'could not be loaded' ); + + return null; + } + + $reflection = new \ReflectionClass( $classname ); + + if ( ! $reflection->isInstantiable() ) { + self::log_skipped_class( $classname, 'is not instantiable' ); + + return null; + } + + $constructor = $reflection->getConstructor(); + + if ( null !== $constructor && $constructor->getNumberOfRequiredParameters() > 0 ) { + self::log_skipped_class( $classname, 'requires constructor arguments' ); + + return null; + } + + return $reflection->newInstance(); + } catch ( \Throwable $e ) { + // Covers Error too: a missing dependency inside the constructor. + self::log_skipped_class( $classname, 'threw while being instantiated: ' . $e->getMessage() ); + + return null; + } + } + + /** + * Log a class the plugin had to skip. + * + * @param mixed $classname Class name, or whatever was given in its place. + * @param string $reason Why it was skipped. + * @return void + */ + private static function log_skipped_class( $classname, $reason ) { + error_log( '[Otter Blocks] Skipped ' . ( is_string( $classname ) ? $classname : gettype( $classname ) ) . ': ' . $reason . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + /** * Initialize AMP blocks. * @@ -1002,10 +1089,10 @@ public function init_amp_blocks() { ); foreach ( $classnames as $classname ) { - $classname = new $classname(); + $instance = self::instantiate_safely( $classname ); - if ( method_exists( $classname, 'instance' ) ) { - $classname->instance(); + if ( null !== $instance && method_exists( $instance, 'instance' ) ) { + $instance->instance(); } } } @@ -1075,7 +1162,7 @@ public function subscribe_fa( $block_content, $block ) { $has_navigation_block = \WP_Block_Type_Registry::get_instance()->is_registered( 'core/navigation' ); if ( $has_navigation_block && ( 'core/navigation-link' === $block['blockName'] || 'core/navigation-submenu' === $block['blockName'] ) ) { - if ( isset( $block['attrs']['className'] ) && strpos( $block['attrs']['className'], 'fa-' ) !== false ) { + if ( strpos( self::get_class_name( isset( $block['attrs'] ) ? $block['attrs'] : array() ), 'fa-' ) !== false ) { self::$is_fa_loaded = true; // See the src/blocks/plugins/menu-icons/inline.css file for where this comes from. @@ -1117,7 +1204,7 @@ public function load_sticky( $block_content, $block ) { return $block_content; } - if ( isset( $block['attrs']['className'] ) && false !== strpos( $block['attrs']['className'], 'o-sticky' ) ) { + if ( false !== strpos( self::get_class_name( isset( $block['attrs'] ) ? $block['attrs'] : array() ), 'o-sticky' ) ) { $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/sticky.asset.php'; wp_enqueue_script( 'otter-sticky', @@ -1196,7 +1283,7 @@ public function load_condition_hide_on_styles( $block_content, $block ) { * @access public */ public static function condition_hide_on_style() { - echo ''; + echo ''; } /** diff --git a/inc/css/blocks/class-posts-css.php b/inc/css/blocks/class-posts-css.php index 19da67532..5e09dab5c 100644 --- a/inc/css/blocks/class-posts-css.php +++ b/inc/css/blocks/class-posts-css.php @@ -8,6 +8,7 @@ namespace ThemeIsle\GutenbergBlocks\CSS\Blocks; use ThemeIsle\GutenbergBlocks\Base_CSS; +use ThemeIsle\GutenbergBlocks\Registration; use ThemeIsle\GutenbergBlocks\CSS\CSS_Utility; @@ -429,8 +430,7 @@ function ( $position, $css_variable ) { return $value[ $position ]; }, 'condition' => function ( $attrs ) { - // @phpstan-ignore-next-line - return isset( $attrs['className'] ) && strpos( $attrs['className'], 'is-style-tiled' ) !== false; + return strpos( Registration::get_class_name( $attrs ), 'is-style-tiled' ) !== false; }, ); }, diff --git a/inc/integrations/api/form-response-data.php b/inc/integrations/api/form-response-data.php index e696dc223..f9e678ae5 100644 --- a/inc/integrations/api/form-response-data.php +++ b/inc/integrations/api/form-response-data.php @@ -105,8 +105,8 @@ public function __construct() { $this->response['success'] = false; $this->response['reasons'] = array(); $this->response['code'] = self::SUCCESS_EMAIL_SEND; - $this->response['displayError'] = 'Error. Please try again.'; - $this->response['submitMessage'] = 'Success'; + $this->response['displayError'] = __( 'Error. Please try again.', 'otter-blocks' ); + $this->response['submitMessage'] = __( 'Success', 'otter-blocks' ); } /** diff --git a/inc/plugins/class-atomic-wind-blocks.php b/inc/plugins/class-atomic-wind-blocks.php index 1ac4cd425..db9815003 100644 --- a/inc/plugins/class-atomic-wind-blocks.php +++ b/inc/plugins/class-atomic-wind-blocks.php @@ -15,6 +15,13 @@ */ class Atomic_Wind_Blocks { + /** + * CSS version for the Atomic Wind blocks. + * + * @var string + */ + const ATOMIC_WIND_CSS_VERSION = '1.0.0'; + /** * Whether we are currently inside a query loop render. * @@ -326,7 +333,7 @@ public function output_singular_css() { $this->expected[ $queried->ID ] = substr_count( $queried->post_content, '