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, '