From 41deaff26234cd0718dac0c95fde4dba5fc86944 Mon Sep 17 00:00:00 2001 From: Julia Canzani Date: Sat, 3 Oct 2026 12:07:42 -0400 Subject: [PATCH 1/3] feat(design-settings): reset, export and import for Customizer-backed design A module for plugins whose design lives in the Customizer (Design Upgrade Pro for LearnDash, Design Upgrade Pro for H5P, Quiz Customizer), so each gets the same Design tab and an export from one site carries the whole suite's design to another. - register( $plugin, [ options, theme_mods, panel, migrate ] ) and tab( $plugin ) for register_plugin_settings - export: one plugin or all registered, as tangible-design-settings JSON; only registered options/theme mods, never licences - import: upload, review per plugin (applied/skipped, untick), apply. Values are matched to registered Customizer settings and run through their own sanitize/validate; anything else is skipped and listed. A migrate callback sees the raw section first. Apply replaces, not merges. - reset behind a native ; capability edit_theme_options - forms print from admin_footer (the tab sits inside the framework's own settings form) and controls join them with the form attribute - PHPUnit: export scope, parse, sanitizer pass-through and refusal, migration, replace semantics, reset scope Co-Authored-By: Claude Opus 5.5 --- date/index.php | 2 +- design-settings/index.php | 286 +++++++++++++++++++++++++++++ design-settings/readme.md | 84 +++++++++ design-settings/tab.php | 294 ++++++++++++++++++++++++++++++ index.php | 3 +- package.json | 2 +- plugin.php | 2 +- tests/phpunit/design-settings.php | 182 ++++++++++++++++++ 8 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 design-settings/index.php create mode 100644 design-settings/readme.md create mode 100644 design-settings/tab.php create mode 100644 tests/phpunit/design-settings.php diff --git a/date/index.php b/date/index.php index fbd1cf3..f730c37 100644 --- a/date/index.php +++ b/date/index.php @@ -30,7 +30,7 @@ function date( $arg = false ) { (include __DIR__ . '/module-loader.php')(new class { public $name = 'tangible_date'; - public $version = '20261002'; + public $version = '20261003'; function load() { // Compatibility with older version of Date module diff --git a/design-settings/index.php b/design-settings/index.php new file mode 100644 index 0000000..393e0b1 --- /dev/null +++ b/design-settings/index.php @@ -0,0 +1,286 @@ + [...], 'panel' => '...' ] ) + * 'design' => tab( $plugin ) in register_plugin_settings' tabs + * + * Import never writes what the Customizer couldn't: every value is matched + * to a registered Customizer setting and run through that setting's own + * sanitize/validate, so there is no second allowlist to drift. Keys with no + * setting on this site (an inactive template's, a retired key) are skipped + * and reported. A plugin's `migrate` callback sees the raw section first, so + * exports from older versions still land. Licences and other options a + * plugin didn't register are never exported. + */ +namespace tangible\design_settings; + +use tangible\framework; + +const FORMAT = 'tangible-design-settings'; +const VERSION = 1; + +/** + * @param object $plugin From framework\register_plugin() + * @param array $config { + * @type string[] $options Option names holding the design. + * @type string[] $theme_mods Theme-mod key prefixes holding design (legacy). + * @type string $panel Customizer panel to deep-link. + * @type callable $migrate fn( array $section, string $from_version ): array + * } + */ +function register( $plugin, array $config ) { + framework::$state->design_settings[ $plugin->name ] = (object) array( + 'plugin' => $plugin, + 'options' => array_values( (array) ( $config['options'] ?? array() ) ), + 'theme_mods' => array_values( (array) ( $config['theme_mods'] ?? array() ) ), + 'panel' => (string) ( $config['panel'] ?? '' ), + 'migrate' => $config['migrate'] ?? null, + ); +} + +/** @return object[] name => registration */ +function registered() { + return framework::$state->design_settings ?? array(); +} + +function get( $name ) { + return registered()[ $name ] ?? null; +} + +/** + * The theme mods a registration owns, by prefix. + */ +function owned_theme_mods( $entry ) { + $mods = array(); + if ( ! $entry->theme_mods ) { + return $mods; + } + foreach ( (array) get_theme_mods() as $key => $value ) { + foreach ( $entry->theme_mods as $prefix ) { + if ( 0 === strpos( (string) $key, $prefix ) ) { + $mods[ $key ] = $value; + } + } + } + return $mods; +} + +/** + * Export payload for the named plugins (all registered when empty). + */ +function export( array $names = array() ) { + $plugins = array(); + foreach ( registered() as $name => $entry ) { + if ( $names && ! in_array( $name, $names, true ) ) { + continue; + } + $options = array(); + foreach ( $entry->options as $option ) { + $value = get_option( $option, null ); + if ( null !== $value ) { + $options[ $option ] = $value; + } + } + $plugins[ $name ] = array( + 'title' => $entry->plugin->title ?? $name, + 'version' => (string) ( $entry->plugin->version ?? '' ), + 'options' => $options, + 'theme_mods' => owned_theme_mods( $entry ), + ); + } + return array( + 'format' => FORMAT, + 'version' => VERSION, + 'exported' => gmdate( 'c' ), + 'site' => home_url(), + 'plugins' => $plugins, + ); +} + +/** + * Parse an uploaded file into a payload, or a WP_Error saying why not. + */ +function parse( $json ) { + $payload = json_decode( (string) $json, true ); + if ( ! is_array( $payload ) || ( $payload['format'] ?? '' ) !== FORMAT ) { + return new \WP_Error( 'format', 'This file is not a Tangible design settings export.' ); + } + if ( (int) ( $payload['version'] ?? 0 ) > VERSION ) { + return new \WP_Error( 'version', 'This export was made by a newer version. Update the plugins on this site first.' ); + } + if ( ! is_array( $payload['plugins'] ?? null ) ) { + return new \WP_Error( 'plugins', 'This export contains no design settings.' ); + } + return $payload; +} + +/** + * A Customizer manager with every plugin's settings registered, to borrow + * their sanitize/validate callbacks. Built once per request; a setting's + * sanitize_callback lives on a filter it adds when constructed, so anything + * that resets hooks (the test suite does, per test) needs a fresh one. + */ +function customizer( $fresh = false ) { + static $manager = null; + if ( $fresh || null === $manager ) { + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $manager = new \WP_Customize_Manager( array( 'settings_previewed' => false ) ); + do_action( 'customize_register', $manager ); + } + return $manager; +} + +/** + * Sanitize one value through the Customizer setting registered under $id. + * + * @return array{0: bool, 1: mixed} [ accepted, value ] + */ +function sanitize_setting( $id, $value, $type ) { + $setting = customizer()->get_setting( $id ); + if ( ! $setting || $setting->type !== $type ) { + return array( false, null ); + } + $clean = $setting->sanitize( $value ); + if ( null === $clean || is_wp_error( $clean ) ) { + return array( false, null ); + } + $valid = $setting->validate( $clean ); + if ( is_wp_error( $valid ) ) { + return array( false, null ); + } + return array( true, $clean ); +} + +/** + * What importing $payload would do, per plugin, without writing anything. + * + * @return array name => [ title, status: ready|missing, from, values: [options, theme_mods], applied, skipped: string[] ] + */ +function plan( array $payload ) { + $plan = array(); + foreach ( (array) $payload['plugins'] as $name => $section ) { + $section = (array) $section; + $entry = get( $name ); + $title = (string) ( $section['title'] ?? $name ); + if ( ! $entry ) { + $plan[ $name ] = array( 'title' => $title, 'status' => 'missing', 'from' => (string) ( $section['version'] ?? '' ), 'applied' => 0, 'skipped' => array(), 'values' => null ); + continue; + } + + if ( is_callable( $entry->migrate ) ) { + $section = (array) call_user_func( $entry->migrate, $section, (string) ( $section['version'] ?? '' ) ); + } + + $options = array(); + $mods = array(); + $skipped = array(); + $applied = 0; + + foreach ( (array) ( $section['options'] ?? array() ) as $option => $value ) { + if ( ! in_array( $option, $entry->options, true ) ) { + $skipped[] = $option; + continue; + } + if ( is_array( $value ) ) { + $options[ $option ] = array(); + foreach ( $value as $key => $item ) { + list( $ok, $clean ) = sanitize_setting( "{$option}[{$key}]", $item, 'option' ); + if ( $ok ) { + $options[ $option ][ $key ] = $clean; + $applied++; + } else { + $skipped[] = "{$option}[{$key}]"; + } + } + } else { + list( $ok, $clean ) = sanitize_setting( $option, $value, 'option' ); + if ( $ok ) { + $options[ $option ] = $clean; + $applied++; + } else { + $skipped[] = $option; + } + } + } + + foreach ( (array) ( $section['theme_mods'] ?? array() ) as $key => $value ) { + $owned = false; + foreach ( $entry->theme_mods as $prefix ) { + $owned = $owned || 0 === strpos( (string) $key, $prefix ); + } + list( $ok, $clean ) = $owned ? sanitize_setting( $key, $value, 'theme_mod' ) : array( false, null ); + if ( $ok ) { + $mods[ $key ] = $clean; + $applied++; + } else { + $skipped[] = $key; + } + } + + $plan[ $name ] = array( + 'title' => $entry->plugin->title ?? $title, + 'status' => 'ready', + 'from' => (string) ( $section['version'] ?? '' ), + 'applied' => $applied, + 'skipped' => $skipped, + 'values' => array( 'options' => $options, 'theme_mods' => $mods ), + ); + } + return $plan; +} + +/** + * Write a planned import for the chosen plugins. Each plugin's design is + * replaced, not merged: the import makes it match the export. + */ +function apply( array $plan, array $names ) { + foreach ( $names as $name ) { + $entry = get( $name ); + $step = $plan[ $name ] ?? null; + if ( ! $entry || ! $step || 'ready' !== $step['status'] ) { + continue; + } + foreach ( $entry->options as $option ) { + $value = $step['values']['options'][ $option ] ?? null; + if ( null === $value || array() === $value ) { + delete_option( $option ); + } else { + update_option( $option, $value ); + } + } + foreach ( owned_theme_mods( $entry ) as $key => $unused ) { + remove_theme_mod( $key ); + } + foreach ( $step['values']['theme_mods'] as $key => $value ) { + set_theme_mod( $key, $value ); + } + do_action( 'tangible_design_settings_imported', $entry->plugin ); + } +} + +/** + * Remove every design value the plugin registered. + */ +function reset( $name ) { + $entry = get( $name ); + if ( ! $entry ) { + return; + } + foreach ( $entry->options as $option ) { + delete_option( $option ); + } + foreach ( owned_theme_mods( $entry ) as $key => $unused ) { + remove_theme_mod( $key ); + } + do_action( 'tangible_design_settings_reset', $entry->plugin ); +} + +require_once __DIR__ . '/tab.php'; diff --git a/design-settings/readme.md b/design-settings/readme.md new file mode 100644 index 0000000..749d040 --- /dev/null +++ b/design-settings/readme.md @@ -0,0 +1,84 @@ +# Design settings + +Reset, export and import for plugins whose design lives in the Customizer. +One module, so every Tangible design plugin gets the same **Design** tab and +an export from one site carries the whole suite's design to another. + +## Use + +```php +use tangible\design_settings; + +$plugin = \tangible\framework\register_plugin( $config ); + +design_settings\register( $plugin, array( + 'options' => array( 'h5pd_designer_options' ), // option names holding the design + 'theme_mods' => array(), // theme-mod key prefixes (legacy templates) + 'panel' => 'h5pd_designer_panel', // Customizer panel to deep-link + 'migrate' => null, // fn( array $section, string $from_version ): array +) ); + +\tangible\framework\register_plugin_settings( $plugin, array( + 'tabs' => array( + 'design' => design_settings\tab( $plugin ), + 'license' => array( … ), + ), +) ); +``` + +## The tab + +- **Open the Customizer**: links to the registered panel. +- **Export**: this plugin, or every registered design plugin on the site. + It downloads a JSON file. Only registered options and theme mods go in; + licences and other settings never do. +- **Import**: upload a file and review it before anything changes. The review + shows each plugin's applied and skipped values, and lets you untick plugins. + Applying *replaces* each chosen plugin's design: it doesn't merge. +- **Reset**: deletes the registered options and theme mods after a confirm + dialog. + +The capability is `edit_theme_options`, the same one the Customizer uses. In +network admin the tab only explains that design is per site. + +## Import safety + +Every value is matched to a registered Customizer setting +(`option[key]`, `option` or a theme mod) and run through that setting's own +`sanitize()` and `validate()`, using a `WP_Customize_Manager` with every +plugin's `customize_register` callbacks applied. There's no second allowlist to +drift out of date. Anything without a setting on this site (an inactive +template's keys, a retired key, a foreign option) is skipped and listed in the +review. A plugin's `migrate` callback receives the raw section first, so exports +from older versions are renamed into today's keys before sanitizing. + +## Format + +```json +{ + "format": "tangible-design-settings", + "version": 1, + "exported": "2026-10-03T12:00:00+00:00", + "site": "https://example.com", + "plugins": { + "design-upgrade-pro-h5p": { + "title": "Design Upgrade Pro for H5P", + "version": "1.6.0", + "options": { "h5pd_designer_options": { "btn_color_primary": "#002bc9" } }, + "theme_mods": {} + } + } +} +``` + +A preset is the same format shipped with a plugin, so presets can reuse +`plan()` and `apply()`. + +## Hooks + +- `tangible_design_settings_imported( $plugin )`: fires after a plugin's design is replaced. +- `tangible_design_settings_reset( $plugin )`: fires after a reset. + +Plugins that cache output built from their options should rebuild on these, or +on their options' own `update_option_*` and `delete_option_*` hooks, which fire +anyway. diff --git a/design-settings/tab.php b/design-settings/tab.php new file mode 100644 index 0000000..4902c99 --- /dev/null +++ b/design-settings/tab.php @@ -0,0 +1,294 @@ +, and a + * nested
is dropped by the HTML parser, so this tab's forms are + * printed at body level from admin_footer and its controls join them with + * the `form` attribute. The reset confirm is a native (focus trap, + * Esc to close, ::backdrop) — the pattern Design Upgrade Pro shipped first. + * + * Capability: edit_theme_options, the Customizer's own. An import is held + * per user for review (a transient) and re-planned at apply time, so what + * gets written is sanitized against the settings as they are then. + */ +namespace tangible\design_settings; + +use tangible\framework; + +const ACTION = 'tangible_design_settings'; + +/** + * Tab config for register_plugin_settings(): + * 'tabs' => [ 'design' => design_settings\tab( $plugin ), … ] + */ +function tab( $plugin, $slug = 'design' ) { + return array( + 'title' => 'Design', + 'callback' => function ( $plugin ) use ( $slug ) { + render_tab( $plugin, $slug ); + }, + ); +} + +function review_key() { + return ACTION . '_review_' . get_current_user_id(); +} + +function tab_url( $plugin, $slug, array $args = array() ) { + return add_query_arg( $args, framework\get_plugin_settings_page_url( $plugin, $slug ) ); +} + +function render_tab( $plugin, $slug ) { + + if ( is_network_admin() ) { + echo '

Design settings belong to each site. Manage them from a site’s dashboard.

'; + return; + } + if ( ! current_user_can( 'edit_theme_options' ) ) { + return; + } + + $entry = get( $plugin->name ); + if ( ! $entry ) { + return; + } + + add_action( 'admin_footer', function () use ( $plugin, $slug ) { + render_forms( $plugin, $slug ); + } ); + + render_notices(); + + $customizer = admin_url( 'customize.php' . ( $entry->panel ? '?autofocus[panel]=' . rawurlencode( $entry->panel ) : '' ) ); + $others = count( registered() ) - 1; + $review = isset( $_GET['design-import'] ) ? get_transient( review_key() ) : false; + ?> +
+

Design settings

+

+ Design settings are edited live in the Customizer. + Open the Customizer +

+ + + +

Export

+

Download the design as a file to keep, or to import on another site. Licences are never included.

+

+ + 0 ) { ?> + + +

+ +

Import

+

Choose an export file. You’ll see what will change before anything is applied.

+

+ + +

+ +

Reset

+

Remove every saved design setting for title ); ?>. The plugin then renders its built-in default design until new values are saved.

+

+ +

+
+ array( 'success', 'All design settings have been reset to the plugin’s default design.' ), + 'imported' => array( 'success', sprintf( 'Imported design settings for %d plugin%s.', $count, 1 === $count ? '' : 's' ) ), + 'cancelled' => array( 'info', 'Import cancelled. Nothing was changed.' ), + ); + if ( isset( $messages[ $notice ] ) ) { + list( $type, $text ) = $messages[ $notice ]; + printf( '

%s

', esc_attr( $type ), esc_html( $text ) ); + } + if ( '' !== $error ) { + printf( '

%s

', esc_html( $error ) ); + } +} + +function render_review( array $payload ) { + $plan = plan( $payload ); + ?> +
+

Review import

+

+ From , + exported . + Each selected plugin’s design is replaced by the file’s. +

+ + + + $step ) { + $ready = 'ready' === $step['status']; + $id = 'tangible-design-import-' . sanitize_key( $name ); + ?> + + + + + + + + +
ImportPluginSettingsSkipped
+ > + + +
Exported from version +
+ + Not installed on this site + +
+ not available here + +
+ + None + +
+

+ + +

+
+ ', esc_attr( ACTION . '_' . $action ) ); + printf( '', esc_attr( $plugin->name ) ); + printf( '', esc_attr( $slug ) ); + wp_nonce_field( ACTION . '_' . $action, '_tangible_design_nonce' ); + }; + ?> + + + + + +
+ +

Reset all design settings?

+

Every saved design setting for title ); ?> will be removed. This cannot be undone; export first to keep a copy.

+

+ + +

+
+
+ + 403 ) ); + } + check_admin_referer( ACTION . '_' . $action, '_tangible_design_nonce' ); + $entry = get( sanitize_text_field( wp_unslash( $_POST['plugin'] ?? '' ) ) ); + if ( ! $entry ) { + wp_die( 'Unknown plugin.', '', array( 'response' => 400 ) ); + } + return $entry->plugin; +} + +function back( $plugin, array $args ) { + wp_safe_redirect( tab_url( $plugin, sanitize_key( $_POST['tab'] ?? 'design' ), $args ) ); + exit; +} + +add_action( 'admin_post_' . ACTION . '_export', function () { + $plugin = guard( 'export' ); + $names = 'all' === ( $_POST['scope'] ?? '' ) ? array() : array( $plugin->name ); + $host = sanitize_file_name( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); + $file = 'tangible-design-' . ( $names ? $plugin->name : 'all' ) . '-' . $host . '-' . gmdate( 'Y-m-d' ) . '.json'; + + nocache_headers(); + header( 'Content-Type: application/json; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . $file . '"' ); + echo wp_json_encode( export( $names ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + exit; +} ); + +add_action( 'admin_post_' . ACTION . '_import', function () { + $plugin = guard( 'import' ); + $upload = $_FILES['file'] ?? null; + + if ( ! $upload || UPLOAD_ERR_OK !== ( $upload['error'] ?? UPLOAD_ERR_NO_FILE ) || ! is_uploaded_file( $upload['tmp_name'] ) ) { + back( $plugin, array( 'design-error' => 'No file was uploaded.' ) ); + } + if ( $upload['size'] > MB_IN_BYTES ) { + back( $plugin, array( 'design-error' => 'That file is too large to be a design settings export.' ) ); + } + + $payload = parse( file_get_contents( $upload['tmp_name'] ) ); + if ( is_wp_error( $payload ) ) { + back( $plugin, array( 'design-error' => $payload->get_error_message() ) ); + } + + set_transient( review_key(), $payload, 30 * MINUTE_IN_SECONDS ); + back( $plugin, array( 'design-import' => 'review' ) ); +} ); + +add_action( 'admin_post_' . ACTION . '_apply', function () { + $plugin = guard( 'apply' ); + $payload = get_transient( review_key() ); + delete_transient( review_key() ); + + if ( ! empty( $_POST['cancel'] ) ) { + back( $plugin, array( 'design-notice' => 'cancelled' ) ); + } + if ( ! is_array( $payload ) ) { + back( $plugin, array( 'design-error' => 'The import expired. Choose the file again.' ) ); + } + + $names = array_map( 'sanitize_text_field', array_map( 'wp_unslash', (array) ( $_POST['plugins'] ?? array() ) ) ); + $plan = plan( $payload ); + $names = array_values( array_filter( $names, function ( $name ) use ( $plan ) { + return 'ready' === ( $plan[ $name ]['status'] ?? '' ); + } ) ); + apply( $plan, $names ); + back( $plugin, array( 'design-notice' => 'imported', 'design-count' => count( $names ) ) ); +} ); + +add_action( 'admin_post_' . ACTION . '_reset', function () { + $plugin = guard( 'reset' ); + reset( $plugin->name ); + back( $plugin, array( 'design-notice' => 'reset' ) ); +} ); diff --git a/index.php b/index.php index f932c43..5cd35e0 100644 --- a/index.php +++ b/index.php @@ -16,7 +16,7 @@ class framework { (include __DIR__ . '/module-loader.php')(new class { public $name = 'tangible_framework'; - public $version = '20261002'; + public $version = '20261003'; function load() { @@ -33,6 +33,7 @@ function load() { require_once __DIR__ . '/ajax/index.php'; require_once __DIR__ . '/api/index.php'; require_once __DIR__ . '/design/index.php'; + require_once __DIR__ . '/design-settings/index.php'; require_once __DIR__ . '/env/index.php'; require_once __DIR__ . '/file-system/index.php'; require_once __DIR__ . '/format/index.php'; diff --git a/package.json b/package.json index 1a128f9..6b36297 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tangible/framework", "type": "module", - "version": "2026.10.2", + "version": "2026.10.3", "description": "Framework module", "homepage": "https://github.com/tangibleinc/framework", "repository": { diff --git a/plugin.php b/plugin.php index 28f5882..5a06d4f 100644 --- a/plugin.php +++ b/plugin.php @@ -2,7 +2,7 @@ /** * Plugin Name: Tangible Framework * Description: Framework module shared by Tangible plugins - * Version: 2026.10.2 + * Version: 2026.10.3 * GitHub URI: TangibleInc/framework */ use tangible\framework; diff --git a/tests/phpunit/design-settings.php b/tests/phpunit/design-settings.php new file mode 100644 index 0000000..0267ea7 --- /dev/null +++ b/tests/phpunit/design-settings.php @@ -0,0 +1,182 @@ + 'test-design', 'title' => 'Test Design', 'version' => '2.0.0' ]; + self::$other = (object) [ 'name' => 'test-other', 'title' => 'Test Other', 'version' => '1.0.0' ]; + + add_action('customize_register', function ($wp_customize) { + $wp_customize->add_setting('test_design[color]', [ 'type' => 'option', 'sanitize_callback' => 'sanitize_hex_color' ]); + $wp_customize->add_setting('test_design[size]', [ 'type' => 'option', 'sanitize_callback' => 'absint' ]); + $wp_customize->add_setting('test_design[renamed]', [ 'type' => 'option', 'sanitize_callback' => 'sanitize_text_field' ]); + $wp_customize->add_setting('test_legacy_bg', [ 'type' => 'theme_mod', 'sanitize_callback' => 'sanitize_hex_color' ]); + $wp_customize->add_setting('test_other[on]', [ 'type' => 'option', 'sanitize_callback' => 'sanitize_text_field' ]); + }); + } + + function set_up() { + parent::set_up(); + \tangible\framework::$state->design_settings = []; + design_settings\register(self::$plugin, [ + 'options' => [ 'test_design' ], + 'theme_mods' => [ 'test_legacy_' ], + 'panel' => 'test_panel', + 'migrate' => function ($section, $from) { + // 1.x stored "old" where 2.x stores "renamed" + if (version_compare($from, '2.0.0', '<') && isset($section['options']['test_design']['old'])) { + $section['options']['test_design']['renamed'] = $section['options']['test_design']['old']; + unset($section['options']['test_design']['old']); + } + return $section; + }, + ]); + design_settings\register(self::$other, [ 'options' => [ 'test_other' ] ]); + design_settings\customizer(true); + } + + function tear_down() { + delete_option('test_design'); + delete_option('test_other'); + delete_option('test_design_license_key'); + remove_theme_mod('test_legacy_bg'); + remove_theme_mod('unrelated_mod'); + parent::tear_down(); + } + + // ── export ─────────────────────────────────────────────────────── + + function test_export_carries_registered_design_and_nothing_else() { + update_option('test_design', [ 'color' => '#112233' ]); + update_option('test_design_license_key', 'SECRET'); + set_theme_mod('test_legacy_bg', '#ffffff'); + set_theme_mod('unrelated_mod', 'x'); + + $export = design_settings\export([ 'test-design' ]); + + $this->assertSame(design_settings\FORMAT, $export['format']); + $this->assertSame([ 'test-design' ], array_keys($export['plugins'])); + $section = $export['plugins']['test-design']; + $this->assertSame('2.0.0', $section['version']); + $this->assertSame([ 'test_design' => [ 'color' => '#112233' ] ], $section['options']); + $this->assertSame([ 'test_legacy_bg' => '#ffffff' ], $section['theme_mods']); + $this->assertStringNotContainsString('SECRET', wp_json_encode($export)); + } + + function test_export_of_everything_includes_every_registered_plugin() { + $this->assertSame([ 'test-design', 'test-other' ], array_keys(design_settings\export()['plugins'])); + } + + // ── parse ──────────────────────────────────────────────────────── + + function test_parse_rejects_other_files_and_newer_formats() { + $this->assertWPError(design_settings\parse('{"hello":"world"}')); + $this->assertWPError(design_settings\parse('not json')); + $this->assertWPError(design_settings\parse(wp_json_encode([ 'format' => design_settings\FORMAT, 'version' => design_settings\VERSION + 1, 'plugins' => [] ]))); + $this->assertIsArray(design_settings\parse(wp_json_encode(design_settings\export()))); + } + + // ── plan ───────────────────────────────────────────────────────── + + function payload($plugins) { + return [ 'format' => design_settings\FORMAT, 'version' => 1, 'plugins' => $plugins ]; + } + + function test_values_go_through_each_settings_own_sanitizer() { + $plan = design_settings\plan($this->payload([ + 'test-design' => [ 'version' => '2.0.0', 'options' => [ 'test_design' => [ + 'color' => '#abcdef', + 'size' => '12px', // absint → 12 + ] ] ], + ])); + $this->assertSame('ready', $plan['test-design']['status']); + $this->assertSame([ 'color' => '#abcdef', 'size' => 12 ], $plan['test-design']['values']['options']['test_design']); + $this->assertSame(2, $plan['test-design']['applied']); + } + + function test_values_the_customizer_would_refuse_are_skipped_not_written() { + $plan = design_settings\plan($this->payload([ + 'test-design' => [ 'version' => '2.0.0', + 'options' => [ + 'test_design' => [ 'color' => 'red;}body{display:none', 'nope' => 'x' ], + 'test_design_license_key' => 'STOLEN', + ], + 'theme_mods' => [ 'test_legacy_bg' => '#000000', 'unrelated_mod' => 'x' ], + ], + ])); + $step = $plan['test-design']; + $this->assertSame([], $step['values']['options']['test_design']); + $this->assertArrayNotHasKey('test_design_license_key', $step['values']['options']); + $this->assertSame([ 'test_legacy_bg' => '#000000' ], $step['values']['theme_mods']); + $this->assertEqualsCanonicalizing( + [ 'test_design[color]', 'test_design[nope]', 'test_design_license_key', 'unrelated_mod' ], + $step['skipped'] + ); + } + + function test_plugins_not_on_this_site_are_reported_not_applied() { + $plan = design_settings\plan($this->payload([ 'somewhere-else' => [ 'title' => 'Elsewhere', 'options' => [ 'x' => [ 'y' => 1 ] ] ] ])); + $this->assertSame('missing', $plan['somewhere-else']['status']); + } + + function test_migrate_sees_the_raw_section_first() { + $plan = design_settings\plan($this->payload([ + 'test-design' => [ 'version' => '1.4.0', 'options' => [ 'test_design' => [ 'old' => 'kept' ] ] ], + ])); + $this->assertSame([ 'renamed' => 'kept' ], $plan['test-design']['values']['options']['test_design']); + } + + // ── apply ──────────────────────────────────────────────────────── + + function test_apply_replaces_the_chosen_plugins_design_only() { + update_option('test_design', [ 'color' => '#111111', 'size' => 3 ]); + update_option('test_other', [ 'on' => 'yes' ]); + set_theme_mod('test_legacy_bg', '#222222'); + + $fired = []; + add_action('tangible_design_settings_imported', function ($plugin) use (&$fired) { $fired[] = $plugin->name; }); + + $plan = design_settings\plan($this->payload([ + 'test-design' => [ 'version' => '2.0.0', 'options' => [ 'test_design' => [ 'color' => '#999999' ] ], 'theme_mods' => [] ], + 'test-other' => [ 'version' => '1.0.0', 'options' => [ 'test_other' => [ 'on' => 'no' ] ] ], + ])); + design_settings\apply($plan, [ 'test-design' ]); + + $this->assertSame([ 'color' => '#999999' ], get_option('test_design'), 'replaced, not merged'); + $this->assertFalse(get_theme_mod('test_legacy_bg'), 'owned theme mods replaced too'); + $this->assertSame([ 'on' => 'yes' ], get_option('test_other'), 'unchosen plugin untouched'); + $this->assertSame([ 'test-design' ], $fired); + } + + // ── reset ──────────────────────────────────────────────────────── + + function test_reset_removes_registered_design_and_nothing_else() { + update_option('test_design', [ 'color' => '#111111' ]); + update_option('test_design_license_key', 'KEEP'); + set_theme_mod('test_legacy_bg', '#222222'); + set_theme_mod('unrelated_mod', 'keep'); + + design_settings\reset('test-design'); + + $this->assertFalse(get_option('test_design')); + $this->assertFalse(get_theme_mod('test_legacy_bg')); + $this->assertSame('KEEP', get_option('test_design_license_key')); + $this->assertSame('keep', get_theme_mod('unrelated_mod')); + } +} From fc157c28a652bf6827aee8e8f6912ecc19affe1b Mon Sep 17 00:00:00 2001 From: Julia Canzani Date: Sat, 3 Oct 2026 12:12:55 -0400 Subject: [PATCH 2/3] fix(design-settings): register Customizer settings as a real request would The review step builds a WP_Customize_Manager outside a Customizer request, and LearnDash's customize_register callback reads the global $wp_customize: get_panel() on null, a fatal on the settings page. Set the global while callbacks run and restore it after; contain a callback that throws, so a broken neighbour can't take the page down. Settings it would have added are absent, so their values are skipped, never written unsanitized, and the review shows a warning with the reason. Co-Authored-By: Claude Opus 5.5 --- design-settings/index.php | 22 +++++++++++++++++++++- design-settings/tab.php | 5 +++++ tests/phpunit/design-settings.php | 25 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/design-settings/index.php b/design-settings/index.php index 393e0b1..b2e25b6 100644 --- a/design-settings/index.php +++ b/design-settings/index.php @@ -133,11 +133,31 @@ function customizer( $fresh = false ) { if ( $fresh || null === $manager ) { require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; $manager = new \WP_Customize_Manager( array( 'settings_previewed' => false ) ); - do_action( 'customize_register', $manager ); + + // Registration callbacks are written for a real Customizer request: + // some read the global (LearnDash does), and one that throws must not + // take the settings page down. Settings it would have added are + // simply absent, so their values are skipped, and the review says so. + $previous = $GLOBALS['wp_customize'] ?? null; + $GLOBALS['wp_customize'] = $manager; + framework::$state->design_settings_error = null; + try { + do_action( 'customize_register', $manager ); + } catch ( \Throwable $e ) { + framework::$state->design_settings_error = $e->getMessage(); + } + $GLOBALS['wp_customize'] = $previous; } return $manager; } +/** + * Why the Customizer's settings couldn't all be loaded, if they couldn't. + */ +function customizer_error() { + return framework::$state->design_settings_error ?? null; +} + /** * Sanitize one value through the Customizer setting registered under $id. * diff --git a/design-settings/tab.php b/design-settings/tab.php index 4902c99..2d10cdf 100644 --- a/design-settings/tab.php +++ b/design-settings/tab.php @@ -124,6 +124,11 @@ function render_notices() { function render_review( array $payload ) { $plan = plan( $payload ); ?> + +
+

Another plugin’s Customizer setup failed while this import was checked, so its values are listed as skipped. The plugin reported:

+
+

Review import

diff --git a/tests/phpunit/design-settings.php b/tests/phpunit/design-settings.php index 0267ea7..8dd9f92 100644 --- a/tests/phpunit/design-settings.php +++ b/tests/phpunit/design-settings.php @@ -142,6 +142,31 @@ function test_migrate_sees_the_raw_section_first() { $this->assertSame([ 'renamed' => 'kept' ], $plan['test-design']['values']['options']['test_design']); } + // ── registration outside a Customizer request ──────────────────── + + function test_registration_callbacks_can_rely_on_the_global() { + $seen = null; + add_action('customize_register', function () use (&$seen) { + global $wp_customize; + $seen = $wp_customize instanceof \WP_Customize_Manager; + }, 1); + $manager = design_settings\customizer(true); + $this->assertTrue($seen); + $this->assertNull($GLOBALS['wp_customize'] ?? null, 'global restored afterwards'); + $this->assertNotNull($manager->get_setting('test_design[color]')); + } + + function test_a_throwing_callback_is_contained_and_reported() { + add_action('customize_register', function () { throw new \RuntimeException('neighbour broke'); }, 1); + design_settings\customizer(true); + $this->assertSame('neighbour broke', design_settings\customizer_error()); + + // Our settings registered after it are absent, so values are skipped, never written unsanitized. + $plan = design_settings\plan($this->payload([ 'test-design' => [ 'version' => '2.0.0', 'options' => [ 'test_design' => [ 'color' => '#abcdef' ] ] ] ])); + $this->assertSame([], $plan['test-design']['values']['options']['test_design']); + $this->assertSame([ 'test_design[color]' ], $plan['test-design']['skipped']); + } + // ── apply ──────────────────────────────────────────────────────── function test_apply_replaces_the_chosen_plugins_design_only() { From bc40472e389f5ffeca9e3ab0cbe4f14562411542 Mon Sep 17 00:00:00 2001 From: Julia Canzani Date: Sat, 3 Oct 2026 12:14:22 -0400 Subject: [PATCH 3/3] fix(design-settings): empty sections export as {} so edited files keep their keys Co-Authored-By: Claude Opus 5.5 --- design-settings/index.php | 7 +++++-- tests/phpunit/design-settings.php | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/design-settings/index.php b/design-settings/index.php index b2e25b6..f0e0ed6 100644 --- a/design-settings/index.php +++ b/design-settings/index.php @@ -89,11 +89,14 @@ function export( array $names = array() ) { $options[ $option ] = $value; } } + $mods = owned_theme_mods( $entry ); $plugins[ $name ] = array( 'title' => $entry->plugin->title ?? $name, 'version' => (string) ( $entry->plugin->version ?? '' ), - 'options' => $options, - 'theme_mods' => owned_theme_mods( $entry ), + // Maps, even when empty: PHP would encode an empty array as [], + // and a hand-edited file then can't gain keys. + 'options' => $options ? $options : new \stdClass(), + 'theme_mods' => $mods ? $mods : new \stdClass(), ); } return array( diff --git a/tests/phpunit/design-settings.php b/tests/phpunit/design-settings.php index 8dd9f92..df725da 100644 --- a/tests/phpunit/design-settings.php +++ b/tests/phpunit/design-settings.php @@ -79,6 +79,12 @@ function test_export_carries_registered_design_and_nothing_else() { $this->assertStringNotContainsString('SECRET', wp_json_encode($export)); } + function test_empty_sections_export_as_maps() { + $json = wp_json_encode(design_settings\export([ 'test-other' ])); + $this->assertStringContainsString('"options":{}', $json); + $this->assertStringContainsString('"theme_mods":{}', $json); + } + function test_export_of_everything_includes_every_registered_plugin() { $this->assertSame([ 'test-design', 'test-other' ], array_keys(design_settings\export()['plugins'])); }