'primary',
@@ -38,8 +46,8 @@
'component_id' => $_id,
'menu_class' => 'primary-menu-ul nav-ul' . $additional_menu_class,
'container' => 'ul',
- 'walker' => '\Neve\Views\Nav_Walker',
- 'fallback_cb' => '\Neve\Views\Nav_Walker::fallback',
+ 'walker' => $has_nav_walker ? '\Neve\Views\Nav_Walker' : '',
+ 'fallback_cb' => $has_nav_walker ? '\Neve\Views\Nav_Walker::fallback' : 'wp_page_menu',
'echo' => false,
]
);
diff --git a/inc/admin/dashboard/main.php b/inc/admin/dashboard/main.php
index d7dd79bdec..210f1cff08 100755
--- a/inc/admin/dashboard/main.php
+++ b/inc/admin/dashboard/main.php
@@ -22,13 +22,13 @@ class Main {
/**
* Changelog Handler.
*
- * @var Changelog_Handler
+ * @var Changelog_Handler|null
*/
private $cl_handler;
/**
* Plugin Helper instance.
*
- * @var Plugin_Helper
+ * @var Plugin_Helper|null
*/
private $plugin_helper;
/**
@@ -42,8 +42,57 @@ class Main {
* Main constructor.
*/
public function __construct() {
- $this->plugin_helper = new Plugin_Helper();
- $this->cl_handler = new Changelog_Handler();
+ if ( class_exists( Plugin_Helper::class ) ) {
+ $this->plugin_helper = new Plugin_Helper();
+ }
+
+ if ( class_exists( Changelog_Handler::class ) ) {
+ $this->cl_handler = new Changelog_Handler();
+ }
+ }
+
+ /**
+ * Read a changelog through the handler, if there is one.
+ *
+ * @param string $path Absolute path to the changelog file.
+ *
+ * @return mixed[]
+ */
+ private function get_changelog( $path ) {
+ if ( null === $this->cl_handler ) {
+ return array();
+ }
+
+ return $this->cl_handler->get_changelog( $path );
+ }
+
+ /**
+ * Plugin action data for a recommended plugin card.
+ *
+ * @param string $slug Plugin slug.
+ *
+ * @return array
+ */
+ private function get_plugin_actions( $slug ) {
+ if ( null === $this->plugin_helper ) {
+ return [
+ 'cta' => 'install',
+ 'path' => '',
+ 'activate' => '',
+ 'deactivate' => '',
+ 'network' => false,
+ 'version' => '0.0.0',
+ ];
+ }
+
+ return [
+ 'cta' => $this->plugin_helper->get_plugin_state( $slug ),
+ 'path' => $this->plugin_helper->get_plugin_path( $slug ),
+ 'activate' => $this->plugin_helper->get_plugin_action_link( $slug ),
+ 'deactivate' => $this->plugin_helper->get_plugin_action_link( $slug, 'deactivate' ),
+ 'network' => $this->plugin_helper->get_is_network_wide( $slug ),
+ 'version' => $this->plugin_helper->get_plugin_version( $slug, '0.0.0' ),
+ ];
}
/**
@@ -350,7 +399,7 @@ private function get_localization() {
'Themeisle' . esc_html__( '(opens in a new tab)', 'neve' ) . ''
),
],
- 'changelog' => $this->cl_handler->get_changelog( get_template_directory() . '/CHANGELOG.md' ),
+ 'changelog' => $this->get_changelog( get_template_directory() . '/CHANGELOG.md' ),
'onboarding' => [],
'hasFileSystem' => WP_Filesystem(),
'hidePluginsTab' => apply_filters( 'neve_hide_useful_plugins', ! array_key_exists( 'useful_plugins', $old_about_config ) ),
@@ -369,7 +418,7 @@ private function get_localization() {
'orbitFox' => array(
'isInstalled' => file_exists( WP_PLUGIN_DIR . '/themeisle-companion/themeisle-companion.php' ),
'isActive' => class_exists( 'Orbit_Fox' ),
- 'activationUrl' => $this->plugin_helper->get_plugin_action_link( 'themeisle-companion' ),
+ 'activationUrl' => null === $this->plugin_helper ? '' : $this->plugin_helper->get_plugin_action_link( 'themeisle-companion' ),
'data' => class_exists( 'Orbit_Fox' ) ? get_option( 'obfx_data' ) : array(),
),
];
@@ -378,7 +427,7 @@ private function get_localization() {
$installed_plugins = get_plugins();
$is_otter_installed = array_key_exists( 'otter-pro/otter-pro.php', $installed_plugins );
$is_sparks_installed = array_key_exists( 'sparks-for-woocommerce/sparks-for-woocommerce.php', $installed_plugins );
- $data['changelogPro'] = $this->cl_handler->get_changelog( NEVE_PRO_PATH . '/CHANGELOG.md' );
+ $data['changelogPro'] = $this->get_changelog( NEVE_PRO_PATH . '/CHANGELOG.md' );
$data['isOtterProInstalled'] = $is_otter_installed;
$data['otterProInstall'] = $is_otter_installed ? esc_url( wp_nonce_url( admin_url( 'plugins.php?action=activate&plugin=otter-pro%2Fotter-pro.php&plugin_status=all&paged=1&s' ), 'activate-plugin_otter-pro/otter-pro.php' ) ) : esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=install_otter_pro' ), 'install_otter_pro' ) );
$data['sparksInstallActivateEndpoint'] = $is_sparks_installed ? esc_url( wp_nonce_url( admin_url( 'plugins.php?action=activate&plugin=sparks-for-woocommerce%2Fsparks-for-woocommerce.php&plugin_status=all&paged=1&s' ), 'activate-plugin_sparks-for-woocommerce/sparks-for-woocommerce.php' ) ) : esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=install_sparks' ), 'install_sparks' ) );
@@ -907,19 +956,7 @@ private function get_recommended_plugins() {
continue;
}
- $action = $this->plugin_helper->get_plugin_state( $slug );
-
- $plugins[ $slug ] = array_merge(
- [
- 'cta' => $action,
- 'path' => $this->plugin_helper->get_plugin_path( $slug ),
- 'activate' => $this->plugin_helper->get_plugin_action_link( $slug ),
- 'deactivate' => $this->plugin_helper->get_plugin_action_link( $slug, 'deactivate' ),
- 'network' => $this->plugin_helper->get_is_network_wide( $slug ),
- 'version' => $this->plugin_helper->get_plugin_version( $slug, '0.0.0' ),
- ],
- $args
- );
+ $plugins[ $slug ] = array_merge( $this->get_plugin_actions( $slug ), $args );
}
return $plugins;
diff --git a/inc/admin/metabox/manager.php b/inc/admin/metabox/manager.php
index 2253bdc0b5..d44db72ac8 100755
--- a/inc/admin/metabox/manager.php
+++ b/inc/admin/metabox/manager.php
@@ -38,6 +38,13 @@ final class Manager {
*/
private $control_classes;
+ /**
+ * Meta keys registered for the block editor sidebar, keyed by meta key.
+ *
+ * @var array
+ */
+ private $registered_meta_keys = array();
+
/**
* Init function
*/
@@ -53,6 +60,7 @@ public function init() {
*/
add_action( 'init', array( $this, 'neve_register_meta' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'meta_sidebar_script_enqueue' ) );
+ add_filter( 'is_protected_meta', array( $this, 'protect_post_sidebar_meta' ), 10, 3 );
}
/**
@@ -331,7 +339,30 @@ public function neve_register_meta() {
$control['id'],
$meta_settings
);
+
+ $this->registered_meta_keys[ $control['id'] ] = true;
+ }
+ }
+
+ /**
+ * Mark the meta data as protected.
+ *
+ * @param bool $is_protected whether the key is protected.
+ * @param string $meta_key the meta key.
+ * @param string $meta_type the type of meta object this key belongs to.
+ *
+ * @return bool
+ */
+ public function protect_post_sidebar_meta( $is_protected, $meta_key, $meta_type ) {
+ if (
+ $meta_type === 'post' &&
+ isset( $this->registered_meta_keys[ $meta_key ] ) &&
+ $this->registered_meta_keys[ $meta_key ]
+ ) {
+ return true;
}
+
+ return $is_protected;
}
/**
@@ -423,10 +454,13 @@ private function get_post_elements_default_order() {
$default_order = $this->get_v4_defaults( 'neve_layout_single_post_elements_order', $this->post_ordering() );
$content_order = get_theme_mod( 'neve_layout_single_post_elements_order', wp_json_encode( $default_order ) );
- if ( ! is_string( $content_order ) ) {
- $content_order = wp_json_encode( $default_order );
+ if ( is_string( $content_order ) ) {
+ $content_order = json_decode( $content_order, true );
+ }
+
+ if ( ! is_array( $content_order ) ) {
+ $content_order = $default_order;
}
- $content_order = json_decode( $content_order, true );
if ( empty( $content_order ) ) {
return wp_json_encode( $content_order );
}
diff --git a/inc/compatibility/elementor.php b/inc/compatibility/elementor.php
index d2dca99088..1b4f945956 100644
--- a/inc/compatibility/elementor.php
+++ b/inc/compatibility/elementor.php
@@ -134,12 +134,16 @@ public function enqueue() {
/**
* Filter rest responses to add Neve Palette Colors to pages using Elementor.
*
- * @param \WP_REST_Response $response request response.
- * @param array $handler request handler.
- * @param \WP_REST_Request $request rest request.
- * @return \WP_REST_Response
+ * @param \WP_REST_Response|\WP_Error $response request response.
+ * @param array $handler request handler.
+ * @param \WP_REST_Request $request rest request.
+ * @return \WP_REST_Response|\WP_Error
*/
public function alter_global_colors_front_end( $response, $handler, \WP_REST_Request $request ) {
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
$route = $request->get_route();
$rest_to_slugs = [
'nvprimaryaccent' => 'nv-primary-accent',
@@ -178,12 +182,16 @@ public function alter_global_colors_front_end( $response, $handler, \WP_REST_Req
/**
* Filter rest responses to add Neve Palette Colors to Elementor.
*
- * @param \WP_REST_Response $response request response.
- * @param array $handler request handler.
- * @param \WP_REST_Request $request rest request.
- * @return \WP_REST_Response
+ * @param \WP_REST_Response|\WP_Error $response request response.
+ * @param array $handler request handler.
+ * @param \WP_REST_Request $request rest request.
+ * @return \WP_REST_Response|\WP_Error
*/
public function alter_global_colors_in_picker( $response, $handler, \WP_REST_Request $request ) {
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
$route = $request->get_route();
if ( $route !== '/elementor/v1/globals' ) {
diff --git a/inc/core/admin.php b/inc/core/admin.php
index b38b6a7310..d234886dec 100644
--- a/inc/core/admin.php
+++ b/inc/core/admin.php
@@ -264,6 +264,11 @@ public function migrate_theme_mods_for_new_skin( $theme_mods ) {
if ( ! neve_is_new_skin() ) {
return $theme_mods;
}
+
+ if ( ! class_exists( Mods_Migrator::class ) ) {
+ return $theme_mods;
+ }
+
$migrator = new Mods_Migrator( $theme_mods );
return $migrator->get_migrated_mods();
diff --git a/inc/core/core_loader.php b/inc/core/core_loader.php
index 462bcceab9..01b1385f5c 100644
--- a/inc/core/core_loader.php
+++ b/inc/core/core_loader.php
@@ -149,6 +149,10 @@ private function define_modules() {
private function load_modules() {
do_action( 'neve_before_modules_load' );
+ if ( ! class_exists( Factory::class ) ) {
+ return;
+ }
+
$factory = new Factory( $this->features );
$factory->load_modules();
}
@@ -164,10 +168,9 @@ private function define_hooks() {
if ( is_customize_preview() ) {
Mods::$no_cache = true;
}
- $admin = new Admin();
- add_action( 'init', array( $admin, 'load_site_import' ), 20 );
- add_action( 'admin_enqueue_scripts', array( $admin, 'register_react_components' ), 0 );
- add_action( 'ti-about-after-sidebar-content', array( $admin, 'render_logger_toggle' ) );
+ if ( class_exists( Admin::class ) ) {
+ $this->load_admin_hooks();
+ }
$key_lite = str_replace( '-', '_', basename( get_template_directory() ) );
add_filter(
@@ -176,6 +179,34 @@ function () {
return [ 'mods' => array_filter( get_theme_mods() ) ];
}
);
+
+ if ( class_exists( Front_End::class ) ) {
+ $this->load_front_end_hooks();
+ }
+ }
+
+ /**
+ * Load admin hooks.
+ *
+ * @access private
+ *
+ * @return void
+ */
+ private function load_admin_hooks() {
+ $admin = new Admin();
+ add_action( 'init', array( $admin, 'load_site_import' ), 20 );
+ add_action( 'admin_enqueue_scripts', array( $admin, 'register_react_components' ), 0 );
+ add_action( 'ti-about-after-sidebar-content', array( $admin, 'render_logger_toggle' ) );
+ }
+
+ /**
+ * Load front end hooks.
+ *
+ * @access private
+ *
+ * @return void
+ */
+ private function load_front_end_hooks() {
$front_end = new Front_End();
add_action( 'wp_enqueue_scripts', array( $front_end, 'enqueue_scripts' ) );
add_action( 'after_setup_theme', array( $front_end, 'setup_theme' ) );
diff --git a/inc/core/dynamic_css.php b/inc/core/dynamic_css.php
index 90b8838789..53546dbe78 100644
--- a/inc/core/dynamic_css.php
+++ b/inc/core/dynamic_css.php
@@ -41,6 +41,10 @@ public function legacy_style() {
$desktop_css = '';
$tablet_css = '';
foreach ( $classes as $class ) {
+ if ( ! class_exists( $class ) ) {
+ continue;
+ }
+
$object = new $class();
$object->init();
$mobile_css .= $object->get_style( 'mobile' );
@@ -68,14 +72,21 @@ public function enqueue() {
$this->legacy_style();
}
- $this->generator = $is_for_gutenberg ? new Gutenberg() : new Frontend();
- $_subscribers = $this->generator->get();
+ $generator_class = $is_for_gutenberg ? Gutenberg::class : Frontend::class;
+ $generated_css = '';
+
+ if ( class_exists( $generator_class ) ) {
+ $this->generator = new $generator_class();
+ $_subscribers = $this->generator->get();
- $_subscribers = array_merge( $_subscribers, apply_filters( 'neve_style_subscribers', [] ) );
+ $_subscribers = array_merge( $_subscribers, apply_filters( 'neve_style_subscribers', [] ) );
- $this->generator->set( $_subscribers );
+ $this->generator->set( $_subscribers );
+
+ $generated_css = $this->generator->generate();
+ }
- $style = apply_filters( 'neve_dynamic_style_output', $this->generator->generate(), $is_for_gutenberg ? 'gutenberg' : 'frontend' );
+ $style = apply_filters( 'neve_dynamic_style_output', $generated_css, $is_for_gutenberg ? 'gutenberg' : 'frontend' );
$style .= self::get_root_css();
diff --git a/inc/core/factory.php b/inc/core/factory.php
index 3d3ae31f24..c78a2cb1c7 100644
--- a/inc/core/factory.php
+++ b/inc/core/factory.php
@@ -51,8 +51,21 @@ public function __construct( $modules, $namespace = '\\Neve\\' ) {
public function load_modules() {
foreach ( $this->modules as $module_name ) {
$module = $this->build( $module_name );
- if ( $module !== null ) {
+ if ( $module === null ) {
+ continue;
+ }
+ if ( method_exists( $module, 'init' ) ) {
$module->init();
+ continue;
+ }
+
+ $message = sprintf(
+ 'Module "%s" was built but does not implement an init() method.',
+ $this->namespace . $module_name
+ );
+ if ( function_exists( '_doing_it_wrong' ) ) {
+ _doing_it_wrong( __METHOD__, esc_html( $message ), '4.2.12' );
+ continue;
}
}
}
diff --git a/inc/core/settings/mods.php b/inc/core/settings/mods.php
index e4bdd40903..b4b934f574 100644
--- a/inc/core/settings/mods.php
+++ b/inc/core/settings/mods.php
@@ -200,7 +200,13 @@ public static function set( $key, $value ) {
* @return mixed
*/
public static function to_json( $key, $default = false, $as_array = true ) {
- return json_decode( self::get( $key, $default ), $as_array );
+ $value = self::get( $key, $default );
+
+ if ( is_array( $value ) ) {
+ $value = wp_json_encode( $value );
+ }
+
+ return json_decode( $value, $as_array );
}
/**
diff --git a/inc/core/settings/mods_migrator.php b/inc/core/settings/mods_migrator.php
index 41478cbfde..bd3d4ee62b 100644
--- a/inc/core/settings/mods_migrator.php
+++ b/inc/core/settings/mods_migrator.php
@@ -124,6 +124,10 @@ private function migrate_mods() {
* @return void
*/
private function attempt_builders_migration() {
+ if ( ! class_exists( Builder_Migrator::class ) ) {
+ return;
+ }
+
$hfg_migrator = new Builder_Migrator();
foreach ( $this->builder_map as $builder ) {
diff --git a/inc/core/styles/frontend.php b/inc/core/styles/frontend.php
index f355571d94..c1a3dc4ce8 100644
--- a/inc/core/styles/frontend.php
+++ b/inc/core/styles/frontend.php
@@ -376,6 +376,9 @@ public function setup_layout_subscribers() {
Dynamic_Selector::META_IS_RESPONSIVE => true,
Dynamic_Selector::META_FILTER => function ( $css_prop, $value, $meta, $device ) {
$width = Mods::to_json( Config::MODS_CONTAINER_WIDTH );
+ if ( ! isset( $width[ $device ] ) || ! is_numeric( $width[ $device ] ) || ( $device === Dynamic_Selector::DESKTOP && ! is_numeric( $value ) ) ) {
+ return '';
+ }
if ( $device === Dynamic_Selector::DESKTOP ) {
return sprintf( 'max-width:%spx', round( ( $value / 100 ) * $width[ $device ] - Config::CONTENT_DEFAULT_PADDING ) );
}
@@ -463,6 +466,9 @@ public function setup_layout_subscribers() {
Dynamic_Selector::META_IS_RESPONSIVE => true,
Dynamic_Selector::META_FILTER => function ( $css_prop, $value, $meta, $device ) {
$width = Mods::to_json( Config::MODS_CONTAINER_WIDTH );
+ if ( ! isset( $width[ $device ] ) || ! is_numeric( $width[ $device ] ) || ( $device === Dynamic_Selector::DESKTOP && ! is_numeric( $value ) ) ) {
+ return '';
+ }
$value = $device !== Dynamic_Selector::DESKTOP ? ( $width[ $device ] - Config::CONTENT_DEFAULT_PADDING ) : round( ( $value / 100 ) * $width[ $device ] - Config::CONTENT_DEFAULT_PADDING );
return sprintf( 'max-width:%spx', $value );
@@ -530,6 +536,9 @@ public function setup_layout_subscribers() {
Dynamic_Selector::META_IS_RESPONSIVE => true,
Dynamic_Selector::META_FILTER => function ( $css_prop, $value, $meta, $device ) {
$width = Mods::to_json( Config::MODS_CONTAINER_WIDTH );
+ if ( ! isset( $width[ $device ] ) || ! is_numeric( $width[ $device ] ) || ( $device === Dynamic_Selector::DESKTOP && ! is_numeric( $value ) ) ) {
+ return '';
+ }
$value = $device !== Dynamic_Selector::DESKTOP ? ( $width[ $device ] - Config::CONTENT_DEFAULT_PADDING ) : round( ( $value / 100 ) * $width[ $device ] - Config::CONTENT_DEFAULT_PADDING );
return sprintf( 'max-width:%spx', $value );
diff --git a/inc/core/styles/generator.php b/inc/core/styles/generator.php
index 85d5c8e953..4438eb5f81 100644
--- a/inc/core/styles/generator.php
+++ b/inc/core/styles/generator.php
@@ -41,6 +41,13 @@ class Generator {
* @return string|void Css output.
*/
public function generate( $echo = false ) {
+ if ( ! class_exists( Dynamic_Selector::class ) ) {
+ if ( ! $echo ) {
+ return '';
+ }
+ return;
+ }
+
$desktop_css = '';
$tablet_css = '';
$all_css = '';
diff --git a/inc/customizer/controls/ordering.php b/inc/customizer/controls/ordering.php
index 1a0ba42a5b..334558538f 100644
--- a/inc/customizer/controls/ordering.php
+++ b/inc/customizer/controls/ordering.php
@@ -127,7 +127,7 @@ private function get_component_status_class( $component ) {
if ( empty( $value ) ) {
return ' enabled';
}
- $value = json_decode( $value, true );
+ $value = is_string( $value ) ? json_decode( $value, true ) : $value;
if ( ! is_array( $value ) ) {
$value = array();
diff --git a/inc/customizer/loader.php b/inc/customizer/loader.php
index bf91a3b9f5..94bbe8b790 100644
--- a/inc/customizer/loader.php
+++ b/inc/customizer/loader.php
@@ -281,6 +281,9 @@ public function set_featured_image() {
* @return void
*/
private function load_modules() {
+ if ( ! class_exists( Factory::class ) ) {
+ return;
+ }
$factory = new Factory( $this->customizer_modules );
$factory->load_modules();
}
diff --git a/inc/customizer/options/layout_blog.php b/inc/customizer/options/layout_blog.php
index dcccdf860a..9eac1eec05 100644
--- a/inc/customizer/options/layout_blog.php
+++ b/inc/customizer/options/layout_blog.php
@@ -723,7 +723,11 @@ public function sanitize_post_content_ordering( $value ) {
return wp_json_encode( $allowed );
}
- $decoded = json_decode( $value, true );
+ $decoded = is_string( $value ) ? json_decode( $value, true ) : $value;
+
+ if ( ! is_array( $decoded ) || empty( $decoded ) ) {
+ return wp_json_encode( $allowed );
+ }
foreach ( $decoded as $val ) {
if ( ! in_array( $val, $allowed, true ) ) {
@@ -731,7 +735,7 @@ public function sanitize_post_content_ordering( $value ) {
}
}
- return $value;
+ return wp_json_encode( array_values( $decoded ) );
}
/**
@@ -773,7 +777,10 @@ private function get_post_elements_order() {
'excerpt',
);
$content_order = get_theme_mod( 'neve_post_content_ordering', wp_json_encode( $default ) );
- $content_order = json_decode( $content_order, true );
+
+ if ( is_string( $content_order ) ) {
+ $content_order = json_decode( $content_order, true );
+ }
return is_array( $content_order ) ? $content_order : $default;
}
@@ -809,7 +816,15 @@ public function should_show_masonry() {
return false;
}
- $columns = json_decode( get_theme_mod( 'neve_grid_layout', $this->grid_columns_default() ), true );
+ $columns = get_theme_mod( 'neve_grid_layout', $this->grid_columns_default() );
+ if ( is_string( $columns ) ) {
+ $columns = json_decode( $columns, true );
+ }
+
+ if ( ! is_array( $columns ) ) {
+ return false;
+ }
+
$columns = array_filter(
array_values( $columns ),
function ( $value ) {
diff --git a/inc/customizer/options/layout_single_post.php b/inc/customizer/options/layout_single_post.php
index 219a57c9fa..d12e5d292e 100644
--- a/inc/customizer/options/layout_single_post.php
+++ b/inc/customizer/options/layout_single_post.php
@@ -878,7 +878,14 @@ public function element_is_enabled( $element ) {
);
$content_order = get_theme_mod( 'neve_layout_single_post_elements_order', wp_json_encode( $default_order ) );
- $content_order = json_decode( $content_order, true );
+ if ( is_string( $content_order ) ) {
+ $content_order = json_decode( $content_order, true );
+ }
+
+ if ( ! is_array( $content_order ) ) {
+ $content_order = $default_order;
+ }
+
if ( ! in_array( $element, $content_order, true ) ) {
return false;
}
@@ -906,7 +913,11 @@ public function sanitize_post_elements_ordering( $value ) {
return wp_json_encode( $allowed );
}
- $decoded = json_decode( $value, true );
+ $decoded = is_string( $value ) ? json_decode( $value, true ) : $value;
+
+ if ( ! is_array( $decoded ) ) {
+ return wp_json_encode( $allowed );
+ }
foreach ( $decoded as $val ) {
if ( ! in_array( $val, $allowed, true ) ) {
@@ -914,7 +925,7 @@ public function sanitize_post_elements_ordering( $value ) {
}
}
- return $value;
+ return wp_json_encode( array_values( $decoded ) );
}
/**
diff --git a/inc/views/nav_walker.php b/inc/views/nav_walker.php
index 9fc01a105b..d0c9230a3f 100644
--- a/inc/views/nav_walker.php
+++ b/inc/views/nav_walker.php
@@ -365,7 +365,7 @@ function ( $item_output, $nav_item, $depth, $args ) use ( $item ) {
public static function fallback() {
$fallback_args = array(
'depth' => - 1,
- 'menu_id' => 'nv-primary-navigation-' . \HFG\current_row( \HFG\Core\Builder\Header::BUILDER_NAME ),
+ 'menu_id' => Nav::get_menu_id(),
'menu_class' => 'primary-menu-ul nav-ul',
'container' => 'ul',
'before' => '',
diff --git a/inc/views/partials/excerpt.php b/inc/views/partials/excerpt.php
index 0f2f2501ad..a30e760fe1 100644
--- a/inc/views/partials/excerpt.php
+++ b/inc/views/partials/excerpt.php
@@ -77,7 +77,7 @@ private function get_excerpt( $length = 25, $post_id = null ) {
if ( has_excerpt( $post_id ) ) {
$excerpt_more = apply_filters( 'excerpt_more', ' […]' );
- $content = wp_trim_words( get_the_excerpt( $post_id ), $length, $excerpt_more );
+ $content = $this->trim_words_keep_html( get_the_excerpt( $post_id ), $length, $excerpt_more );
return apply_filters( 'the_excerpt', $content );
}
@@ -89,6 +89,147 @@ private function get_excerpt( $length = 25, $post_id = null ) {
return apply_filters( 'the_excerpt', $content );
}
+ /**
+ * Trim words while preserving HTML markup.
+ *
+ * Similar to wp_trim_words(), but preserves HTML tags.
+ *
+ * @param string $text HTML content to trim.
+ * @param int $num_words Maximum number of words.
+ * @param string $more String to append when the content is trimmed.
+ *
+ * @return string
+ */
+ private function trim_words_keep_html( $text, $num_words, $more = '...' ) {
+ $num_words = (int) $num_words;
+ $trimmed = $this->trim_markup( $text, $num_words, $more );
+
+ return apply_filters( 'wp_trim_words', $trimmed, $num_words, $more, $text );
+ }
+
+ /**
+ * Trim a text to a number of words, leaving the markup around them in place.
+ *
+ * @param string $text HTML content to trim.
+ * @param int $num_words Maximum number of words.
+ * @param string $more String to append when the content is trimmed.
+ *
+ * @return string
+ */
+ private function trim_markup( $text, $num_words, $more ) {
+ if ( $num_words <= 0 || '' === $text ) {
+ return '';
+ }
+
+ // `wp_get_word_count_type()` is WP 6.2+; older installs count words.
+ $count_type = function_exists( 'wp_get_word_count_type' ) ? wp_get_word_count_type() : 'words';
+
+ // Some locales budget characters rather than words, as `wp_trim_words()` does.
+ $count_chars = 0 === strpos( $count_type, 'characters' )
+ && 1 === preg_match( '/^utf\-?8$/i', get_option( 'blog_charset' ) );
+
+ $tokens = preg_split(
+ '/(<[^>]*>)/',
+ $text,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
+ );
+
+ if ( ! is_array( $tokens ) ) {
+ return wp_trim_words( $text, $num_words, $more );
+ }
+
+ $output = '';
+ $remaining = $num_words;
+ $cut = false;
+
+ // Markup and whitespace are held back until a kept word follows them, so a
+ // tag opened right at the cut does not leave an empty element behind.
+ $pending = '';
+ $pending_cost = 0;
+
+ foreach ( $tokens as $token ) {
+ // Tokens are split on this same pattern, so a match is one of the tags.
+ if ( preg_match( '#^<[^>]*>$#', $token ) ) {
+ $pending .= $token;
+
+ continue;
+ }
+
+ $parts = preg_split(
+ '/(\s+)/u',
+ $token,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
+ );
+
+ if ( ! is_array( $parts ) ) {
+ $output .= $pending . $token;
+ $pending = '';
+ $pending_cost = 0;
+
+ continue;
+ }
+
+ foreach ( $parts as $part ) {
+ if ( '' === trim( $part ) ) {
+ // Core collapses each run of whitespace to a single space.
+ $pending .= $count_chars ? ' ' : $part;
+ $pending_cost += $count_chars ? 1 : 0;
+
+ continue;
+ }
+
+ $remaining -= $pending_cost;
+
+ if ( $remaining <= 0 ) {
+ $cut = true;
+ break;
+ }
+
+ $chars = $count_chars ? $this->split_characters( $part ) : array( $part );
+
+ // Character locales can cut part way through a run of text.
+ if ( count( $chars ) > $remaining ) {
+ $output .= $pending . implode( '', array_slice( $chars, 0, $remaining ) );
+ $cut = true;
+ break;
+ }
+
+ $output .= $pending . $part;
+ $pending = '';
+ $pending_cost = 0;
+ $remaining -= count( $chars );
+ }
+
+ if ( $cut ) {
+ break;
+ }
+ }
+
+ if ( ! $cut ) {
+ return $text;
+ }
+
+ // Close any tags that are still open so the resulting HTML remains valid.
+ return force_balance_tags( $output ) . $more;
+ }
+
+ /**
+ * Split a string into its characters.
+ *
+ * @param string $text Text to split.
+ *
+ * @return string[]
+ */
+ private function split_characters( $text ) {
+ if ( ! preg_match_all( '/./u', $text, $matches ) ) {
+ return array();
+ }
+
+ return $matches[0];
+ }
+
/**
* Get the excerpt length option casted as `int`.
*
diff --git a/inc/views/pluggable/metabox_settings.php b/inc/views/pluggable/metabox_settings.php
index e970079bdf..3f449cebc4 100644
--- a/inc/views/pluggable/metabox_settings.php
+++ b/inc/views/pluggable/metabox_settings.php
@@ -556,7 +556,11 @@ public function filter_post_elements( $elements_order ) {
return $elements_order;
}
- return json_decode( $meta_elements_order, true );
+ if ( is_string( $meta_elements_order ) ) {
+ $meta_elements_order = json_decode( $meta_elements_order, true );
+ }
+
+ return is_array( $meta_elements_order ) ? $meta_elements_order : $elements_order;
}
/**
diff --git a/inc/views/post_layout.php b/inc/views/post_layout.php
index 856ae0770d..07bcf3467f 100644
--- a/inc/views/post_layout.php
+++ b/inc/views/post_layout.php
@@ -214,10 +214,13 @@ private function get_content_order() {
$default_order = $this->get_v4_defaults( 'neve_layout_single_post_elements_order', $this->post_ordering() );
$content_order = get_theme_mod( 'neve_layout_single_post_elements_order', wp_json_encode( $default_order ) );
- if ( ! is_string( $content_order ) ) {
- $content_order = wp_json_encode( $default_order );
+ if ( is_string( $content_order ) ) {
+ $content_order = json_decode( $content_order, true );
+ }
+
+ if ( ! is_array( $content_order ) ) {
+ $content_order = $default_order;
}
- $content_order = json_decode( $content_order, true );
if ( apply_filters( 'neve_filter_toggle_content_parts', true, 'title' ) !== true ) {
$title_key = array_search( 'title-meta', $content_order, true );
if ( $title_key !== false ) {
diff --git a/inc/views/scroll_to_top.php b/inc/views/scroll_to_top.php
index 9eb007fe6d..ed2c7ceceb 100644
--- a/inc/views/scroll_to_top.php
+++ b/inc/views/scroll_to_top.php
@@ -31,7 +31,7 @@ public function init() {
* @return void
*/
public function scroll_to_top_amp() {
- if ( ! Scroll_To_Top_Options::is_enabled() ) {
+ if ( ! $this->is_enabled() ) {
return;
}
@@ -91,7 +91,7 @@ public function scroll_to_top_amp() {
* @return void
*/
public function enqueue_scripts() {
- if ( ! Scroll_To_Top_Options::is_enabled() ) {
+ if ( ! $this->is_enabled() ) {
return;
}
@@ -131,7 +131,7 @@ private function localize_scroll() {
* @return void
*/
public function render_button() {
- if ( ! Scroll_To_Top_Options::is_enabled() ) {
+ if ( ! $this->is_enabled() ) {
return;
}
@@ -168,6 +168,26 @@ public function render_button() {
echo '';
}
+ /**
+ * Check whether the scroll-to-top feature is available and enabled.
+ *
+ * @return bool
+ */
+ private function is_enabled() {
+ $options_class = $this->get_options_class();
+
+ return class_exists( $options_class ) && $options_class::is_enabled();
+ }
+
+ /**
+ * Get the scroll-to-top options class.
+ *
+ * @return class-string
+ */
+ protected function get_options_class() {
+ return Scroll_To_Top_Options::class;
+ }
+
/**
* Get SVG icon for scroll to top button.
*
diff --git a/inc/views/secondary_nav_walker.php b/inc/views/secondary_nav_walker.php
index 29a5531892..295af12f27 100644
--- a/inc/views/secondary_nav_walker.php
+++ b/inc/views/secondary_nav_walker.php
@@ -10,6 +10,11 @@
namespace Neve\Views;
+// Bail when the parent walker file is missing, so extending it cannot fatal.
+if ( ! class_exists( 'Neve\Views\Nav_Walker' ) ) {
+ return;
+}
+
/**
* Class Secondary_Nav_Walker
*
diff --git a/inc/views/template_parts.php b/inc/views/template_parts.php
index 343ca85967..86a5ff3ded 100644
--- a/inc/views/template_parts.php
+++ b/inc/views/template_parts.php
@@ -566,7 +566,13 @@ public function get_ordered_components( $associative = false ) {
'excerpt',
);
- return json_decode( get_theme_mod( 'neve_post_content_ordering', wp_json_encode( $default_ordered_components ) ), $associative );
+ $content_order = get_theme_mod( 'neve_post_content_ordering', wp_json_encode( $default_ordered_components ) );
+
+ if ( is_string( $content_order ) ) {
+ $content_order = json_decode( $content_order, $associative );
+ }
+
+ return is_array( $content_order ) ? $content_order : $default_ordered_components;
}
/**
diff --git a/readme.md b/readme.md
index 6061e73477..478c03b80b 100644
--- a/readme.md
+++ b/readme.md
@@ -131,7 +131,7 @@ New Features
- Integrated installation of Login Customizer, Cookie Notice, Duplicate Page, and Custom Fonts/Scripts modules
- New Maintenance and Coming Soon custom layouts [PRO]
- Admin Dashboard Customizer module to personalize the WordPress admin experience (Menu, Admin Bar, Custom Pages) [Agency]
-- Included premium WP Landing Kit wordpress plugin [Agency]
+- Included premium WP Landing Kit WordPress plugin [Agency]
Enhancements
diff --git a/readme.txt b/readme.txt
index 3fb77c6902..b639e8a529 100644
--- a/readme.txt
+++ b/readme.txt
@@ -131,7 +131,7 @@ New Features
- Integrated installation of Login Customizer, Cookie Notice, Duplicate Page, and Custom Fonts/Scripts modules
- New Maintenance and Coming Soon custom layouts [PRO]
- Admin Dashboard Customizer module to personalize the WordPress admin experience (Menu, Admin Bar, Custom Pages) [Agency]
-- Included premium WP Landing Kit wordpress plugin [Agency]
+- Included premium WP Landing Kit WordPress plugin [Agency]
Enhancements
diff --git a/tests/stubs/woocommerce-cart.php b/tests/stubs/woocommerce-cart.php
index 7a9e08a942..d74b2f1f43 100644
--- a/tests/stubs/woocommerce-cart.php
+++ b/tests/stubs/woocommerce-cart.php
@@ -158,3 +158,30 @@ function is_checkout() {
return false;
}
}
+
+if ( ! function_exists( 'is_shop' ) ) {
+ /**
+ * Stand-in for the shop page conditional.
+ *
+ * The `WooCommerce` class above stays declared for the rest of the run, so any code
+ * gated on `class_exists( 'WooCommerce' )` reaches this too.
+ *
+ * @return bool
+ */
+ function is_shop() {
+ return false;
+ }
+}
+
+if ( ! function_exists( 'wc_get_page_id' ) ) {
+ /**
+ * Stand-in for the WooCommerce page id lookup.
+ *
+ * @param string $page the page slug.
+ *
+ * @return int
+ */
+ function wc_get_page_id( $page ) {
+ return -1;
+ }
+}
diff --git a/tests/test-elementor-compatibility.php b/tests/test-elementor-compatibility.php
new file mode 100644
index 0000000000..845d0246ba
--- /dev/null
+++ b/tests/test-elementor-compatibility.php
@@ -0,0 +1,92 @@
+setAccessible( true );
+ $custom_colors->setValue( $elementor, [] );
+
+ return $elementor;
+ }
+
+ /**
+ * Errored responses should be passed through untouched.
+ */
+ public function test_global_colors_in_picker_passes_through_wp_error() {
+ $elementor = $this->get_elementor_compat();
+ $request = new WP_REST_Request( 'GET', self::GLOBALS_ROUTE );
+ $error = new WP_Error( 'rest_forbidden', 'Sorry, you are not allowed to do that.', [ 'status' => 403 ] );
+
+ $this->assertSame( $error, $elementor->alter_global_colors_in_picker( $error, [], $request ) );
+ }
+
+ /**
+ * Errored responses on a color route should be passed through untouched.
+ */
+ public function test_global_colors_front_end_passes_through_wp_error() {
+ $elementor = $this->get_elementor_compat();
+ $request = new WP_REST_Request( 'GET', self::COLOR_ROUTE );
+ $error = new WP_Error( 'rest_forbidden', 'Sorry, you are not allowed to do that.', [ 'status' => 403 ] );
+
+ $this->assertSame( $error, $elementor->alter_global_colors_front_end( $error, [], $request ) );
+ }
+
+ /**
+ * Valid responses should still get the Neve palette colors merged in.
+ */
+ public function test_global_colors_in_picker_adds_palette_colors() {
+ $elementor = $this->get_elementor_compat();
+ $request = new WP_REST_Request( 'GET', self::GLOBALS_ROUTE );
+ $response = new WP_REST_Response( [ 'colors' => [] ] );
+
+ $filtered = $elementor->alter_global_colors_in_picker( $response, [], $request );
+ $data = $filtered->get_data();
+
+ $this->assertArrayHasKey( 'nvprimaryaccent', $data['colors'] );
+ $this->assertArrayHasKey( 'value', $data['colors']['nvprimaryaccent'] );
+ }
+
+ /**
+ * Valid responses on a single color route should be replaced with the Neve color.
+ */
+ public function test_global_colors_front_end_overrides_color() {
+ $elementor = $this->get_elementor_compat();
+ $request = new WP_REST_Request( 'GET', self::COLOR_ROUTE );
+ $response = new WP_REST_Response( [] );
+
+ $filtered = $elementor->alter_global_colors_front_end( $response, [], $request );
+ $data = $filtered->get_data();
+
+ $this->assertSame( 'nvprimaryaccent', $data['id'] );
+ $this->assertArrayHasKey( 'value', $data );
+ }
+}
diff --git a/tests/test-neve-autoloader.php b/tests/test-neve-autoloader.php
index 283346923f..4c33d9accf 100644
--- a/tests/test-neve-autoloader.php
+++ b/tests/test-neve-autoloader.php
@@ -193,4 +193,37 @@ public function testFailedLookupLeavesClassLoadableByNextAutoloader() {
spl_autoload_unregister( array( $autoloader, 'load_class' ) );
}
}
+
+ /**
+ * A miss inside a registered namespace resolves to false rather than
+ * requiring a file that is not there. The caller is what has to guard; the
+ * autoloader only reports.
+ */
+ public function testMissingMappedClassResolvesToFalse() {
+ $autoloader = new \Neve\Autoloader();
+ $autoloader->add_namespace( 'Neve_Fixture', $this->fixture_dir );
+
+ $this->assertFalse( $autoloader->load_class( 'Neve_Fixture\\Widgets\\Absent_Widget' ) );
+ $this->assertFalse( class_exists( 'Neve_Fixture\\Widgets\\Absent_Widget', false ) );
+ }
+
+ /**
+ * A miss must not be remembered. The file can appear later in the same
+ * request, and a cached negative would keep the class unreachable.
+ */
+ public function testMissIsNotCachedAgainstALaterFile() {
+ $autoloader = new \Neve\Autoloader();
+ $autoloader->add_namespace( 'Neve_Fixture', $this->fixture_dir );
+
+ $this->assertFalse( $autoloader->load_class( 'Neve_Fixture\\Widgets\\Deferred_Widget' ) );
+
+ mkdir( $this->fixture_dir . 'widgets/', 0777, true );
+ file_put_contents(
+ $this->fixture_dir . 'widgets/deferred_widget.php',
+ 'assertNotFalse( $autoloader->load_class( 'Neve_Fixture\\Widgets\\Deferred_Widget' ) );
+ $this->assertTrue( class_exists( 'Neve_Fixture\\Widgets\\Deferred_Widget', false ) );
+ }
}
diff --git a/tests/test-neve-excerpt-html.php b/tests/test-neve-excerpt-html.php
new file mode 100644
index 0000000000..4e9e6872bd
--- /dev/null
+++ b/tests/test-neve-excerpt-html.php
@@ -0,0 +1,342 @@
+setAccessible( true );
+
+ return $method->invoke( $excerpt, $text, $num_words, $this->more );
+ }
+
+ /**
+ * Trim a text with the locale counting characters, as CJK locales do.
+ *
+ * @param string $text Text to trim.
+ * @param int $limit Number of characters to keep.
+ *
+ * @return string
+ */
+ private function trim_by_characters( $text, $limit ) {
+ global $wp_locale;
+
+ $previous = $wp_locale->word_count_type;
+ $wp_locale->word_count_type = 'characters_including_spaces';
+
+ $output = $this->trim( $text, $limit );
+
+ $wp_locale->word_count_type = $previous;
+
+ return $output;
+ }
+
+ /**
+ * A link before the cut keeps both its markup and its text.
+ */
+ public function test_keeps_link_when_cut_falls_after_it() {
+ $text = 'Read the full announcement and then a long tail of words';
+
+ $this->assertSame(
+ 'Read the full announcement and then a long' . $this->more,
+ $this->trim( $text, 8 )
+ );
+ }
+
+ /**
+ * A cut inside a link closes it and leaves the marker outside.
+ */
+ public function test_closes_inline_tag_left_open_by_the_cut() {
+ $text = 'Read the full release announcement tail words';
+
+ $this->assertSame(
+ 'Read the full release' . $this->more,
+ $this->trim( $text, 4 )
+ );
+ }
+
+ /**
+ * A text shorter than the limit is returned untouched, marker included.
+ */
+ public function test_returns_short_text_unchanged() {
+ $text = 'Short bold excerpt';
+
+ $this->assertSame( $text, $this->trim( $text, 25 ) );
+ $this->assertSame( $text, $this->trim( $text, 3 ) );
+ }
+
+ /**
+ * A tag opened right at the cut must not leave an empty element behind.
+ */
+ public function test_drops_element_opened_at_the_cut() {
+ $this->assertSame(
+ '
one
two
' . $this->more,
+ $this->trim( '
one
two
three
', 2 )
+ );
+ }
+
+ /**
+ * The same, for a list: an empty `li` would render as a stray bullet.
+ */
+ public function test_drops_list_item_opened_at_the_cut() {
+ $this->assertSame(
+ '
a b
' . $this->more,
+ $this->trim( '
a b
c d
', 2 )
+ );
+ }
+
+ /**
+ * Nested tags are closed at the cut.
+ */
+ public function test_closes_nested_tags() {
+ $this->assertSame(
+ 'One two three' . $this->more,
+ $this->trim( 'One two three four five six', 3 )
+ );
+ }
+
+ /**
+ * A tag nested in itself is unwound rather than left doubled.
+ */
+ public function test_unwinds_a_tag_nested_in_itself() {
+ $this->assertSame(
+ 'Nested one two' . $this->more,
+ $this->trim( 'Nested one two three four five', 3 )
+ );
+ }
+
+ /**
+ * Markup the author never closed is closed at the cut anyway.
+ */
+ public function test_closes_unbalanced_author_markup() {
+ $this->assertSame(
+ 'Unclosed link never closed' . $this->more,
+ $this->trim( 'Unclosed link never closed and more words', 4 )
+ );
+ }
+
+ /**
+ * A closing tag with nothing to close is dropped.
+ */
+ public function test_drops_orphan_closing_tag() {
+ $this->assertSame(
+ 'Stray closing tag here' . $this->more,
+ $this->trim( 'Stray closing tag here and more', 4 )
+ );
+ }
+
+ /**
+ * Void elements are neither counted as words nor closed.
+ */
+ public function test_leaves_void_elements_alone() {
+ $this->assertSame(
+ 'Image after text words' . $this->more,
+ $this->trim( 'Image after text words more', 4 )
+ );
+ }
+
+ /**
+ * A hyphenated custom element is a tag, not three words of text.
+ */
+ public function test_treats_custom_elements_as_markup() {
+ $this->assertSame(
+ 'Custom element text' . $this->more,
+ $this->trim( 'Custom element text here more words', 3 )
+ );
+
+ $this->assertSame(
+ 'Cut inside' . $this->more,
+ $this->trim( 'Cut inside element tail', 2 )
+ );
+ }
+
+ /**
+ * A tag name carrying a colon is markup too, and is closed at the cut.
+ */
+ public function test_treats_namespaced_elements_as_markup() {
+ $this->assertSame(
+ 'Vector icon label' . $this->more,
+ $this->trim( 'Vector icon label here more words', 3 )
+ );
+
+ $this->assertSame(
+ 'Cut inside' . $this->more,
+ $this->trim( 'Cut inside icon tail', 2 )
+ );
+ }
+
+ /**
+ * Comments carry no words, so their contents must not spend the budget.
+ */
+ public function test_does_not_count_comments_as_words() {
+ $this->assertSame(
+ 'bold text more' . $this->more,
+ $this->trim( 'bold text more words here', 3 )
+ );
+ }
+
+ /**
+ * Whitespace between kept words is preserved rather than normalised.
+ */
+ public function test_preserves_whitespace_between_kept_words() {
+ $this->assertSame(
+ "Line\nbreaks and\ttabs" . $this->more,
+ $this->trim( "Line\nbreaks and\ttabs here plus more", 4 )
+ );
+ }
+
+ /**
+ * A zero length keeps nothing.
+ */
+ public function test_zero_length_returns_empty_string() {
+ $this->assertSame( '', $this->trim( 'a b c', 0 ) );
+ $this->assertSame( '', $this->trim( '', 8 ) );
+ }
+
+ /**
+ * Text without markup is trimmed exactly as `wp_trim_words()` would.
+ */
+ public function test_matches_core_for_text_without_markup() {
+ $text = 'one two three four five six';
+
+ foreach ( array( 1, 3, 5, 6, 25 ) as $limit ) {
+ $this->assertSame(
+ wp_trim_words( $text, $limit, $this->more ),
+ $this->trim( $text, $limit ),
+ 'Diverged from wp_trim_words() at length ' . $limit
+ );
+ }
+ }
+
+ /**
+ * Character locales keep their markup rather than falling back to core.
+ */
+ public function test_keeps_link_in_character_counting_locales() {
+ $text = '你好世界链接文字更多内容';
+
+ $this->assertSame(
+ '你好世界链接' . $this->more,
+ $this->trim_by_characters( $text, 6 )
+ );
+ }
+
+ /**
+ * Character locales spend the budget per character, whitespace included.
+ */
+ public function test_matches_core_in_character_counting_locales() {
+ $cases = array(
+ array( 'ab cd efgh', 5 ),
+ array( 'aa bb cc dd', 4 ),
+ array( 'a b c d e f', 3 ),
+ array( '你好世界链接文字', 6 ),
+ );
+
+ foreach ( $cases as list( $text, $limit ) ) {
+ global $wp_locale;
+
+ $previous = $wp_locale->word_count_type;
+ $wp_locale->word_count_type = 'characters_including_spaces';
+ $core = wp_trim_words( $text, $limit, $this->more );
+ $wp_locale->word_count_type = $previous;
+
+ $this->assertSame(
+ $core,
+ $this->trim_by_characters( $text, $limit ),
+ 'Diverged from wp_trim_words() for "' . $text . '" at length ' . $limit
+ );
+ }
+ }
+
+ /**
+ * A text within the character budget is returned untouched.
+ */
+ public function test_character_locales_return_short_text_unchanged() {
+ $text = '短文字';
+
+ $this->assertSame( $text, $this->trim_by_characters( $text, 25 ) );
+ }
+
+ /**
+ * The `wp_trim_words` filter runs on the result, as it does in core.
+ */
+ public function test_applies_the_wp_trim_words_filter() {
+ $calls = array();
+
+ $capture = function ( $trimmed, $num_words, $more, $original ) use ( &$calls ) {
+ $calls[] = compact( 'num_words', 'more', 'original' );
+
+ return strtoupper( $trimmed );
+ };
+
+ add_filter( 'wp_trim_words', $capture, 10, 4 );
+
+ $text = 'one two three four five';
+
+ // Both the trimmed and the untrimmed return paths go through the filter.
+ $this->assertSame( 'ONE TWO THREE' . strtoupper( $this->more ), $this->trim( $text, 3 ) );
+ $this->assertSame( strtoupper( $text ), $this->trim( $text, 25 ) );
+
+ remove_filter( 'wp_trim_words', $capture, 10 );
+
+ $this->assertCount( 2, $calls );
+ $this->assertSame( 3, $calls[0]['num_words'] );
+ $this->assertSame( $this->more, $calls[0]['more'] );
+ $this->assertSame( $text, $calls[0]['original'] );
+ }
+
+ /**
+ * The rendered archive excerpt keeps the link of a hand-written excerpt.
+ */
+ public function test_rendered_excerpt_keeps_the_link() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_content' => 'Body content, not used by this assertion.',
+ 'post_excerpt' => 'Read the full announcement and then a long tail of words to drop',
+ )
+ );
+
+ $GLOBALS['post'] = get_post( $post_id );
+ setup_postdata( $GLOBALS['post'] );
+ set_theme_mod( 'neve_post_excerpt_length', 8 );
+
+ $partial = new \Neve\Views\Partials\Excerpt();
+
+ ob_start();
+ $partial->render_post_excerpt( 'index', $post_id );
+ $output = (string) ob_get_clean();
+
+ remove_theme_mod( 'neve_post_excerpt_length' );
+ wp_reset_postdata();
+
+ $this->assertStringContainsString( 'full announcement', $output );
+ $this->assertStringNotContainsString( 'to drop', $output );
+ }
+}
diff --git a/tests/test-neve-file-guards.php b/tests/test-neve-file-guards.php
index e58b56185f..9405be09da 100644
--- a/tests/test-neve-file-guards.php
+++ b/tests/test-neve-file-guards.php
@@ -159,6 +159,28 @@ public function testGoogleFontsStillLoad() {
$this->assertNotEmpty( $fonts );
}
+ /**
+ * A third-party filter must not replace the variant catalog with a scalar.
+ */
+ public function testGoogleFontVariantsRejectNonArrayFilterResults() {
+ $previous_families = \Neve\Views\Font_Manager::$font_families;
+ \Neve\Views\Font_Manager::$font_families = array(
+ 'Roboto' => array( '400' ),
+ );
+
+ add_filter( 'neve_google_fonts_with_variants_array', '__return_false' );
+
+ try {
+ $this->assertSame( array(), neve_get_google_fonts( true ) );
+ ( new \Neve\Views\Font_Manager() )->register_google_fonts();
+ $this->assertFalse( wp_style_is( 'neve-google-font-roboto', 'enqueued' ) );
+ } finally {
+ remove_filter( 'neve_google_fonts_with_variants_array', '__return_false' );
+ \Neve\Views\Font_Manager::$font_families = $previous_families;
+ wp_dequeue_style( 'neve-google-font-roboto' );
+ }
+ }
+
/**
* Enqueue the customizer controls with a given font list in place.
*
diff --git a/tests/test-neve-hfg-component-guards.php b/tests/test-neve-hfg-component-guards.php
new file mode 100644
index 0000000000..c25270fb8e
--- /dev/null
+++ b/tests/test-neve-hfg-component-guards.php
@@ -0,0 +1,142 @@
+previous_state = array(
+ 'builder' => Abstract_Builder::$current_builder,
+ 'builder_component' => Abstract_Builder::$current_component,
+ 'current_component' => Abstract_Component::$current_component,
+ );
+ }
+
+ /**
+ * Restore the static render state.
+ */
+ public function tear_down() {
+ Abstract_Builder::$current_builder = $this->previous_state['builder'];
+ Abstract_Builder::$current_component = $this->previous_state['builder_component'];
+ Abstract_Component::$current_component = $this->previous_state['current_component'];
+
+ parent::tear_down();
+ }
+
+ /**
+ * Get the header builder instance.
+ *
+ * @return Abstract_Builder
+ */
+ private function header_builder() {
+ $builder = Main::get_instance()->get_builder( 'header' );
+
+ $this->assertInstanceOf( Abstract_Builder::class, $builder );
+
+ return $builder;
+ }
+
+ /**
+ * An unknown component id resolves to null instead of raising a warning.
+ */
+ public function test_get_component_returns_null_for_unknown_id() {
+ $this->assertNull( $this->header_builder()->get_component( 'neve-not-a-component' ) );
+ }
+
+ /**
+ * With no component in render context, get_component resolves to null.
+ */
+ public function test_get_component_returns_null_without_render_context() {
+ Abstract_Builder::$current_component = null;
+ Abstract_Component::$current_component = null;
+
+ $this->assertNull( $this->header_builder()->get_component() );
+ }
+
+ /**
+ * A stale component id left in the render context resolves to null.
+ */
+ public function test_get_component_returns_null_for_stale_context() {
+ Abstract_Builder::$current_component = null;
+ Abstract_Component::$current_component = 'neve-stale-component';
+
+ $this->assertNull( $this->header_builder()->get_component() );
+ }
+
+ /**
+ * A registered component is still returned.
+ */
+ public function test_get_component_returns_registered_component() {
+ $builder = $this->header_builder();
+ $components = $builder->get_components();
+
+ if ( empty( $components ) ) {
+ $this->markTestSkipped( 'No header components registered.' );
+ }
+
+ $id = key( $components );
+
+ Abstract_Builder::$current_component = null;
+ Abstract_Component::$current_component = $id;
+
+ $component = $builder->get_component();
+
+ $this->assertInstanceOf( Abstract_Component::class, $component );
+ $this->assertSame( $id, $component->get_id() );
+ }
+
+ /**
+ * Templates that resolve the component from the render context.
+ *
+ * @return array
+ */
+ public function component_template_provider() {
+ return array(
+ 'wrapper' => array( 'component-wrapper' ),
+ 'footer sidebar' => array( 'components/component-footer-sidebar' ),
+ 'logo' => array( 'components/component-logo' ),
+ 'nav' => array( 'components/component-nav' ),
+ );
+ }
+
+ /**
+ * Component templates render nothing when the component is unresolvable.
+ *
+ * @param string $template Template slug.
+ *
+ * @dataProvider component_template_provider
+ */
+ public function test_component_template_renders_nothing_without_component( $template ) {
+ Abstract_Builder::$current_builder = 'header';
+ Abstract_Builder::$current_component = null;
+ Abstract_Component::$current_component = 'neve-stale-component';
+
+ ob_start();
+ Main::get_instance()->load( $template );
+ $output = ob_get_clean();
+
+ $this->assertSame( '', trim( $output ) );
+ }
+}
diff --git a/tests/test-neve-metabox-meta.php b/tests/test-neve-metabox-meta.php
new file mode 100644
index 0000000000..7105671a08
--- /dev/null
+++ b/tests/test-neve-metabox-meta.php
@@ -0,0 +1,173 @@
+previous_rest_server = $wp_rest_server;
+
+ // The test case unregisters every meta key on teardown.
+ $manager = new \Neve\Admin\Metabox\Manager();
+ $manager->neve_register_meta();
+
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $this->page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Neve meta page',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $_POST = array();
+ }
+
+ /**
+ * Teardown.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+
+ $wp_rest_server = $this->previous_rest_server;
+ $_POST = array();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Save the sidebar meta the way the editor sidebar does, over the REST API.
+ *
+ * @param string $value the value to save.
+ *
+ * @return WP_REST_Response
+ */
+ private function rest_save_sidebar_meta( $value ) {
+ $request = new WP_REST_Request( 'POST', '/wp/v2/pages/' . $this->page_id );
+ $request->set_body_params( array( 'meta' => array( 'neve_meta_sidebar' => $value ) ) );
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Submit the meta box form the way post.php does when the editor saves meta boxes.
+ *
+ * @param array $meta meta id => [ key, value ] pairs, as rendered by the Custom Fields box.
+ */
+ private function submit_meta_boxes( $meta ) {
+ $_POST = array(
+ 'post_ID' => $this->page_id,
+ 'post_type' => 'page',
+ 'post_title' => 'Neve meta page',
+ 'post_status' => 'publish',
+ 'meta' => $meta,
+ );
+
+ edit_post();
+
+ $_POST = array();
+ }
+
+ /**
+ * The value picked in the Neve sidebar is saved over REST.
+ */
+ public function test_sidebar_meta_is_saved_over_rest() {
+ $response = $this->rest_save_sidebar_meta( 'full-width' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'full-width', get_post_meta( $this->page_id, 'neve_meta_sidebar', true ) );
+ }
+
+ /**
+ * With the Custom Fields panel on, the meta box form is submitted right after the REST save.
+ *
+ * It carries the key/value pairs rendered when the editor was loaded, so the stale value
+ * must not be written back over the one just saved from the sidebar.
+ */
+ public function test_custom_fields_submit_does_not_revert_sidebar_meta() {
+ update_post_meta( $this->page_id, 'neve_meta_sidebar', 'default' );
+
+ $meta_id = $this->get_meta_id( 'neve_meta_sidebar' );
+
+ $this->rest_save_sidebar_meta( 'full-width' );
+ $this->assertEquals( 'full-width', get_post_meta( $this->page_id, 'neve_meta_sidebar', true ) );
+
+ // The Custom Fields box submits the value loaded with the page.
+ $this->submit_meta_boxes(
+ array(
+ $meta_id => array(
+ 'key' => 'neve_meta_sidebar',
+ 'value' => 'default',
+ ),
+ )
+ );
+
+ $this->assertEquals( 'full-width', get_post_meta( $this->page_id, 'neve_meta_sidebar', true ) );
+ }
+
+ /**
+ * Meta that is not ours still goes through the Custom Fields panel.
+ */
+ public function test_custom_fields_submit_still_updates_other_meta() {
+ update_post_meta( $this->page_id, 'some_other_key', 'first' );
+
+ $this->submit_meta_boxes(
+ array(
+ $this->get_meta_id( 'some_other_key' ) => array(
+ 'key' => 'some_other_key',
+ 'value' => 'second',
+ ),
+ )
+ );
+
+ $this->assertEquals( 'second', get_post_meta( $this->page_id, 'some_other_key', true ) );
+ }
+
+ /**
+ * Get the meta id for a key on the test page.
+ *
+ * @param string $key the meta key.
+ *
+ * @return int
+ */
+ private function get_meta_id( $key ) {
+ global $wpdb;
+
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s", $this->page_id, $key )
+ );
+ }
+}
diff --git a/tests/test-neve-mods-manager.php b/tests/test-neve-mods-manager.php
index cd18c5b0db..60f9ea9c2b 100644
--- a/tests/test-neve-mods-manager.php
+++ b/tests/test-neve-mods-manager.php
@@ -90,4 +90,23 @@ public function test_to_json_value() {
$mod_value = \Neve\Core\Settings\Mods::to_json( 'test_mod4' );
$this->assertEquals( $mod_value, [ 'subkey' => 3 ] );
}
+
+ /**
+ * Test an already-decoded responsive value.
+ */
+ public function test_to_json_accepts_array_value() {
+ $value = array(
+ 'mobile' => 14,
+ 'tablet' => 16,
+ 'desktop' => 18,
+ );
+
+ \Neve\Core\Settings\Mods::set( 'test_mod_array', $value );
+
+ $this->assertSame( $value, \Neve\Core\Settings\Mods::to_json( 'test_mod_array' ) );
+ $this->assertEquals(
+ json_decode( wp_json_encode( $value ) ),
+ \Neve\Core\Settings\Mods::to_json( 'test_mod_array', false, false )
+ );
+ }
}
diff --git a/tests/test-neve-ordering.php b/tests/test-neve-ordering.php
new file mode 100644
index 0000000000..ab5ed3bcd1
--- /dev/null
+++ b/tests/test-neve-ordering.php
@@ -0,0 +1,379 @@
+get_ordered_components( $associative );
+ }
+
+ /**
+ * The default is used when no mod is set.
+ */
+ public function test_blog_returns_defaults_when_mod_missing() {
+ $this->assertSame( $this->blog_defaults, $this->get_ordered_components() );
+ $this->assertSame( $this->blog_defaults, $this->get_ordered_components( true ) );
+ }
+
+ /**
+ * A JSON string mod - the shape the Customizer control writes - is decoded.
+ */
+ public function test_blog_decodes_json_string_mod() {
+ set_theme_mod( 'neve_post_content_ordering', wp_json_encode( array( 'title-meta', 'thumbnail' ) ) );
+
+ $this->assertSame( array( 'title-meta', 'thumbnail' ), $this->get_ordered_components( true ) );
+ }
+
+ /**
+ * An array-valued mod is returned as-is instead of fataling in json_decode().
+ */
+ public function test_blog_array_mod_does_not_fatal() {
+ $order = array( 'excerpt', 'thumbnail' );
+ set_theme_mod( 'neve_post_content_ordering', $order );
+
+ $this->assertSame( $order, $this->get_ordered_components() );
+ $this->assertSame( $order, $this->get_ordered_components( true ) );
+ }
+
+ /**
+ * An array injected by a theme_mod_ filter is handled the same way.
+ */
+ public function test_blog_array_from_theme_mod_filter_does_not_fatal() {
+ $order = array( 'title-meta', 'excerpt' );
+ $filter = function () use ( $order ) {
+ return $order;
+ };
+ add_filter( 'theme_mod_neve_post_content_ordering', $filter );
+
+ try {
+ $components = $this->get_ordered_components( true );
+ $this->assertSame( $order, $components );
+ } finally {
+ remove_filter( 'theme_mod_neve_post_content_ordering', $filter );
+ }
+ }
+
+ /**
+ * Values that are neither arrays nor valid JSON arrays fall back to the defaults.
+ *
+ * @param mixed $mod the stored mod value.
+ *
+ * @dataProvider provide_invalid_blog_mods
+ */
+ public function test_blog_invalid_mod_falls_back_to_defaults( $mod ) {
+ set_theme_mod( 'neve_post_content_ordering', $mod );
+
+ $this->assertSame( $this->blog_defaults, $this->get_ordered_components() );
+ $this->assertSame( $this->blog_defaults, $this->get_ordered_components( true ) );
+ }
+
+ /**
+ * Invalid blog mod values.
+ *
+ * @return array
+ */
+ public function provide_invalid_blog_mods() {
+ return array(
+ 'empty string' => array( '' ),
+ 'broken json' => array( '[thumbnail,' ),
+ 'json scalar' => array( '"thumbnail"' ),
+ 'integer' => array( 5 ),
+ 'boolean' => array( true ),
+ );
+ }
+
+ /**
+ * The customizer side reader tolerates the same shapes.
+ *
+ * @param mixed $mod the stored mod value.
+ * @param array $expected the expected order.
+ *
+ * @dataProvider provide_blog_mod_shapes
+ */
+ public function test_blog_post_elements_order_handles_all_shapes( $mod, $expected ) {
+ set_theme_mod( 'neve_post_content_ordering', $mod );
+
+ $layout_blog = new \Neve\Customizer\Options\Layout_Blog();
+ $method = new ReflectionMethod( $layout_blog, 'get_post_elements_order' );
+ $method->setAccessible( true );
+
+ $this->assertSame( $expected, $method->invoke( $layout_blog ) );
+ }
+
+ /**
+ * Blog mod shapes and the order they should produce.
+ *
+ * @return array
+ */
+ public function provide_blog_mod_shapes() {
+ $defaults = array( 'thumbnail', 'title-meta', 'excerpt' );
+
+ return array(
+ 'json string' => array( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), array( 'excerpt', 'thumbnail' ) ),
+ 'array' => array( array( 'excerpt', 'thumbnail' ), array( 'excerpt', 'thumbnail' ) ),
+ 'broken json' => array( '[thumbnail,', $defaults ),
+ 'boolean' => array( true, $defaults ),
+ );
+ }
+
+ /**
+ * The blog sanitize callback keeps valid input and never fatals on an array.
+ */
+ public function test_blog_sanitize_handles_arrays_and_invalid_input() {
+ $layout_blog = new \Neve\Customizer\Options\Layout_Blog();
+ $encoded = wp_json_encode( $this->blog_defaults );
+
+ // A valid JSON string is passed through untouched.
+ $this->assertSame( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), $layout_blog->sanitize_post_content_ordering( wp_json_encode( array( 'excerpt', 'thumbnail' ) ) ) );
+
+ // An array is accepted and normalized back to a JSON string.
+ $this->assertSame( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), $layout_blog->sanitize_post_content_ordering( array( 'excerpt', 'thumbnail' ) ) );
+
+ // Associative, sparse and JSON object input is reindexed - the ordering control needs a JSON list.
+ $this->assertSame( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), $layout_blog->sanitize_post_content_ordering( array( 'a' => 'excerpt', 'b' => 'thumbnail' ) ) );
+ $this->assertSame( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), $layout_blog->sanitize_post_content_ordering( array( 2 => 'excerpt', 5 => 'thumbnail' ) ) );
+ $this->assertSame( wp_json_encode( array( 'excerpt', 'thumbnail' ) ), $layout_blog->sanitize_post_content_ordering( '{"a":"excerpt","b":"thumbnail"}' ) );
+
+ // Unknown components, broken JSON and scalars fall back to the defaults.
+ $this->assertSame( $encoded, $layout_blog->sanitize_post_content_ordering( wp_json_encode( array( 'thumbnail', 'evil' ) ) ) );
+ $this->assertSame( $encoded, $layout_blog->sanitize_post_content_ordering( array( 'thumbnail', 'evil' ) ) );
+ $this->assertSame( $encoded, $layout_blog->sanitize_post_content_ordering( '[thumbnail,' ) );
+ $this->assertSame( $encoded, $layout_blog->sanitize_post_content_ordering( '' ) );
+ $this->assertSame( $encoded, $layout_blog->sanitize_post_content_ordering( true ) );
+ }
+
+ /*
+ * ---------------------------------------------------------------------------------------
+ * Single post - neve_layout_single_post_elements_order
+ * ---------------------------------------------------------------------------------------
+ */
+
+ /**
+ * Get the rendering side content order.
+ *
+ * @return array
+ */
+ private function get_content_order() {
+ $post_layout = new \Neve\Views\Post_Layout();
+ $method = new ReflectionMethod( $post_layout, 'get_content_order' );
+ $method->setAccessible( true );
+
+ return $method->invoke( $post_layout );
+ }
+
+ /**
+ * Ask the customizer whether an element is enabled.
+ *
+ * @param string $element the element slug.
+ *
+ * @return bool
+ */
+ private function element_is_enabled( $element ) {
+ $layout_single_post = new \Neve\Customizer\Options\Layout_Single_Post();
+
+ return $layout_single_post->element_is_enabled( $element );
+ }
+
+ /**
+ * Run the single post control sanitize callback.
+ *
+ * @param mixed $value the value to sanitize.
+ *
+ * @return string
+ */
+ private function sanitize_single_post( $value ) {
+ $layout_single_post = new \Neve\Customizer\Options\Layout_Single_Post();
+
+ return $layout_single_post->sanitize_post_elements_ordering( $value );
+ }
+
+ /**
+ * A JSON string mod - the shape the Customizer control writes - is decoded.
+ */
+ public function test_single_post_decodes_json_string_mod() {
+ set_theme_mod( 'neve_layout_single_post_elements_order', wp_json_encode( array( 'content', 'tags' ) ) );
+
+ $this->assertSame( array( 'content', 'tags' ), $this->get_content_order() );
+ $this->assertTrue( $this->element_is_enabled( 'tags' ) );
+ $this->assertFalse( $this->element_is_enabled( 'comments' ) );
+ }
+
+ /**
+ * An array valued mod does not fatal on the rendering side.
+ */
+ public function test_single_post_array_mod_does_not_fatal_on_render() {
+ set_theme_mod( 'neve_layout_single_post_elements_order', array( 'content', 'comments' ) );
+
+ $this->assertSame( array( 'content', 'comments' ), $this->get_content_order() );
+ }
+
+ /**
+ * An array valued mod does not fatal in the customizer active callback.
+ */
+ public function test_single_post_array_mod_does_not_fatal_in_active_callback() {
+ set_theme_mod( 'neve_layout_single_post_elements_order', array( 'content', 'comments' ) );
+
+ $this->assertTrue( $this->element_is_enabled( 'comments' ) );
+ $this->assertFalse( $this->element_is_enabled( 'tags' ) );
+ }
+
+ /**
+ * An array injected by a theme_mod_ filter - the shape Neve Pro uses for custom post
+ * types - is handled the same way.
+ */
+ public function test_single_post_array_from_theme_mod_filter_does_not_fatal() {
+ $order = array( 'title-meta', 'content' );
+ $filter = function () use ( $order ) {
+ return $order;
+ };
+ add_filter( 'theme_mod_neve_layout_single_post_elements_order', $filter );
+
+ try {
+ $content_order = $this->get_content_order();
+ $enabled = $this->element_is_enabled( 'content' );
+ } finally {
+ remove_filter( 'theme_mod_neve_layout_single_post_elements_order', $filter );
+ }
+
+ $this->assertSame( $order, $content_order );
+ $this->assertTrue( $enabled );
+ }
+
+ /**
+ * Values that are neither arrays nor valid JSON arrays fall back to the defaults.
+ *
+ * @param mixed $mod the stored mod value.
+ *
+ * @dataProvider provide_invalid_single_post_mods
+ */
+ public function test_single_post_invalid_mod_falls_back_to_defaults( $mod ) {
+ set_theme_mod( 'neve_layout_single_post_elements_order', $mod );
+
+ $this->assertContains( 'content', $this->get_content_order() );
+ $this->assertTrue( $this->element_is_enabled( 'content' ) );
+ }
+
+ /**
+ * Invalid single post mod values.
+ *
+ * @return array
+ */
+ public function provide_invalid_single_post_mods() {
+ return array(
+ 'empty string' => array( '' ),
+ 'broken json' => array( '[content,' ),
+ 'json scalar' => array( '"content"' ),
+ 'integer' => array( 5 ),
+ 'boolean' => array( true ),
+ );
+ }
+
+ /**
+ * The single post sanitize callback keeps valid input and never fatals on an array.
+ */
+ public function test_single_post_sanitize_handles_arrays_and_invalid_input() {
+ $order = array( 'content', 'tags' );
+ $encoded = wp_json_encode( $order );
+
+ // A valid JSON string is passed through.
+ $this->assertSame( $encoded, $this->sanitize_single_post( $encoded ) );
+
+ // An array is accepted and normalized back to a JSON string.
+ $this->assertSame( $encoded, $this->sanitize_single_post( $order ) );
+
+ // Associative, sparse and JSON object input is reindexed - the ordering control needs a JSON list.
+ $this->assertSame( $encoded, $this->sanitize_single_post( array( 'a' => 'content', 'b' => 'tags' ) ) );
+ $this->assertSame( $encoded, $this->sanitize_single_post( array( 2 => 'content', 5 => 'tags' ) ) );
+ $this->assertSame( $encoded, $this->sanitize_single_post( '{"a":"content","b":"tags"}' ) );
+
+ // Unknown components, broken JSON and scalars fall back to every allowed component.
+ foreach ( array( wp_json_encode( array( 'content', 'evil' ) ), array( 'content', 'evil' ), '[content,', '', true ) as $invalid ) {
+ $decoded = json_decode( $this->sanitize_single_post( $invalid ), true );
+ $this->assertIsArray( $decoded );
+ $this->assertContains( 'content', $decoded );
+ $this->assertContains( 'comments', $decoded );
+ }
+ }
+
+ /**
+ * The metabox per post override tolerates an array or broken meta value.
+ */
+ public function test_single_post_metabox_elements_order_filter_handles_all_shapes() {
+ $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
+ $this->go_to( get_permalink( $post_id ) );
+
+ $metabox = new \Neve\Views\Pluggable\Metabox_Settings();
+ $fallback = array( 'title-meta', 'content' );
+
+ // A JSON string - the shape the editor sidebar saves - wins over the customizer order.
+ update_post_meta( $post_id, 'neve_post_elements_order', wp_json_encode( array( 'content', 'tags' ) ) );
+ $this->assertSame( array( 'content', 'tags' ), $metabox->filter_post_elements( $fallback ) );
+
+ // An array valued meta - only reachable when the meta was written before the
+ // registered sanitize_text_field callback was in place - is returned as is
+ // instead of fataling.
+ $array_meta = function ( $value, $object_id, $meta_key ) use ( $post_id ) {
+ if ( $object_id === $post_id && $meta_key === 'neve_post_elements_order' ) {
+ return array( array( 'tags', 'content' ) );
+ }
+
+ return $value;
+ };
+ add_filter( 'get_post_metadata', $array_meta, 10, 3 );
+
+ try {
+ $this->assertSame( array( 'tags', 'content' ), $metabox->filter_post_elements( $fallback ) );
+ } finally {
+ remove_filter( 'get_post_metadata', $array_meta, 10 );
+ }
+
+ // Broken JSON falls back to the order passed in by the customizer.
+ update_post_meta( $post_id, 'neve_post_elements_order', '[content,' );
+ $this->assertSame( $fallback, $metabox->filter_post_elements( $fallback ) );
+ }
+}
diff --git a/tests/test-neve-sanitization.php b/tests/test-neve-sanitization.php
index fbd32d8958..e02a740a30 100644
--- a/tests/test-neve-sanitization.php
+++ b/tests/test-neve-sanitization.php
@@ -134,6 +134,104 @@ public function test_sanitize_responsive_int_json() {
$this->do_assertion_for_sanitize_responsive_int_json( $input_value, $expected_value );
}
+ /**
+ * Test that color sanitization does not fatal on array or non-string values.
+ */
+ public function test_sanitize_colors_with_non_string_values() {
+ $this->assertSame( '', neve_sanitize_colors( [ '#ffffff', '#000000' ] ) );
+ $this->assertSame( '', neve_sanitize_colors( [ 'mobile' => '#ffffff' ] ) );
+ $this->assertSame( '', neve_sanitize_colors( [] ) );
+
+ // Other non-color values are rejected too.
+ $this->assertSame( '', neve_sanitize_colors( null ) );
+ $this->assertSame( '', neve_sanitize_colors( true ) );
+ $this->assertSame( '', neve_sanitize_colors( new stdClass() ) );
+
+ // Invalid strings return an empty string, never null.
+ $this->assertSame( '', neve_sanitize_colors( 'not-a-color' ) );
+ $this->assertSame( '', neve_sanitize_colors( '' ) );
+
+ // Valid values keep working as before.
+ $this->assertSame( '#ff0000', neve_sanitize_colors( '#ff0000' ) );
+ $this->assertSame( 'rgba(255,0,0,1)', neve_sanitize_colors( 'rgba(255, 0, 0, 1)' ) );
+ $this->assertSame( 'var(--nv-primary-accent)', neve_sanitize_colors( 'var(--nv-primary-accent)' ) );
+ }
+
+ /**
+ * Logo data can already be decoded when it reaches the sanitizer.
+ */
+ public function test_logo_json_sanitizer_accepts_array_input() {
+ $input = array(
+ 'light' => 12,
+ 'dark' => 34,
+ 'same' => false,
+ );
+
+ $this->assertSame(
+ wp_json_encode( $input ),
+ \HFG\Core\Components\Logo::sanitize_logo_json( $input )
+ );
+ }
+
+ /**
+ * Test that only well formed CSS variable expressions are treated as CSS variables.
+ */
+ public function test_is_css_var() {
+ $valid = [
+ 'var(--nv-primary-accent)',
+ 'var(--nv-c-1,#E5E7EB)',
+ 'var(--nv-c-1, #e5e7eb)',
+ 'var( --nv-site-bg , #fff )',
+ 'var(--secondarybtnbg, transparent)',
+ 'var(--x, rgba(0,0,0,.5))',
+ 'var(--x, hsl(120 50% 50%))',
+ 'var(--x, var(--y, #fff))',
+ ];
+
+ foreach ( $valid as $value ) {
+ $this->assertTrue( neve_is_css_var( $value ), $value . ' should be a CSS var' );
+ }
+
+ $invalid = [
+ 'avatar',
+ 'varsity',
+ '#var',
+ 'var',
+ 'var()',
+ 'var(--)',
+ 'var(--x',
+ 'var(--x))',
+ 'var(--x);color:red',
+ 'var(--x)}body{background:red}',
+ 'var(--x, url(evil.css))',
+ 'var(--x, "quoted")',
+ 'linear-gradient(var(--a), var(--b))',
+ '',
+ [ 'var(--x)' ],
+ null,
+ ];
+
+ foreach ( $invalid as $value ) {
+ $this->assertFalse( neve_is_css_var( $value ), var_export( $value, true ) . ' should not be a CSS var' );
+ }
+ }
+
+ /**
+ * Test that color sanitization does not pass off arbitrary strings as CSS variables.
+ */
+ public function test_sanitize_colors_rejects_fake_css_vars() {
+ // Strings that merely contain "var" are not CSS variables.
+ $this->assertSame( '', neve_sanitize_colors( 'avatar' ) );
+ $this->assertSame( '', neve_sanitize_colors( 'var(--x);color:red' ) );
+ $this->assertSame( '', neve_sanitize_colors( 'var(--x)}body{background:red}' ) );
+ $this->assertSame( '', neve_sanitize_colors( 'var(--x, url(evil.css))' ) );
+
+ // Well formed CSS variables pass through untouched.
+ $this->assertSame( 'var(--nv-site-bg)', neve_sanitize_colors( 'var(--nv-site-bg)' ) );
+ $this->assertSame( 'var(--nv-c-1, #e5e7eb)', neve_sanitize_colors( ' var(--nv-c-1, #e5e7eb) ' ) );
+ $this->assertSame( 'var(--x, var(--y, #fff))', neve_sanitize_colors( 'var(--x, var(--y, #fff))' ) );
+ }
+
/**
* Private reusable function for the assertion of sanitize responsive int json.
*
diff --git a/tests/test-neve-scroll-to-top-guards.php b/tests/test-neve-scroll-to-top-guards.php
new file mode 100644
index 0000000000..3db6f05c8a
--- /dev/null
+++ b/tests/test-neve-scroll-to-top-guards.php
@@ -0,0 +1,44 @@
+enqueue_scripts();
+ $this->assertFalse( wp_script_is( 'neve-scroll-to-top', 'enqueued' ) );
+
+ ob_start();
+ $view->scroll_to_top_amp();
+ $view->render_button();
+ $output = ob_get_clean();
+
+ $this->assertSame( '', $output );
+ }
+}