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..f0e0ed6 --- /dev/null +++ b/design-settings/index.php @@ -0,0 +1,309 @@ + [...], '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; + } + } + $mods = owned_theme_mods( $entry ); + $plugins[ $name ] = array( + 'title' => $entry->plugin->title ?? $name, + 'version' => (string) ( $entry->plugin->version ?? '' ), + // 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( + '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 ) ); + + // 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. + * + * @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..2d10cdf --- /dev/null +++ b/design-settings/tab.php @@ -0,0 +1,299 @@ +, 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 ); + ?> + +
+

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

+
+ +
+

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..df725da --- /dev/null +++ b/tests/phpunit/design-settings.php @@ -0,0 +1,213 @@ + '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_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'])); + } + + // ── 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']); + } + + // ── 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() { + 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')); + } +}