Make WordPress Core

Ticket #30937: 30937.diff

File 30937.diff, 145.5 KB (added by jorbin, 8 years ago)
  • tests/qunit/fixtures/customize-settings.js

     
    147147                'mobile': {
    148148                        'label': 'Enter mobile preview mode'
    149149                }
     150        },
     151        'changeset': {
     152                'status': '',
     153                'uuid': '0c674ff4-c159-4e7a-beb4-cb830ae73979'
    150154        }
    151155};
    152156window._wpCustomizeControlsL10n = {};
  • tests/phpunit/tests/functions.php

     
    880880                $this->assertSame( false, $raised_limit );
    881881                $this->assertEquals( WP_MAX_MEMORY_LIMIT, $ini_limit_after );
    882882        }
     883
     884        /**
     885         * Tests wp_generate_uuid4().
     886         *
     887         * @covers wp_generate_uuid4()
     888         * @ticket 38164
     889         */
     890        function test_wp_generate_uuid4() {
     891                $uuids = array();
     892                for ( $i = 0; $i < 20; $i += 1 ) {
     893                        $uuid = wp_generate_uuid4();
     894                        $this->assertRegExp( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid );
     895                        $uuids[] = $uuid;
     896                }
     897
     898                $unique_uuids = array_unique( $uuids );
     899                $this->assertEquals( $uuids, $unique_uuids );
     900        }
    883901}
  • tests/phpunit/tests/customize/manager.php

     
    2727        public $undefined;
    2828
    2929        /**
     30         * Admin user ID.
     31         *
     32         * @var int
     33         */
     34        protected static $admin_user_id;
     35
     36        /**
     37         * Set up before class.
     38         *
     39         * @param WP_UnitTest_Factory $factory Factory.
     40         */
     41        public static function wpSetUpBeforeClass( $factory ) {
     42                self::$admin_user_id = $factory->user->create( array( 'role' => 'administrator' ) );
     43        }
     44
     45        /**
    3046         * Set up test.
    3147         */
    3248        function setUp() {
     
    91107         * @ticket 30988
    92108         */
    93109        function test_unsanitized_post_values() {
     110                wp_set_current_user( self::$admin_user_id );
    94111                $manager = $this->manager;
    95112
    96113                $customized = array(
     
    108125         * @ticket 30988
    109126         */
    110127        function test_post_value() {
     128                wp_set_current_user( self::$admin_user_id );
    111129                $posted_settings = array(
    112130                        'foo' => 'OOF',
    113131                );
     
    131149         * @ticket 34893
    132150         */
    133151        function test_invalid_post_value() {
     152                wp_set_current_user( self::$admin_user_id );
    134153                $default_value = 'foo_default';
    135154                $setting = $this->manager->add_setting( 'foo', array(
    136155                        'validate_callback' => array( $this, 'filter_customize_validate_foo' ),
     
    196215         * @ticket 37247
    197216         */
    198217        function test_post_value_validation_sanitization_order() {
     218                wp_set_current_user( self::$admin_user_id );
    199219                $default_value = '0';
    200220                $setting = $this->manager->add_setting( 'numeric', array(
    201221                        'validate_callback' => array( $this, 'filter_customize_validate_numeric' ),
     
    240260         * @see WP_Customize_Manager::validate_setting_values()
    241261         */
    242262        function test_validate_setting_values() {
     263                wp_set_current_user( self::$admin_user_id );
    243264                $setting = $this->manager->add_setting( 'foo', array(
    244265                        'validate_callback' => array( $this, 'filter_customize_validate_foo' ),
    245266                        'sanitize_callback' => array( $this, 'filter_customize_sanitize_foo' ),
     
    284305         * @ticket 37247
    285306         */
    286307        function test_validate_setting_values_validation_sanitization_order() {
     308                wp_set_current_user( self::$admin_user_id );
    287309                $setting = $this->manager->add_setting( 'numeric', array(
    288310                        'validate_callback' => array( $this, 'filter_customize_validate_numeric' ),
    289311                        'sanitize_callback' => array( $this, 'filter_customize_sanitize_numeric' ),
     
    325347         * @see WP_Customize_Manager::set_post_value()
    326348         */
    327349        function test_set_post_value() {
     350                wp_set_current_user( self::$admin_user_id );
    328351                $this->manager->add_setting( 'foo', array(
    329352                        'sanitize_callback' => array( $this, 'sanitize_foo_for_test_set_post_value' ),
    330353                ) );
     
    429452                }
    430453                $this->assertFalse( $this->manager->has_published_pages() );
    431454
    432                 wp_set_current_user( $this->factory()->user->create( array( 'role' => 'editor' ) ) );
     455                wp_set_current_user( self::$admin_user_id );
    433456                $this->manager->nav_menus->customize_register();
    434457                $setting_id = 'nav_menus_created_posts';
    435458                $setting = $this->manager->get_setting( $setting_id );
     
    448471         * @ticket 30936
    449472         */
    450473        function test_register_dynamic_settings() {
     474                wp_set_current_user( self::$admin_user_id );
    451475                $posted_settings = array(
    452476                        'foo' => 'OOF',
    453477                        'bar' => 'RAB',
     
    547571                wp_set_current_user( self::factory()->user->create( array( 'role' => 'author' ) ) );
    548572                $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
    549573
    550                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     574                wp_set_current_user( self::$admin_user_id );
    551575                $this->assertTrue( current_user_can( 'edit_theme_options' ) );
    552576                $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
    553577
     
    640664         * @see WP_Customize_Manager::customize_pane_settings()
    641665         */
    642666        function test_customize_pane_settings() {
    643                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     667                wp_set_current_user( self::$admin_user_id );
    644668                $this->manager->register_controls();
    645669                $this->manager->prepare_controls();
    646670                $autofocus = array( 'control' => 'blogname' );
     
    662686                $data = json_decode( $json, true );
    663687                $this->assertNotEmpty( $data );
    664688
    665                 $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices' ), array_keys( $data ) );
     689                $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset' ), array_keys( $data ) );
    666690                $this->assertEquals( $autofocus, $data['autofocus'] );
    667691                $this->assertArrayHasKey( 'save', $data['nonce'] );
    668692                $this->assertArrayHasKey( 'preview', $data['nonce'] );
     
    674698         * @see WP_Customize_Manager::customize_preview_settings()
    675699         */
    676700        function test_customize_preview_settings() {
    677                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     701                wp_set_current_user( self::$admin_user_id );
    678702                $this->manager->register_controls();
    679703                $this->manager->prepare_controls();
    680704                $this->manager->set_post_value( 'foo', 'bar' );
     
    693717                $this->assertArrayHasKey( 'activePanels', $settings );
    694718                $this->assertArrayHasKey( 'activeSections', $settings );
    695719                $this->assertArrayHasKey( 'activeControls', $settings );
    696                 $this->assertArrayHasKey( 'settingValidities', $settings );
    697720                $this->assertArrayHasKey( 'nonce', $settings );
    698721                $this->assertArrayHasKey( '_dirty', $settings );
    699722
     
    770793                $manager = new WP_Customize_Manager();
    771794                $manager->register_controls();
    772795                $section_id = 'foo-section';
    773                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     796                wp_set_current_user( self::$admin_user_id );
    774797                $manager->add_section( $section_id, array(
    775798                        'title'      => 'Section',
    776799                        'priority'   => 1,
     
    801824         */
    802825        function test_add_section_return_instance() {
    803826                $manager = new WP_Customize_Manager();
    804                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     827                wp_set_current_user( self::$admin_user_id );
    805828
    806829                $section_id = 'foo-section';
    807830                $result_section = $manager->add_section( $section_id, array(
     
    828851         */
    829852        function test_add_setting_return_instance() {
    830853                $manager = new WP_Customize_Manager();
    831                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     854                wp_set_current_user( self::$admin_user_id );
    832855
    833856                $setting_id = 'foo-setting';
    834857                $result_setting = $manager->add_setting( $setting_id );
     
    899922         */
    900923        function test_add_panel_return_instance() {
    901924                $manager = new WP_Customize_Manager();
    902                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     925                wp_set_current_user( self::$admin_user_id );
    903926
    904927                $panel_id = 'foo-panel';
    905928                $result_panel = $manager->add_panel( $panel_id, array(
     
    926949        function test_add_control_return_instance() {
    927950                $manager = new WP_Customize_Manager();
    928951                $section_id = 'foo-section';
    929                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     952                wp_set_current_user( self::$admin_user_id );
    930953                $manager->add_section( $section_id, array(
    931954                        'title'    => 'Section',
    932955                        'priority' => 1,
  • tests/phpunit/tests/customize/setting.php

     
    9797         * @see WP_Customize_Setting::value()
    9898         */
    9999        function test_preview_standard_types_non_multidimensional() {
     100                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    100101                $_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
    101102
    102103                // Try non-multidimensional settings.
     
    175176         * @see WP_Customize_Setting::value()
    176177         */
    177178        function test_preview_standard_types_multidimensional() {
     179                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    178180                $_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
    179181
    180182                foreach ( $this->standard_type_configs as $type => $type_options ) {
     
    314316         * @see WP_Customize_Setting::preview()
    315317         */
    316318        function test_preview_custom_type() {
     319                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    317320                $type = 'custom_type';
    318321                $post_data_overrides = array(
    319322                        "unset_{$type}_with_post_value" => "unset_{$type}_without_post_value\\o/",
     
    478481         * @ticket 31428
    479482         */
    480483        function test_is_current_blog_previewed() {
     484                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    481485                $type = 'option';
    482486                $name = 'blogname';
    483487                $post_value = rand_str();
     
    502506                        $this->markTestSkipped( 'Cannot test WP_Customize_Setting::is_current_blog_previewed() with switch_to_blog() if not on multisite.' );
    503507                }
    504508
     509                wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
    505510                $type = 'option';
    506511                $name = 'blogdescription';
    507512                $post_value = rand_str();
     
    647652         * @ticket 37294
    648653         */
    649654        public function test_multidimensional_value_when_previewed() {
     655                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    650656                WP_Customize_Setting::reset_aggregated_multidimensionals();
    651657
    652658                $initial_value = 456;
  • tests/phpunit/tests/customize/selective-refresh-ajax.php

     
    140140        }
    141141
    142142        /**
    143          * Make sure that the Customizer "signature" is not included in partial render responses.
    144          *
    145          * @see WP_Customize_Selective_Refresh::handle_render_partials_request()
    146          */
    147         function test_handle_render_partials_request_removes_customize_signature() {
    148                 $this->setup_valid_render_partials_request_environment();
    149                 $this->assertTrue( is_customize_preview() );
    150                 $this->assertEquals( 1000, has_action( 'shutdown', array( $this->wp_customize, 'customize_preview_signature' ) ) );
    151                 ob_start();
    152                 try {
    153                         $this->selective_refresh->handle_render_partials_request();
    154                 } catch ( WPDieException $e ) {
    155                         unset( $e );
    156                 }
    157                 ob_end_clean();
    158                 $this->assertFalse( has_action( 'shutdown', array( $this->wp_customize, 'customize_preview_signature' ) ) );
    159         }
    160 
    161         /**
    162143         * Test WP_Customize_Selective_Refresh::handle_render_partials_request() for an unrecognized partial.
    163144         *
    164145         * @see WP_Customize_Selective_Refresh::handle_render_partials_request()
  • tests/phpunit/tests/post.php

     
    12401240                $this->assertEquals( 0, get_post( $page_id )->post_parent );
    12411241        }
    12421242
     1243        /**
     1244         * Test ensuring that the post_name (UUID) is preserved when wp_insert_post()/wp_update_post() is called.
     1245         *
     1246         * @see _wp_customize_changeset_filter_insert_post_data()
     1247         * @ticket 30937
     1248         */
     1249        function test_wp_insert_post_for_customize_changeset_should_not_drop_post_name() {
     1250
     1251                $this->assertEquals( 10, has_filter( 'wp_insert_post_data', '_wp_customize_changeset_filter_insert_post_data' ) );
     1252
     1253                $changeset_data = array(
     1254                        'blogname' => array(
     1255                                'value' => 'Hello World',
     1256                        ),
     1257                );
     1258
     1259                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'contributor' ) ) );
     1260
     1261                $uuid = wp_generate_uuid4();
     1262                $post_id = wp_insert_post( array(
     1263                        'post_type' => 'customize_changeset',
     1264                        'post_name' => strtoupper( $uuid ),
     1265                        'post_content' => wp_json_encode( $changeset_data ),
     1266                ) );
     1267                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected lower-case UUID4 to be inserted.' );
     1268                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1269
     1270                $changeset_data['blogname']['value'] = 'Hola Mundo';
     1271                wp_update_post( array(
     1272                        'ID' => $post_id,
     1273                        'post_status' => 'draft',
     1274                        'post_content' => wp_json_encode( $changeset_data ),
     1275                ) );
     1276                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected post_name to not have been dropped for drafts.' );
     1277                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1278
     1279                $changeset_data['blogname']['value'] = 'Hallo Welt';
     1280                wp_update_post( array(
     1281                        'ID' => $post_id,
     1282                        'post_status' => 'pending',
     1283                        'post_content' => wp_json_encode( $changeset_data ),
     1284                ) );
     1285                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected post_name to not have been dropped for pending.' );
     1286                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1287        }
     1288
    12431289}
  • src/wp-includes/admin-bar.php

     
    366366 * @since 4.3.0
    367367 *
    368368 * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar instance.
     369 * @global WP_Customize_Manager $wp_customize
    369370 */
    370371function wp_admin_bar_customize_menu( $wp_admin_bar ) {
     372        global $wp_customize;
     373
    371374        // Don't show for users who can't access the customizer or when in the admin.
    372375        if ( ! current_user_can( 'customize' ) || is_admin() ) {
    373376                return;
    374377        }
    375378
     379        // Don't show if the user cannot edit a given customize_changeset post currently being previewed.
     380        if ( is_customize_preview() && $wp_customize->changeset_post_id() && ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) {
     381                return;
     382        }
     383
    376384        $current_url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
     385        if ( is_customize_preview() && $wp_customize->changeset_uuid() ) {
     386                $current_url = remove_query_arg( 'customize_changeset_uuid', $current_url );
     387        }
     388
    377389        $customize_url = add_query_arg( 'url', urlencode( $current_url ), wp_customize_url() );
     390        if ( is_customize_preview() ) {
     391                $customize_url = add_query_arg( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ), $customize_url );
     392        }
    378393
    379394        $wp_admin_bar->add_menu( array(
    380395                'id'     => 'customize',
  • src/wp-includes/class-wp-customize-nav-menus.php

     
    4848                $this->previewed_menus = array();
    4949                $this->manager         = $manager;
    5050
    51                 // Skip useless hooks when the user can't manage nav menus anyway.
     51                // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L469-L499
     52                add_action( 'customize_register', array( $this, 'customize_register' ), 11 );
     53                add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 );
     54                add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 );
     55
     56                // Skip remaining hooks when the user can't manage nav menus anyway.
    5257                if ( ! current_user_can( 'edit_theme_options' ) ) {
    5358                        return;
    5459                }
     
    5863                add_action( 'wp_ajax_search-available-menu-items-customizer', array( $this, 'ajax_search_available_items' ) );
    5964                add_action( 'wp_ajax_customize-nav-menus-insert-auto-draft', array( $this, 'ajax_insert_auto_draft_post' ) );
    6065                add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
    61                 add_action( 'customize_register', array( $this, 'customize_register' ), 11 );
    62                 add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 );
    63                 add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 );
    6466                add_action( 'customize_controls_print_footer_scripts', array( $this, 'print_templates' ) );
    6567                add_action( 'customize_controls_print_footer_scripts', array( $this, 'available_items_template' ) );
    6668                add_action( 'customize_preview_init', array( $this, 'customize_preview_init' ) );
     
    486488         */
    487489        public function customize_register() {
    488490
     491                /*
     492                 * Preview settings for nav menus early so that the sections and controls will be added properly.
     493                 * See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L506-L543
     494                 */
     495                $nav_menus_setting_ids = array();
     496                foreach ( array_keys( $this->manager->unsanitized_post_values() ) as $setting_id ) {
     497                        if ( preg_match( '/^(nav_menu_locations|nav_menu|nav_menu_item)\[/', $setting_id ) ) {
     498                                $nav_menus_setting_ids[] = $setting_id;
     499                        }
     500                }
     501                foreach ( $nav_menus_setting_ids as $setting_id ) {
     502                        $setting = $this->manager->get_setting( $setting_id );
     503                        if ( $setting ) {
     504                                $setting->preview();
     505                        }
     506                }
     507
    489508                // Require JS-rendered control types.
    490509                $this->manager->register_panel_type( 'WP_Customize_Nav_Menus_Panel' );
    491510                $this->manager->register_control_type( 'WP_Customize_Nav_Menu_Control' );
  • src/wp-includes/post.php

     
    111111                'query_var' => false,
    112112        ) );
    113113
     114        register_post_type( 'customize_changeset', array(
     115                'labels' => array(
     116                        'name'               => _x( 'Changesets', 'post type general name' ),
     117                        'singular_name'      => _x( 'Changeset', 'post type singular name' ),
     118                        'menu_name'          => _x( 'Changesets', 'admin menu' ),
     119                        'name_admin_bar'     => _x( 'Changeset', 'add new on admin bar' ),
     120                        'add_new'            => _x( 'Add New', 'Customize Changeset' ),
     121                        'add_new_item'       => __( 'Add New Changeset' ),
     122                        'new_item'           => __( 'New Changeset' ),
     123                        'edit_item'          => __( 'Edit Changeset' ),
     124                        'view_item'          => __( 'View Changeset' ),
     125                        'all_items'          => __( 'All Changesets' ),
     126                        'search_items'       => __( 'Search Changesets' ),
     127                        'not_found'          => __( 'No changesets found.' ),
     128                        'not_found_in_trash' => __( 'No changesets found in Trash.' ),
     129                ),
     130                'public' => false,
     131                '_builtin' => true, /* internal use only. don't use this when registering your own post type. */
     132                'map_meta_cap' => true,
     133                'hierarchical' => false,
     134                'rewrite' => false,
     135                'query_var' => false,
     136                'can_export' => false,
     137                'delete_with_user' => false,
     138                'supports' => array( 'title', 'author' ),
     139                'capability_type' => 'customize_changeset',
     140                'capabilities' => array(
     141                        'create_posts' => 'customize',
     142                        'delete_others_posts' => 'customize',
     143                        'delete_post' => 'customize',
     144                        'delete_posts' => 'customize',
     145                        'delete_private_posts' => 'customize',
     146                        'delete_published_posts' => 'customize',
     147                        'edit_others_posts' => 'customize',
     148                        'edit_post' => 'customize',
     149                        'edit_posts' => 'customize',
     150                        'edit_private_posts' => 'customize',
     151                        'edit_published_posts' => 'do_not_allow',
     152                        'publish_posts' => 'customize',
     153                        'read' => 'read',
     154                        'read_post' => 'customize',
     155                        'read_private_posts' => 'customize',
     156                ),
     157        ) );
     158
    114159        register_post_status( 'publish', array(
    115160                'label'       => _x( 'Published', 'post status' ),
    116161                'public'      => true,
  • src/wp-includes/default-filters.php

     
    7575
    7676// Slugs
    7777add_filter( 'pre_term_slug', 'sanitize_title' );
     78add_filter( 'wp_insert_post_data', '_wp_customize_changeset_filter_insert_post_data', 10, 2 );
    7879
    7980// Keys
    8081foreach ( array( 'pre_post_type', 'pre_post_status', 'pre_post_comment_status', 'pre_post_ping_status' ) as $filter ) {
     
    382383add_action( 'wp_loaded', '_custom_header_background_just_in_time' );
    383384add_action( 'wp_head', '_custom_logo_header_styles' );
    384385add_action( 'plugins_loaded', '_wp_customize_include' );
     386add_action( 'publish_customize_changeset', '_wp_customize_publish_changeset', 10, 2 );
    385387add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' );
    386388add_action( 'delete_attachment', '_delete_attachment_theme_mod' );
    387389
  • src/wp-includes/customize/class-wp-customize-selective-refresh.php

     
    307307                        return;
    308308                }
    309309
    310                 $this->manager->remove_preview_signature();
    311 
    312310                /*
    313311                 * Note that is_customize_preview() returning true will entail that the
    314312                 * user passed the 'customize' capability check and the nonce check, since
  • src/wp-includes/customize/class-wp-customize-theme-control.php

     
    6363         */
    6464        public function content_template() {
    6565                $current_url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
    66                 $active_url  = esc_url( remove_query_arg( 'theme', $current_url ) );
    67                 $preview_url = esc_url( add_query_arg( 'theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces.
     66                $active_url  = esc_url( remove_query_arg( 'customize_theme', $current_url ) );
     67                $preview_url = esc_url( add_query_arg( 'customize_theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces.
    6868                $preview_url = str_replace( '__THEME__', '{{ data.theme.id }}', $preview_url );
    6969                ?>
    7070                <# if ( data.theme.isActiveTheme ) { #>
  • src/wp-includes/script-loader.php

     
    447447
    448448        $scripts->add( 'customize-base',     "/wp-includes/js/customize-base$suffix.js",     array( 'jquery', 'json2', 'underscore' ), false, 1 );
    449449        $scripts->add( 'customize-loader',   "/wp-includes/js/customize-loader$suffix.js",   array( 'customize-base' ), false, 1 );
    450         $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'customize-base' ), false, 1 );
     450        $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'wp-a11y', 'customize-base' ), false, 1 );
    451451        $scripts->add( 'customize-models',   "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 );
    452452        $scripts->add( 'customize-views',    "/wp-includes/js/customize-views.js",  array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 );
    453453        $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base', 'wp-a11y', 'wp-util' ), false, 1 );
  • src/wp-includes/class-wp-customize-manager.php

     
    130130        protected $controls = array();
    131131
    132132        /**
    133          * Return value of check_ajax_referer() in customize_preview_init() method.
    134          *
    135          * @since 3.5.0
    136          * @access protected
    137          * @var false|int
    138          */
    139         protected $nonce_tick;
    140 
    141         /**
    142133         * Panel types that may be rendered from JS templates.
    143134         *
    144135         * @since 4.3.0
     
    193184        protected $autofocus = array();
    194185
    195186        /**
     187         * Changeset UUID.
     188         *
     189         * @since 4.7.0
     190         * @access protected
     191         * @var string
     192         */
     193        protected $changeset_uuid;
     194
     195        /**
     196         * Messenger channel.
     197         *
     198         * @since 4.7.0
     199         * @access protected
     200         * @var string
     201         */
     202        protected $messenger_channel;
     203
     204        /**
    196205         * Unsanitized values for Customize Settings parsed from $_POST['customized'].
    197206         *
    198207         * @var array
     
    200209        private $_post_values;
    201210
    202211        /**
     212         * Changeset post ID.
     213         *
     214         * @var int|false
     215         */
     216        private $_changeset_post_id;
     217
     218        /**
     219         * Changeset data loaded from a customize_changeset post.
     220         *
     221         * @var array
     222         */
     223        private $_changeset_data;
     224
     225        /**
    203226         * Constructor.
    204227         *
    205228         * @since 3.4.0
     229         * @since 4.7.0 Added $args param.
     230         *
     231         * @param array $args {
     232         *     Args.
     233         *
     234         *     @type string $changeset_uuid    Changeset UUID, the post_name for the customize_changeset post containing the customized state. Defaults to new UUID.
     235         *     @type string $theme             Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params.
     236         *     @type string $messenger_channel Messenger channel. Defaults to customize_messenger_channel query param.
     237         * }
    206238         */
    207         public function __construct() {
     239        public function __construct( $args = array() ) {
     240
     241                $args = array_merge(
     242                        array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel' ), null ),
     243                        $args
     244                );
     245
     246                if ( ! isset( $args['changeset_uuid'] ) ) {
     247                        $args['changeset_uuid'] = wp_generate_uuid4();
     248                }
     249
     250                // The theme and messenger_channel should be supplied via $args, but they are also looked at in the $_REQUEST global here for back-compat.
     251                if ( ! isset( $args['theme'] ) ) {
     252                        if ( isset( $_REQUEST['customize_theme'] ) ) {
     253                                $args['theme'] = wp_unslash( $_REQUEST['customize_theme'] );
     254                        } elseif ( isset( $_REQUEST['theme'] ) ) { // Deprecated.
     255                                $args['theme'] = wp_unslash( $_REQUEST['theme'] );
     256                        }
     257                }
     258                if ( ! isset( $args['messenger_channel'] ) && isset( $_REQUEST['customize_messenger_channel'] ) ) {
     259                        $args['messenger_channel'] = sanitize_key( wp_unslash( $_REQUEST['customize_messenger_channel'] ) );
     260                }
     261
     262                $this->original_stylesheet = get_stylesheet();
     263                $this->theme = wp_get_theme( $args['theme'] );
     264                foreach ( array( 'changeset_uuid', 'messenger_channel' ) as $key ) {
     265                        $this->$key = $args[ $key ];
     266                }
     267
    208268                require_once( ABSPATH . WPINC . '/class-wp-customize-setting.php' );
    209269                require_once( ABSPATH . WPINC . '/class-wp-customize-panel.php' );
    210270                require_once( ABSPATH . WPINC . '/class-wp-customize-section.php' );
     
    271331                        $this->nav_menus = new WP_Customize_Nav_Menus( $this );
    272332                }
    273333
    274                 add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) );
    275 
    276334                add_action( 'setup_theme', array( $this, 'setup_theme' ) );
    277335                add_action( 'wp_loaded',   array( $this, 'wp_loaded' ) );
    278336
    279                 // Run wp_redirect_status late to make sure we override the status last.
    280                 add_action( 'wp_redirect_status', array( $this, 'wp_redirect_status' ), 1000 );
    281 
    282337                // Do not spawn cron (especially the alternate cron) while running the Customizer.
    283338                remove_action( 'init', 'wp_cron' );
    284339
     
    340395         * @param mixed $message UI message
    341396         */
    342397        protected function wp_die( $ajax_message, $message = null ) {
    343                 if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
     398                if ( $this->doing_ajax() ) {
    344399                        wp_die( $ajax_message );
    345400                }
    346401
     
    348403                        $message = __( 'Cheatin&#8217; uh?' );
    349404                }
    350405
     406                if ( $this->messenger_channel ) {
     407                        header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
     408                        echo '<!DOCTYPE html><html>';
     409                        wp_print_scripts( array( 'customize-base' ) );
     410
     411                        $settings = array(
     412                                'messengerArgs' => array(
     413                                        'channel' => $this->messenger_channel,
     414                                        'url' => wp_customize_url(),
     415                                ),
     416                                'error' => $ajax_message,
     417                        );
     418                        ?>
     419                        <script>
     420                        ( function( api, settings ) {
     421                                var preview = new api.Messenger( settings.messengerArgs );
     422                                preview.send( 'iframe-loading-error', settings.error );
     423                        } )( wp.customize, <?php echo wp_json_encode( $settings ) ?> );
     424                        </script>
     425                        <?php
     426                        echo "<p>$message</p>";
     427                        echo '</html>';
     428                        die(); // @todo Not testable.
     429                }
     430
    351431                wp_die( $message );
    352432        }
    353433
     
    355435         * Return the Ajax wp_die() handler if it's a customized request.
    356436         *
    357437         * @since 3.4.0
     438         * @deprecated 4.7.0
    358439         *
    359          * @return string
     440         * @return callable Die handler.
    360441         */
    361442        public function wp_die_handler() {
     443                _deprecated_function( __METHOD__, '4.7.0' );
     444
    362445                if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
    363446                        return '_ajax_wp_die_handler';
    364447                }
     
    374457         * @since 3.4.0
    375458         */
    376459        public function setup_theme() {
    377                 send_origin_headers();
     460                global $pagenow;
    378461
    379                 $doing_ajax_or_is_customized = ( $this->doing_ajax() || isset( $_POST['customized'] ) );
    380                 if ( is_admin() && ! $doing_ajax_or_is_customized ) {
    381                         auth_redirect();
    382                 } elseif ( $doing_ajax_or_is_customized && ! is_user_logged_in() ) {
    383                         $this->wp_die( 0, __( 'You must be logged in to complete this action.' ) );
     462                // Check permissions for customize.php access since this method is called before customize.php can run any code,
     463                if ( 'customize.php' === $pagenow && ! current_user_can( 'customize' ) ) {
     464                        if ( ! is_user_logged_in() ) {
     465                                auth_redirect();
     466                        } else {
     467                                wp_die(
     468                                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     469                                        '<p>' . __( 'Sorry, you are not allowed to customize this site.' ) . '</p>',
     470                                        403
     471                                );
     472                        }
     473                        return;
    384474                }
    385475
    386                 show_admin_bar( false );
     476                if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $this->changeset_uuid ) ) {
     477                        $this->wp_die( -1, __( 'Invalid changeset UUID' ) );
     478                }
    387479
    388                 if ( ! current_user_can( 'customize' ) ) {
    389                         $this->wp_die( -1, __( 'Sorry, you are not allowed to customize this site.' ) );
     480                // If unauthenticated then require a valid changeset UUID to load the preview. In this way, the UUID serves as a secret key.
     481                if ( ! current_user_can( 'customize' ) && ! $this->changeset_post_id() ) {
     482                        $this->wp_die( -1, __( 'Non-existent changeset UUID.' ) );
    390483                }
    391484
    392                 $this->original_stylesheet = get_stylesheet();
     485                send_origin_headers();
    393486
    394                 $this->theme = wp_get_theme( isset( $_REQUEST['theme'] ) ? $_REQUEST['theme'] : null );
     487                // Hide the admin bar if we're embedded in the customizer iframe.
     488                if ( $this->messenger_channel ) {
     489                        show_admin_bar( false );
     490                }
    395491
    396492                if ( $this->is_theme_active() ) {
    397493                        // Once the theme is loaded, we'll validate it.
     
    507603        }
    508604
    509605        /**
     606         * Get the changeset UUID.
     607         *
     608         * @since 4.7.0
     609         * @access public
     610         *
     611         * @return string UUID.
     612         */
     613        public function changeset_uuid() {
     614                return $this->changeset_uuid;
     615        }
     616
     617        /**
    510618         * Get the theme being customized.
    511619         *
    512620         * @since 3.4.0
     
    603711                 */
    604712                do_action( 'customize_register', $this );
    605713
    606                 if ( $this->is_preview() && ! is_admin() )
     714                /*
     715                 * Note that settings must be previewed here even outside the customizer preview
     716                 * and also in the customizer pane itself. This is to enable loading an existing
     717                 * changeset into the customizer. Previewing the settings only has to be prevented
     718                 * in the case of a customize_save action because then update_option()
     719                 * may short-circuit because it will detect that there are no changes to
     720                 * make.
     721                 */
     722                if ( ! $this->doing_ajax( 'customize_save' ) ) {
     723                        foreach ( $this->settings as $setting ) {
     724                                $setting->preview();
     725                        }
     726                }
     727
     728                if ( $this->is_preview() && ! is_admin() ) {
    607729                        $this->customize_preview_init();
     730                }
    608731        }
    609732
    610733        /**
     
    614737         * Instead, the JS will sniff out the location header.
    615738         *
    616739         * @since 3.4.0
     740         * @deprecated 4.7.0
    617741         *
    618          * @param $status
     742         * @param int $status Status.
    619743         * @return int
    620744         */
    621745        public function wp_redirect_status( $status ) {
    622                 if ( $this->is_preview() && ! is_admin() )
     746                _deprecated_function( __FUNCTION__, '4.7.0' );
     747
     748                if ( $this->is_preview() && ! is_admin() ) {
    623749                        return 200;
     750                }
    624751
    625752                return $status;
    626753        }
    627754
    628755        /**
     756         * Get the changeset post.
     757         *
     758         * @todo Add persistent object caching for query.
     759         *
     760         * @since 4.7.0
     761         * @access public
     762         *
     763         * @return int|false
     764         */
     765        public function changeset_post_id() {
     766                if ( isset( $this->_changeset_post_id ) ) {
     767                        return $this->_changeset_post_id;
     768                }
     769                $changeset_post_query = new WP_Query( array(
     770                        'post_type' => 'customize_changeset',
     771                        'post_status' => get_post_stati(),
     772                        'name' => $this->changeset_uuid,
     773                        'number' => 1,
     774                        'no_found_rows' => true,
     775                        'update_post_meta_cache' => false,
     776                        'update_term_meta_cache' => false,
     777                ) );
     778                if ( ! empty( $changeset_post_query->posts ) ) {
     779                        $this->_changeset_post_id = $changeset_post_query->posts[0]->ID;
     780                } else {
     781                        $this->_changeset_post_id = false;
     782                }
     783                return $this->_changeset_post_id;
     784        }
     785
     786        /**
     787         * Get changeset data.
     788         *
     789         * @since 4.7.0
     790         *
     791         * @return array Changeset data.
     792         */
     793        public function changeset_data() {
     794                if ( isset( $this->_changeset_data ) ) {
     795                        return $this->_changeset_data;
     796                }
     797                $changeset_post_id = $this->changeset_post_id();
     798                if ( ! $changeset_post_id ) {
     799                        $this->_changeset_data = array();
     800                } else {
     801                        $changeset_post = get_post( $changeset_post_id );
     802                        if ( $changeset_post ) {
     803                                $changeset = json_decode( $changeset_post->post_content, true );
     804                                if ( is_array( $changeset ) ) {
     805                                        $this->_changeset_data = $changeset;
     806                                } else {
     807                                        $this->_changeset_data = array();
     808                                }
     809                        }
     810                }
     811                return $this->_changeset_data;
     812        }
     813
     814        /**
    629815         * Parse the incoming $_POST['customized'] JSON data and store the unsanitized
    630816         * settings for subsequent post_value() lookups.
    631817         *
    632818         * @since 4.1.1
     819         * @since 4.7.0 Added $args param.
    633820         *
     821         * @param array $args {
     822         *     Args.
     823         *
     824         *     @type bool $exclude_changeset Whether or not the changeset values should also be included. This is excluded obtaining values for updating a snapshot.
     825         *     @type bool $exclude_post_data Whether or not the post input values should also be included. When lacking a valid nonce, this should be excluded.
     826         * }
    634827         * @return array
    635828         */
    636         public function unsanitized_post_values() {
    637                 if ( ! isset( $this->_post_values ) ) {
    638                         if ( isset( $_POST['customized'] ) ) {
    639                                 $this->_post_values = json_decode( wp_unslash( $_POST['customized'] ), true );
     829        public function unsanitized_post_values( $args = array() ) {
     830                $args = array_merge(
     831                        array(
     832                                'exclude_changeset' => false,
     833                                'exclude_post_data' => ! current_user_can( 'customize' ),
     834                        ),
     835                        $args
     836                );
     837
     838                $values = array();
     839
     840                // Let default values be from the stashed theme mods if doing a theme switch and if no changeset is present.
     841                if ( ! $this->is_theme_active() && ! $this->changeset_post_id() ) {
     842                        $stashed_theme_mods = get_option( 'customize_stashed_theme_mods' );
     843                        $stylesheet = $this->get_stylesheet();
     844                        if ( isset( $stashed_theme_mods[ $stylesheet ] ) ) {
     845                                $values = array_merge( $values, wp_list_pluck( $stashed_theme_mods[ $stylesheet ], 'value' ) );
    640846                        }
    641                         if ( empty( $this->_post_values ) ) { // if not isset or if JSON error
    642                                 $this->_post_values = array();
     847                }
     848
     849                if ( ! $args['exclude_changeset'] ) {
     850                        foreach ( $this->changeset_data() as $setting_id => $setting_params ) {
     851                                if ( ! array_key_exists( 'value', $setting_params ) ) {
     852                                        continue;
     853                                }
     854                                if ( isset( $setting_params['type'] ) && 'theme_mod' === $setting_params['type'] ) {
     855
     856                                        // Ensure that theme mods values are only used if they were saved under the current theme.
     857                                        $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     858                                        if ( preg_match( $namespace_pattern, $setting_id, $matches ) && $this->get_stylesheet() === $matches['stylesheet'] ) {
     859                                                $values[ $matches['setting_id'] ] = $setting_params['value'];
     860                                        }
     861                                } else {
     862                                        $values[ $setting_id ] = $setting_params['value'];
     863                                }
    643864                        }
    644865                }
    645                 if ( empty( $this->_post_values ) ) {
    646                         return array();
    647                 } else {
    648                         return $this->_post_values;
     866
     867                if ( ! $args['exclude_post_data'] ) {
     868                        if ( ! isset( $this->_post_values ) ) {
     869                                if ( isset( $_POST['customized'] ) ) {
     870                                        $post_values = json_decode( wp_unslash( $_POST['customized'] ), true );
     871                                } else {
     872                                        $post_values = array();
     873                                }
     874                                if ( is_array( $post_values ) ) {
     875                                        $this->_post_values = $post_values;
     876                                } else {
     877                                        $this->_post_values = array();
     878                                }
     879                        }
     880                        $values = array_merge( $values, $this->_post_values );
    649881                }
     882                return $values;
    650883        }
    651884
    652885        /**
     
    733966         * @since 3.4.0
    734967         */
    735968        public function customize_preview_init() {
    736                 $this->nonce_tick = check_ajax_referer( 'preview-customize_' . $this->get_stylesheet(), 'nonce' );
    737969
    738                 $this->prepare_controls();
     970                /*
     971                 * Now that Customizer previews are loaded into iframes via GET requests
     972                 * and natural URLs with transaction UUIDs added, we need to ensure that
     973                 * the responses are never cached by proxies. In practice, this will not
     974                 * be needed if the user is logged-in anyway. But if anonymous access is
     975                 * allowed then the auth cookies would not be sent and WordPress would
     976                 * not send no-cache headers by default.
     977                 */
     978                nocache_headers();
     979                if ( ! headers_sent() ) {
     980                        header( 'X-Robots: noindex, nofollow, noarchive' );
     981                }
     982                add_action( 'wp_head', 'wp_no_robots' );
     983                add_filter( 'wp_headers', array( $this, 'filter_iframe_security_headers' ) );
     984
     985                /*
     986                 * If preview is being served inside the customizer preview iframe, and
     987                 * if the user doesn't have customize capability, then it is assumed
     988                 * that the user's session has expired and they need to re-authenticate.
     989                 */
     990                if ( $this->messenger_channel && ! current_user_can( 'customize' ) ) {
     991                        $this->wp_die( -1, __( 'Unauthorized. You may remove the customize_messenger_channel param to preview as frontend.' ) );
     992                        return;
     993                }
     994
     995                if ( current_user_can( 'customize' ) ) {
     996                        $this->prepare_controls();
     997                }
    739998
    740999                wp_enqueue_script( 'customize-preview' );
    741                 add_action( 'wp', array( $this, 'customize_preview_override_404_status' ) );
    742                 add_action( 'wp_head', array( $this, 'customize_preview_base' ) );
    7431000                add_action( 'wp_head', array( $this, 'customize_preview_loading_style' ) );
    7441001                add_action( 'wp_footer', array( $this, 'customize_preview_settings' ), 20 );
    745                 add_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 );
    746                 add_filter( 'wp_die_handler', array( $this, 'remove_preview_signature' ) );
    747 
    748                 foreach ( $this->settings as $setting ) {
    749                         $setting->preview();
    750                 }
    7511002
    7521003                /**
    7531004                 * Fires once the Customizer preview has initialized and JavaScript
     
    7611012        }
    7621013
    7631014        /**
     1015         * Filter the X-Frame-Options and Content-Security-Policy headers to ensure frontend can load in customizer.
     1016         *
     1017         * @since 4.7.0
     1018         * @access public
     1019         *
     1020         * @param array $headers Headers.
     1021         * @return array Headers.
     1022         */
     1023        public function filter_iframe_security_headers( $headers ) {
     1024                $customize_url = admin_url( 'customize.php' );
     1025                $headers['X-Frame-Options'] = 'ALLOW-FROM ' . $customize_url;
     1026                $headers['Content-Security-Policy'] = 'frame-ancestors ' . preg_replace( '#^(\w+://[^/]+).+?$#', '$1', $customize_url );
     1027                return $headers;
     1028        }
     1029
     1030        /**
    7641031         * Prevent sending a 404 status when returning the response for the customize
    7651032         * preview, since it causes the jQuery Ajax to fail. Send 200 instead.
    7661033         *
    7671034         * @since 4.0.0
     1035         * @deprecated 4.7.0
    7681036         * @access public
    7691037         */
    7701038        public function customize_preview_override_404_status() {
    771                 if ( is_404() ) {
    772                         status_header( 200 );
    773                 }
     1039                _deprecated_function( __METHOD__, '4.7.0' );
    7741040        }
    7751041
    7761042        /**
    7771043         * Print base element for preview frame.
    7781044         *
    7791045         * @since 3.4.0
     1046         * @deprecated 4.7.0
    7801047         */
    7811048        public function customize_preview_base() {
    782                 ?><base href="<?php echo home_url( '/' ); ?>" /><?php
     1049                _deprecated_function( __METHOD__, '4.7.0' );
    7831050        }
    7841051
    7851052        /**
     
    8091076                        body.wp-customizer-unloading * {
    8101077                                pointer-events: none !important;
    8111078                        }
     1079                        form.customize-unpreviewable,
     1080                        form.customize-unpreviewable input,
     1081                        form.customize-unpreviewable select,
     1082                        form.customize-unpreviewable button,
     1083                        a.customize-unpreviewable,
     1084                        area.customize-unpreviewable {
     1085                                cursor: not-allowed !important;
     1086                        }
    8121087                </style><?php
    8131088        }
    8141089
     
    8181093         * @since 3.4.0
    8191094         */
    8201095        public function customize_preview_settings() {
    821                 $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
    822                 $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
     1096                $setting_values = $this->unsanitized_post_values();
     1097
     1098                $self_url = home_url( empty( $_SERVER['REQUEST_URI'] ) ? '/' : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
     1099                $state_query_params = array(
     1100                        'customize_theme',
     1101                        'customize_changeset_uuid',
     1102                        'customize_messenger_channel',
     1103                );
     1104                $self_url = remove_query_arg( $state_query_params, $self_url );
    8231105
     1106                $allowed_urls = $this->get_allowed_urls();
     1107                $allowed_hosts = array();
     1108                foreach ( $allowed_urls as $allowed_url ) {
     1109                        $parsed = wp_parse_url( $allowed_url );
     1110                        if ( empty( $parsed['host'] ) ) {
     1111                                continue;
     1112                        }
     1113                        $host = $parsed['host'];
     1114                        if ( ! empty( $parsed['port'] ) ) {
     1115                                $host .= ':' . $parsed['port'];
     1116                        }
     1117                        $allowed_hosts[] = $host;
     1118                }
    8241119                $settings = array(
     1120                        'changeset' => array(
     1121                                'uuid' => $this->changeset_uuid,
     1122                                'stateQueryParams' => $state_query_params, // @todo Additional persisted query vars may need to be indiated via a customized_persisted_query_vars param.
     1123                        ),
    8251124                        'theme' => array(
    8261125                                'stylesheet' => $this->get_stylesheet(),
    8271126                                'active'     => $this->is_theme_active(),
    8281127                        ),
    8291128                        'url' => array(
    830                                 'self' => empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ),
     1129                                'self' => $self_url,
     1130                                'allowed' => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     1131                                'allowedHosts' => array_unique( $allowed_hosts ),
     1132                                'isCrossDomain' => $this->is_cross_domain(),
    8311133                        ),
    832                         'channel' => wp_unslash( $_POST['customize_messenger_channel'] ),
     1134                        'channel' => $this->messenger_channel,
    8331135                        'activePanels' => array(),
    8341136                        'activeSections' => array(),
    8351137                        'activeControls' => array(),
    836                         'settingValidities' => $exported_setting_validities,
    837                         'nonce' => $this->get_nonces(),
     1138                        'nonce' => current_user_can( 'customize' ) ? $this->get_nonces() : array(),
    8381139                        'l10n' => array(
    8391140                                'shiftClickToEdit' => __( 'Shift-click to edit this element.' ),
     1141                                'linkUnpreviewable' => __( 'This link is not live-previewable.' ),
     1142                                'formUnpreviewable' => __( 'This form is not live-previewable.' ),
    8401143                        ),
    841                         '_dirty' => array_keys( $this->unsanitized_post_values() ),
     1144                        '_dirty' => array_keys( $setting_values ),
    8421145                );
    8431146
    8441147                foreach ( $this->panels as $panel_id => $panel ) {
     
    8921195         * Prints a signature so we can ensure the Customizer was properly executed.
    8931196         *
    8941197         * @since 3.4.0
     1198         * @deprecated 4.7.0
    8951199         */
    8961200        public function customize_preview_signature() {
    897                 echo 'WP_CUSTOMIZER_SIGNATURE';
     1201                _deprecated_function( __METHOD__, '4.7.0' );
    8981202        }
    8991203
    9001204        /**
    9011205         * Removes the signature in case we experience a case where the Customizer was not properly executed.
    9021206         *
    9031207         * @since 3.4.0
     1208         * @deprecated 4.7.0
    9041209         *
    9051210         * @param mixed $return Value passed through for {@see 'wp_die_handler'} filter.
    9061211         * @return mixed Value passed through for {@see 'wp_die_handler'} filter.
    9071212         */
    9081213        public function remove_preview_signature( $return = null ) {
    909                 remove_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 );
     1214                _deprecated_function( __METHOD__, '4.7.0' );
    9101215
    9111216                return $return;
    9121217        }
     
    9931298         * @see WP_Customize_Setting::validate()
    9941299         *
    9951300         * @param array $setting_values Mapping of setting IDs to values to validate and sanitize.
     1301         * @param array $options {
     1302         *     Options.
     1303         *
     1304         *     @var bool $validate_existence    Whether a setting's existence will be checked.
     1305         *     @var bool $validate_capability Whether the setting capability will be checked.
     1306         * }
    9961307         * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`.
    9971308         */
    998         public function validate_setting_values( $setting_values ) {
     1309        public function validate_setting_values( $setting_values, $options = array() ) {
     1310                $options = wp_parse_args( $options, array(
     1311                        'validate_capability' => false,
     1312                        'validate_existence' => false,
     1313                ) );
     1314
    9991315                $validities = array();
    10001316                foreach ( $setting_values as $setting_id => $unsanitized_value ) {
    10011317                        $setting = $this->get_setting( $setting_id );
    1002                         if ( ! $setting || is_null( $unsanitized_value ) ) {
     1318                        if ( ! $setting ) {
     1319                                if ( $options['validate_existence'] ) {
     1320                                        $validities[ $setting_id ] = new WP_Error( 'unrecognized_setting', __( 'Setting does not exist or is unrecognized.' ) );
     1321                                }
     1322                                continue;
     1323                        }
     1324                        if ( is_null( $unsanitized_value ) ) {
     1325                                // @todo Should this be skipped or be invalid?
    10031326                                continue;
    10041327                        }
    1005                         $validity = $setting->validate( $unsanitized_value );
     1328                        if ( $options['validate_capability'] && ! current_user_can( $setting->capability ) ) {
     1329                                $validity = new WP_Error( 'unauthorized', __( 'Unauthorized.' ) );
     1330                        } else {
     1331                                $validity = $setting->validate( $unsanitized_value );
     1332                        }
    10061333                        if ( ! is_wp_error( $validity ) ) {
    10071334                                $value = $setting->sanitize( $unsanitized_value );
    10081335                                if ( is_null( $value ) ) {
     
    10491376        }
    10501377
    10511378        /**
    1052          * Switch the theme and trigger the save() method on each setting.
     1379         * Save (update) changeset.
    10531380         *
    10541381         * @since 3.4.0
     1382         * @since 4.7.0 The semantics of this method have changed to update a changeset, optionally to also change the status.
    10551383         */
    10561384        public function save() {
     1385                if ( ! is_user_logged_in() ) {
     1386                        wp_send_json_error( 'unauthenticated' );
     1387                }
     1388
    10571389                if ( ! $this->is_preview() ) {
    10581390                        wp_send_json_error( 'not_preview' );
    10591391                }
     
    10631395                        wp_send_json_error( 'invalid_nonce' );
    10641396                }
    10651397
     1398                $changeset_post_id = $this->changeset_post_id();
     1399                if ( $changeset_post_id && 'publish' === get_post_status( $changeset_post_id ) ) {
     1400                        wp_send_json_error( 'changeset_already_published' );
     1401                }
     1402
     1403                if ( empty( $changeset_post_id ) ) {
     1404                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->create_posts ) ) {
     1405                                wp_send_json_error( 'cannot_create_changeset_post' );
     1406                        }
     1407                } else {
     1408                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
     1409                                wp_send_json_error( 'cannot_edit_changeset_post' );
     1410                        }
     1411                }
     1412
     1413                if ( ! empty( $_POST['customize_changeset_data'] ) ) {
     1414                        $input_changeset_data = json_decode( wp_unslash( $_POST['customize_changeset_data'] ), true );
     1415                        if ( ! is_array( $input_changeset_data ) ) {
     1416                                wp_send_json_error( 'invalid_customize_changeset_data' );
     1417                        }
     1418                        foreach ( $input_changeset_data as $setting_id => $setting_params ) {
     1419                                if ( array_key_exists( 'value', $setting_params ) ) {
     1420                                        $this->set_post_value( $setting_id, $setting_params['value'] ); // Add to post values so that they can be validated and sanitized.
     1421                                }
     1422                        }
     1423                        $this->add_dynamic_settings( array_keys( $input_changeset_data ) ); // Ensure settings get created even if they lack an input value.
     1424                } else {
     1425                        $input_changeset_data = array();
     1426                }
     1427
     1428                // Validate title.
     1429                $changeset_title = null;
     1430                if ( isset( $_POST['customize_changeset_title'] ) ) {
     1431                        $changeset_title = sanitize_text_field( wp_unslash( $_POST['customize_changeset_title'] ) );
     1432                }
     1433
     1434                // Validate changeset status param.
     1435                $changeset_status = null;
     1436                if ( isset( $_POST['customize_changeset_status'] ) ) {
     1437                        $changeset_status = wp_unslash( $_POST['customize_changeset_status'] );
     1438                        if ( ! get_post_status_object( $changeset_status ) || ! in_array( $changeset_status, array( 'draft', 'pending', 'publish', 'future' ), true ) ) {
     1439                                wp_send_json_error( 'bad_customize_changeset_status', 400 );
     1440                        }
     1441                        $is_publish = ( 'publish' === $changeset_status || 'future' === $changeset_status );
     1442                        if ( $is_publish ) {
     1443                                if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     1444                                        wp_send_json_error( 'changeset_publish_unauthorized', 403 );
     1445                                }
     1446                                if ( false === has_action( 'publish_customize_changeset', '_wp_customize_publish_changeset' ) ) {
     1447                                        wp_send_json_error( 'missing_publish_callback', 500 );
     1448                                }
     1449                        }
     1450                }
     1451
     1452                // Validate changeset date param. Date is assumed to be in local time for the WP.
     1453                $changeset_date = null;
     1454                if ( isset( $_POST['customize_changeset_date'] ) ) {
     1455                        $changeset_date = wp_unslash( $_POST['customize_changeset_date'] );
     1456                        if ( ! preg_match( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $changeset_date ) ) {
     1457                                wp_send_json_error( 'bad_customize_changeset_date', 400 );
     1458                        }
     1459                        if ( ( 'publish' === $changeset_status || 'future' === $changeset_status ) && ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     1460                                wp_send_json_error( 'changeset_publish_unauthorized', 403 );
     1461                        }
     1462                        $changeset_date_gmt = get_gmt_from_date( $changeset_date );
     1463                        $now = gmdate( 'Y-m-d H:i:59' );
     1464                        $is_future_dated = ( mysql2date( 'U', $changeset_date_gmt, false ) > mysql2date( 'U', $now, false ) );
     1465                        if ( ! $this->is_theme_active() && ( 'future' === $changeset_status || $is_future_dated ) ) {
     1466                                wp_send_json_error( 'cannot_schedule_theme_switches', 400 ); // @todo This should be allowed in the future, when theme is a regular setting.
     1467                        }
     1468                        $will_remain_auto_draft = ( ! $changeset_status && ( ! $changeset_post_id || 'auto-draft' === get_post_status( $changeset_post_id ) ) );
     1469                        if ( $changeset_date && $will_remain_auto_draft ) {
     1470                                wp_send_json_error( 'cannot_supply_date_for_auto_draft_changeset', 400 );
     1471                        }
     1472                }
     1473
    10661474                /**
    10671475                 * Fires before save validation happens.
    10681476                 *
     
    10771485                do_action( 'customize_save_validation_before', $this );
    10781486
    10791487                // Validate settings.
    1080                 $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1488                $post_values = $this->unsanitized_post_values( array(
     1489                        'exclude_changeset' => true,
     1490                        'exclude_post_data' => false,
     1491                ) );
     1492                $this->add_dynamic_settings( array_keys( $post_values ) );
     1493                $setting_validities = $this->validate_setting_values( $post_values, array(
     1494                        'validate_capability' => true,
     1495                        'validate_existence' => true,
     1496                ) );
    10811497                $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) );
    10821498                $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
    1083                 if ( $invalid_setting_count > 0 ) {
     1499
     1500                /*
     1501                 * Short-circuit if there are invalid settings and attempting to transition
     1502                 * the changeset to a new status (i.e. publish). When the changeset_status
     1503                 * is set then the request is considered transactional, where if any of the
     1504                 * setting are invalid then none of them will be saved.
     1505                 *
     1506                 * @todo Consider a separate 'transactional' argument which defaults to true if the $changeset_status is set.
     1507                 */
     1508                if ( $changeset_status && $invalid_setting_count > 0 ) {
    10841509                        $response = array(
    10851510                                'setting_validities' => $exported_setting_validities,
    10861511                                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
     
    10911516                        wp_send_json_error( $response );
    10921517                }
    10931518
    1094                 // Do we have to switch themes?
    1095                 if ( ! $this->is_theme_active() ) {
    1096                         // Temporarily stop previewing the theme to allow switch_themes()
    1097                         // to operate properly.
     1519                $response = array(
     1520                        'setting_validities' => $exported_setting_validities,
     1521                );
     1522
     1523                // Obtain/merge data for changeset.
     1524                $updated_setting_ids = array();
     1525                $original_changeset_data = $this->changeset_data();
     1526                $data = $original_changeset_data;
     1527
     1528                foreach ( $input_changeset_data as $setting_id => $setting_params ) {
     1529                        $setting = $this->get_setting( $setting_id );
     1530                        if ( ! $setting || ! $setting->check_capabilities() ) {
     1531                                continue;
     1532                        }
     1533
     1534                        // Skip updating changeset for invalid setting values.
     1535                        if ( isset( $setting_validities[ $setting_id ] ) && is_wp_error( $setting_validities[ $setting_id ] ) ) {
     1536                                continue;
     1537                        }
     1538
     1539                        $changeset_setting_id = $setting_id;
     1540                        if ( 'theme_mod' === $setting->type ) {
     1541                                $changeset_setting_id = sprintf( '%s::%s', $this->get_stylesheet(), $setting_id );
     1542                        }
     1543
     1544                        if ( null === $setting_params ) {
     1545                                // Remove setting from changeset entirely.
     1546                                unset( $data[ $changeset_setting_id ] );
     1547                        } else {
     1548                                // Merge any additional setting params that have been supplied with the existing params.
     1549                                if ( ! isset( $data[ $changeset_setting_id ] ) ) {
     1550                                        $data[ $changeset_setting_id ] = array();
     1551                                }
     1552                                $data[ $changeset_setting_id ] = array_merge(
     1553                                        $data[ $changeset_setting_id ],
     1554                                        $setting_params,
     1555                                        array( 'type' => $setting->type )
     1556                                );
     1557                        }
     1558                        $updated_setting_ids[] = $setting_id;
     1559                } // End foreach().
     1560
     1561                $filter_context = array(
     1562                        'uuid' => $this->changeset_uuid(),
     1563                        'status' => $changeset_status,
     1564                        'date' => $changeset_date,
     1565                        'post_id' => $this->changeset_post_id(),
     1566                        'previous_data' => $original_changeset_data,
     1567                        'manager' => $this,
     1568                );
     1569
     1570                /**
     1571                 * Filters the settings' data that will be persisted into the changeset.
     1572                 *
     1573                 * Plugins may amend additional data (such as additional meta for settings) into the changeset with this filter.
     1574                 *
     1575                 * @param array $data Updated changeset data, mapping setting IDs to arrays containing a $value item and optionally other metadata.
     1576                 * @param array $context {
     1577                 *     Filter context.
     1578                 *
     1579                 *     @type string               $uuid          Changeset UUID.
     1580                 *     @type string               $title         Requested title for the changeset post.
     1581                 *     @type string               $status        Requested status for the changeset post.
     1582                 *     @type string               $date          Requested date for the changeset post.
     1583                 *     @type int|false            $post_id       Post ID for the changeset, or false if it doesn't exist yet.
     1584                 *     @type array                $previous_data Previous data contained in the changeset.
     1585                 *     @type WP_Customize_Manager $manager       Manager instance.
     1586                 * }
     1587                 */
     1588                $data = apply_filters( 'customize_changeset_save', $data, $filter_context );
     1589
     1590                // Switch theme if publishing changes now.
     1591                if ( 'publish' === $changeset_status && ! $this->is_theme_active() ) {
     1592                        // Temporarily stop previewing the theme to allow switch_themes() to operate properly.
    10981593                        $this->stop_previewing_theme();
    10991594                        switch_theme( $this->get_stylesheet() );
    11001595                        update_option( 'theme_switched_via_customizer', true );
    11011596                        $this->start_previewing_theme();
    11021597                }
    11031598
     1599                // Gather the data for wp_insert_post()/wp_update_post().
     1600                $json_options = 0;
     1601                if ( defined( 'JSON_UNESCAPED_SLASHES' ) ) {
     1602                        $json_options |= JSON_UNESCAPED_SLASHES; // Introduced in PHP 5.4.
     1603                }
     1604                $json_options |= JSON_PRETTY_PRINT; // Also introduced in PHP 5.4, but WP defines constant for back compat. See WP Trac #30139.
     1605                $post_array = array(
     1606                        'post_content' => wp_json_encode( $data, $json_options ),
     1607                );
     1608                if ( $changeset_title ) {
     1609                        $post_array['post_title'] = $changeset_title;
     1610                }
     1611                if ( $changeset_post_id ) {
     1612                        $post_array['ID'] = $changeset_post_id;
     1613                } else {
     1614                        $post_array['post_type'] = 'customize_changeset';
     1615                        $post_array['post_name'] = $this->changeset_uuid();
     1616                        $post_array['post_status'] = 'auto-draft';
     1617                }
     1618                if ( $changeset_status ) {
     1619                        $post_array['post_status'] = $changeset_status;
     1620                }
     1621                if ( $changeset_date ) {
     1622                        $post_array['post_date'] = $changeset_date;
     1623                }
     1624
     1625                // @todo Consider a separate 'revision' param which makes this explicit, versus looking at whether the status is provided.
     1626                $this->store_changeset_revision = ! empty( $changeset_status );
     1627                add_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ), 5, 3 );
     1628
     1629                // @todo Add create revision arg for wp.customize.previewer.save() which defaults to true if status is provided.
     1630                // Update the changeset post. The publish_customize_changeset action will cause the settings in the changeset to be saved via WP_Customize_Setting::save().
     1631                $has_kses = ( false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) );
     1632                if ( $has_kses ) {
     1633                        kses_remove_filters(); // Prevent KSES from corrupting JSON in post_content.
     1634                }
     1635                if ( $changeset_post_id ) {
     1636                        $r = wp_update_post( wp_slash( $post_array ), true );
     1637                } else {
     1638                        $r = wp_insert_post( wp_slash( $post_array ), true );
     1639                        if ( ! is_wp_error( $changeset_post_id ) ) {
     1640                                $changeset_post_id = $r;
     1641                                $this->_changeset_post_id = $changeset_post_id;
     1642                        }
     1643                }
     1644                if ( $has_kses ) {
     1645                        kses_init_filters();
     1646                }
     1647
     1648                remove_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ) );
     1649
     1650                if ( is_wp_error( $r ) ) {
     1651                        $response['snapshot_save_failure'] = $r->get_error_code();
     1652
     1653                        /** This filter is documented in wp-includes/class-wp-customize-manager.php */
     1654                        $response = apply_filters( 'customize_save_response', $response, $this );
     1655                        wp_send_json_error( $response );
     1656                }
     1657
     1658                // Note that if the changeset status was publish, then it will get get set to trash if revisions are not supported.
     1659                if ( 'publish' === $changeset_status ) {
     1660                        $response['changeset_status'] = $changeset_status;
     1661                } else {
     1662                        $response['changeset_status'] = get_post_status( $changeset_post_id );
     1663                }
     1664
     1665                if ( 'publish' === $response['changeset_status'] ) {
     1666                        $response['next_changeset_uuid'] = wp_generate_uuid4();
     1667                }
     1668
     1669                $response['changeset_updated_settings'] = $updated_setting_ids;
     1670
     1671                /**
     1672                 * Filters response data for a successful customize_save Ajax request.
     1673                 *
     1674                 * This filter does not apply if there was a nonce or authentication failure.
     1675                 *
     1676                 * @since 4.2.0
     1677                 *
     1678                 * @param array                $response Additional information passed back to the 'saved'
     1679                 *                                       event on `wp.customize`.
     1680                 * @param WP_Customize_Manager $this     WP_Customize_Manager instance.
     1681                 */
     1682                $response = apply_filters( 'customize_save_response', $response, $this );
     1683                wp_send_json_success( $response );
     1684        }
     1685
     1686        /**
     1687         * Whether a changeset revision should be made.
     1688         *
     1689         * @since 4.7.0
     1690         * @access private
     1691         * @var bool
     1692         */
     1693        protected $store_changeset_revision;
     1694
     1695        /**
     1696         * Filters whether a changeset has changed to create a new revision.
     1697         *
     1698         * Note that this will not be called while a changeset post remains in auto-draft status.
     1699         *
     1700         * @since 4.7.0
     1701         * @access private
     1702         *
     1703         * @param bool    $post_has_changed Whether the post has changed.
     1704         * @param WP_Post $last_revision    The last revision post object.
     1705         * @param WP_Post $post             The post object.
     1706         *
     1707         * @return bool Whether a revision should be made.
     1708         */
     1709        public function _filter_revision_post_has_changed( $post_has_changed, $last_revision, $post ) {
     1710                unset( $last_revision );
     1711                if ( 'customize_changeset' === $post->post_type ) {
     1712                        $post_has_changed = $this->store_changeset_revision;
     1713                }
     1714                return $post_has_changed;
     1715        }
     1716
     1717        /**
     1718         * Publish changeset values.
     1719         *
     1720         * This will the values contained in a changeset, even changesets that do not
     1721         * correspond to current manager instance. This is called by
     1722         * `_wp_customize_publish_changeset()` when a customize_changeset post is
     1723         * transitioned to the `publish` status.
     1724         *
     1725         * Please note that if the settings in the changeset are for a non-activated
     1726         * theme, the theme must first be switched to (via `switch_theme()`) before
     1727         * invoking this method.
     1728         *
     1729         * @since 4.7.0
     1730         * @see _wp_customize_publish_changeset()
     1731         *
     1732         * @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
     1733         */
     1734        public function publish_changeset_values( $changeset_post_id = null ) {
     1735
     1736                if ( empty( $changeset_post_id ) ) {
     1737                        $changeset_post_id = $this->changeset_post_id();
     1738                }
     1739                if ( empty( $changeset_post_id ) ) {
     1740                        return;
     1741                }
     1742                $changeset_post = get_post( $changeset_post_id );
     1743                if ( ! $changeset_post ) {
     1744                        return;
     1745                }
     1746
     1747                $changeset_data = json_decode( $changeset_post->post_content, true );
     1748                if ( ! is_array( $changeset_data ) ) {
     1749                        return;
     1750                }
     1751
     1752                $other_theme_mod_settings = array();
     1753                $active_theme_setting_values = array();
     1754                foreach ( $changeset_data as $raw_setting_id => $setting_params ) {
     1755
     1756                        if ( ! isset( $setting_params['value'] ) ) {
     1757                                continue;
     1758                        }
     1759
     1760                        // @todo There is some duplication of logic here with what is in the save method.
     1761                        if ( isset( $setting_params['type'] ) && 'theme_mod' === $setting_params['type'] ) {
     1762
     1763                                // Ensure that theme mods values are only used if they were saved under the current theme.
     1764                                $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     1765                                if ( preg_match( $namespace_pattern, $raw_setting_id, $matches ) ) {
     1766                                        if ( $this->get_stylesheet() === $matches['stylesheet'] ) {
     1767                                                $active_theme_setting_values[ $matches['setting_id'] ] = $setting_params['value'];
     1768                                        } else {
     1769                                                if ( ! isset( $other_theme_mod_settings[ $matches['stylesheet'] ] ) ) {
     1770                                                        $other_theme_mod_settings[ $matches['stylesheet'] ] = array();
     1771                                                }
     1772                                                $other_theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params;
     1773                                        }
     1774                                }
     1775                        } else {
     1776                                $active_theme_setting_values[ $raw_setting_id ] = $setting_params['value'];
     1777                        }
     1778                }
     1779
     1780                $this->add_dynamic_settings( array_keys( $active_theme_setting_values ) );
     1781
    11041782                /**
    11051783                 * Fires once the theme has switched in the Customizer, but before settings
    11061784                 * have been saved.
    11071785                 *
    11081786                 * @since 3.4.0
    11091787                 *
    1110                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     1788                 * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11111789                 */
    11121790                do_action( 'customize_save', $this );
    11131791
    1114                 foreach ( $this->settings as $setting ) {
    1115                         $setting->save();
     1792                /*
     1793                 * Ensure that all settings will allow themselves to be saved. Note that
     1794                 * this is safe because the setting would have checked the capability
     1795                 * when the setting value was written into the changeset. So this is why
     1796                 * an additional check is not required here.
     1797                 */
     1798                $original_setting_capabilities = array();
     1799                foreach ( $this->settings() as $setting ) {
     1800                        $original_setting_capabilities[ $setting->id ] = $setting->capability;
     1801                        $setting->capability = 'exist';
     1802                }
     1803
     1804                // Ensure post data are all set before iterating to save.
     1805                foreach ( $active_theme_setting_values as $setting_id => $setting_params ) {
     1806                        if ( isset( $setting_params['value'] ) ) {
     1807                                $this->set_post_value( $setting_id, $setting_params['value'] );
     1808                        }
     1809                }
     1810
     1811                foreach ( $active_theme_setting_values as $setting_id => $setting_params ) {
     1812                        $setting = $this->get_setting( $setting_id );
     1813                        if ( $setting ) {
     1814                                $setting->save();
     1815                        }
     1816                }
     1817
     1818                // Update the stashed theme mod settings, removing the active theme's stashed settings, if activated.
     1819                if ( did_action( 'switch_theme' ) ) {
     1820                        $this->update_stashed_theme_mod_settings( $other_theme_mod_settings );
    11161821                }
    11171822
    11181823                /**
     
    11201825                 *
    11211826                 * @since 3.6.0
    11221827                 *
    1123                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     1828                 * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11241829                 */
    11251830                do_action( 'customize_save_after', $this );
    11261831
    1127                 $data = array(
    1128                         'setting_validities' => $exported_setting_validities,
    1129                 );
     1832                foreach ( $this->settings() as $setting ) {
     1833                        if ( isset( $original_setting_capabilities[ $setting->id ] ) ) {
     1834                                $setting->capability = $original_setting_capabilities[ $setting->id ];
     1835                        }
     1836                }
     1837        }
    11301838
    1131                 /**
    1132                  * Filters response data for a successful customize_save Ajax request.
    1133                  *
    1134                  * This filter does not apply if there was a nonce or authentication failure.
    1135                  *
    1136                  * @since 4.2.0
    1137                  *
    1138                  * @param array                $data Additional information passed back to the 'saved'
    1139                  *                                   event on `wp.customize`.
    1140                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
    1141                  */
    1142                 $response = apply_filters( 'customize_save_response', $data, $this );
    1143                 wp_send_json_success( $response );
     1839        /**
     1840         * Update stashed theme mod settings.
     1841         *
     1842         * @since 4.7.0
     1843         * @access private
     1844         *
     1845         * @param array $inactive_theme_mod_settings Mapping of stylesheet to arrays of theme mod settings.
     1846         * @return array|false Returns array of updated stashed theme mods or false if the update failed or there were no changes.
     1847         */
     1848        protected function update_stashed_theme_mod_settings( $inactive_theme_mod_settings ) {
     1849                $stashed_theme_mod_settings = get_option( 'customize_stashed_theme_mods' );
     1850                if ( empty( $stashed_theme_mod_settings ) ) {
     1851                        $stashed_theme_mod_settings = array();
     1852                }
     1853
     1854                // Delete any stashed theme mods for the active theme since since they would have been loaded and saved upon activation.
     1855                unset( $stashed_theme_mod_settings[ $this->get_stylesheet() ] );
     1856
     1857                // Merge inactive theme mods with the stashed theme mod settings.
     1858                foreach ( $inactive_theme_mod_settings as $stylesheet => $theme_mod_settings ) {
     1859                        if ( ! isset( $stashed_theme_mod_settings[ $stylesheet ] ) ) {
     1860                                $stashed_theme_mod_settings[ $stylesheet ] = array();
     1861                        }
     1862
     1863                        $stashed_theme_mod_settings[ $stylesheet ] = array_merge(
     1864                                $stashed_theme_mod_settings[ $stylesheet ],
     1865                                $theme_mod_settings
     1866                        );
     1867                }
     1868
     1869                $autoload = false;
     1870                $result = update_option( 'customize_stashed_theme_mods', $stashed_theme_mod_settings, $autoload );
     1871                if ( ! $result ) {
     1872                        return false;
     1873                }
     1874                return $stashed_theme_mod_settings;
    11441875        }
    11451876
    11461877        /**
     
    16842415        }
    16852416
    16862417        /**
     2418         * Determines whether the admin and the frontend are on different domains.
     2419         *
     2420         * @since 4.7.0
     2421         * @access public
     2422         *
     2423         * @return bool Whether cross-domain.
     2424         */
     2425        public function is_cross_domain() {
     2426                $admin_origin = wp_parse_url( admin_url() );
     2427                $home_origin = wp_parse_url( home_url() );
     2428                $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
     2429                return $cross_domain;
     2430        }
     2431
     2432        /**
     2433         * Get URLs allowed to be previewed.
     2434         *
     2435         * If the front end and the admin are served from the same domain, load the
     2436         * preview over ssl if the Customizer is being loaded over ssl. This avoids
     2437         * insecure content warnings. This is not attempted if the admin and front end
     2438         * are on different domains to avoid the case where the front end doesn't have
     2439         * ssl certs. Domain mapping plugins can allow other urls in these conditions
     2440         * using the customize_allowed_urls filter.
     2441         *
     2442         * @since 4.7.0
     2443         * @access public
     2444         *
     2445         * @returns array Allowed URLs.
     2446         */
     2447        public function get_allowed_urls() {
     2448                $allowed_urls = array( home_url( '/' ) );
     2449
     2450                if ( is_ssl() && ! $this->is_cross_domain() ) {
     2451                        $allowed_urls[] = home_url( '/', 'https' );
     2452                }
     2453
     2454                /**
     2455                 * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
     2456                 *
     2457                 * @since 3.4.0
     2458                 *
     2459                 * @param array $allowed_urls An array of allowed URLs.
     2460                 */
     2461                $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
     2462
     2463                return $allowed_urls;
     2464        }
     2465
     2466        /**
    16872467         * Set URL to link the user to when closing the Customizer.
    16882468         *
    16892469         * URL is validated.
     
    17922572         * @since 4.4.0
    17932573         */
    17942574        public function customize_pane_settings() {
    1795                 /*
    1796                  * If the front end and the admin are served from the same domain, load the
    1797                  * preview over ssl if the Customizer is being loaded over ssl. This avoids
    1798                  * insecure content warnings. This is not attempted if the admin and front end
    1799                  * are on different domains to avoid the case where the front end doesn't have
    1800                  * ssl certs. Domain mapping plugins can allow other urls in these conditions
    1801                  * using the customize_allowed_urls filter.
    1802                  */
    1803 
    1804                 $allowed_urls = array( home_url( '/' ) );
    1805                 $admin_origin = parse_url( admin_url() );
    1806                 $home_origin  = parse_url( home_url() );
    1807                 $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
    1808 
    1809                 if ( is_ssl() && ! $cross_domain ) {
    1810                         $allowed_urls[] = home_url( '/', 'https' );
    1811                 }
    1812 
    1813                 /**
    1814                  * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
    1815                  *
    1816                  * @since 3.4.0
    1817                  *
    1818                  * @param array $allowed_urls An array of allowed URLs.
    1819                  */
    1820                 $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
    18212575
    18222576                $login_url = add_query_arg( array(
    18232577                        'interim-login' => 1,
    18242578                        'customize-login' => 1,
    18252579                ), wp_login_url() );
    18262580
     2581                // Ensure dirty flags are set.
     2582                foreach ( array_keys( $this->unsanitized_post_values() ) as $setting_id ) {
     2583                        $setting = $this->get_setting( $setting_id );
     2584                        if ( $setting ) {
     2585                                $setting->dirty = true;
     2586                        }
     2587                }
     2588
    18272589                // Prepare Customizer settings to pass to JavaScript.
    18282590                $settings = array(
     2591                        'changeset' => array(
     2592                                'uuid' => $this->changeset_uuid,
     2593                                'status' => $this->changeset_post_id() ? get_post_status( $this->changeset_post_id() ) : '',
     2594                        ),
    18292595                        'theme'    => array(
    18302596                                'stylesheet' => $this->get_stylesheet(),
    18312597                                'active'     => $this->is_theme_active(),
     
    18352601                                'parent'        => esc_url_raw( admin_url() ),
    18362602                                'activated'     => esc_url_raw( home_url( '/' ) ),
    18372603                                'ajax'          => esc_url_raw( admin_url( 'admin-ajax.php', 'relative' ) ),
    1838                                 'allowed'       => array_map( 'esc_url_raw', $allowed_urls ),
    1839                                 'isCrossDomain' => $cross_domain,
     2604                                'allowed'       => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     2605                                'isCrossDomain' => $this->is_cross_domain(),
    18402606                                'home'          => esc_url_raw( home_url( '/' ) ),
    18412607                                'login'         => esc_url_raw( $login_url ),
    18422608                        ),
     
    23303096         * @see add_dynamic_settings()
    23313097         */
    23323098        public function register_dynamic_settings() {
    2333                 $this->add_dynamic_settings( array_keys( $this->unsanitized_post_values() ) );
     3099                $setting_ids = array_keys( $this->unsanitized_post_values() );
     3100                $this->add_dynamic_settings( $setting_ids );
    23343101        }
    23353102
    23363103        /**
  • src/wp-includes/js/customize-loader.js

     
    132132                                targetWindow: this.iframe[0].contentWindow
    133133                        });
    134134
     135                        // Expose the changeset UUID on the parent window's URL so that the customized state can survive a refresh.
     136                        if ( history.replaceState ) {
     137                                this.messenger.bind( 'changeset-uuid', function( changesetUuid ) {
     138                                        var urlParser;
     139                                        urlParser = document.createElement( 'a' );
     140                                        urlParser.href = location.href;
     141                                        urlParser.search = urlParser.search.replace( /(\?|&)changeset_uuid=[^&]+(?=$|&)/, '$1' );
     142                                        if ( urlParser.search.length > 1 ) {
     143                                                urlParser.search += '&';
     144                                        }
     145                                        urlParser.search += 'changeset_uuid=' + changesetUuid;
     146                                        history.replaceState( { customize: urlParser.href }, '', urlParser.href );
     147                                } );
     148                        }
     149
    135150                        // Wait for the connection from the iframe before sending any postMessage events.
    136151                        this.messenger.bind( 'ready', function() {
    137152                                Loader.messenger.send( 'back' );
  • src/wp-includes/js/customize-base.js

     
    637637                /**
    638638                 * Initialize Messenger.
    639639                 *
    640                  * @param  {object} params       Parameters to configure the messenger.
    641                  *         {string} .url          The URL to communicate with.
    642                  *         {window} .targetWindow The window instance to communicate with. Default window.parent.
    643                  *         {string} .channel      If provided, will send the channel with each message and only accept messages a matching channel.
    644                  * @param  {object} options       Extend any instance parameter or method with this object.
     640                 * @param  {object} params - Parameters to configure the messenger.
     641                 *         {string} params.url - The URL to communicate with.
     642                 *         {window} params.targetWindow - The window instance to communicate with. Default window.parent.
     643                 *         {string} params.channel - If provided, will send the channel with each message and only accept messages a matching channel.
     644                 * @param  {object} options - Extend any instance parameter or method with this object.
    645645                 */
    646646                initialize: function( params, options ) {
     647                        var defaultTarget;
     648
    647649                        // Target the parent frame by default, but only if a parent frame exists.
    648                         var defaultTarget = window.parent == window ? null : window.parent;
     650                        defaultTarget = window.parent === window ? null : window.parent;
    649651
    650652                        $.extend( this, options || {} );
    651653
    652654                        this.add( 'channel', params.channel );
    653655                        this.add( 'url', params.url || '' );
    654656                        this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) {
    655                                 return to.replace( /([^:]+:\/\/[^\/]+).*/, '$1' );
     657                                var urlParser = document.createElement( 'a' );
     658                                urlParser.href = to;
     659                                return urlParser.protocol + '//' + urlParser.host;
    656660                        });
    657661
    658662                        // first add with no value
     
    807811                return result;
    808812        };
    809813
     814        /**
     815         * Utility function namespace
     816         */
     817        api.utils = {};
     818
     819        /**
     820         * Parse query string.
     821         *
     822         * @param {string} queryString Query string.
     823         * @returns {object} Parsed query string.
     824         */
     825        api.utils.parseQueryString = function parseQueryString( queryString ) {
     826                var queryParams = {};
     827                _.each( queryString.split( '&' ), function( pair ) {
     828                        var parts = pair.split( '=', 2 );
     829                        if ( parts[0] ) {
     830                                queryParams[ decodeURIComponent( parts[0] ) ] = _.isUndefined( parts[1] ) ? null : decodeURIComponent( parts[1] );
     831                        }
     832                } );
     833                return queryParams;
     834        };
     835
    810836        // Expose the API publicly on window.wp.customize
    811837        exports.customize = api;
    812838})( wp, jQuery );
  • src/wp-includes/js/customize-selective-refresh.js

     
    485485                return {
    486486                        wp_customize: 'on',
    487487                        nonce: api.settings.nonce.preview,
    488                         theme: api.settings.theme.stylesheet,
    489                         customized: JSON.stringify( dirtyCustomized )
     488                        customize_theme: api.settings.theme.stylesheet,
     489                        customized: JSON.stringify( dirtyCustomized ),
     490                        customize_changeset_uuid: api.settings.changeset.uuid
    490491                };
    491492        };
    492493
  • src/wp-includes/js/customize-preview.js

     
    3737                 * @param {object} options - Extend any instance parameter or method with this object.
    3838                 */
    3939                initialize: function( params, options ) {
    40                         var self = this;
     40                        var preview = this, urlParser = document.createElement( 'a' );
    4141
    42                         api.Messenger.prototype.initialize.call( this, params, options );
     42                        api.Messenger.prototype.initialize.call( preview, params, options );
    4343
    44                         this.body = $( document.body );
    45                         this.body.on( 'click.preview', 'a', function( event ) {
     44                        urlParser.href = preview.origin();
     45                        preview.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
     46
     47                        preview.body = $( document.body );
     48                        preview.body.on( 'click.preview', 'a', function( event ) {
    4649                                var link, isInternalJumpLink;
    4750                                link = $( this );
     51
     52                                // No-op if the anchor is not a link.
     53                                if ( _.isUndefined( link.attr( 'href' ) ) ) {
     54                                        return;
     55                                }
     56
    4857                                isInternalJumpLink = ( '#' === link.attr( 'href' ).substr( 0, 1 ) );
     58
     59                                // Allow internal jump links to behave normally without preventing default.
     60                                if ( isInternalJumpLink ) {
     61                                        return;
     62                                }
     63
     64                                // Prevent initiating navigating from click and instead rely on sending url message to pane below.
    4965                                event.preventDefault();
    5066
    51                                 if ( isInternalJumpLink && '#' !== link.attr( 'href' ) ) {
    52                                         $( link.attr( 'href' ) ).each( function() {
    53                                                 this.scrollIntoView();
    54                                         } );
     67                                // If the link is not previewable, prevent the browser from navigating to it.
     68                                if ( ! api.isLinkPreviewable( link[0] ) ) {
     69                                        wp.a11y.speak( api.settings.l10n.linkUnpreviewable );
     70                                        return;
    5571                                }
    5672
    5773                                /*
     
    5975                                 * nav menu items can just result on focusing on the corresponding
    6076                                 * control instead of also navigating to the URL linked to.
    6177                                 */
    62                                 if ( event.shiftKey || isInternalJumpLink ) {
     78                                if ( event.shiftKey ) {
    6379                                        return;
    6480                                }
    65                                 self.send( 'scroll', 0 );
    66                                 self.send( 'url', link.prop( 'href' ) );
    67                         });
    6881
    69                         // You cannot submit forms.
    70                         this.body.on( 'submit.preview', 'form', function( event ) {
    71                                 var urlParser;
     82                                // Note: It's not relevant to send scroll because sending url message will cause iframe src change instead of refresh.
     83                                preview.send( 'url', link.prop( 'href' ) );
     84                        } );
     85
     86                        preview.body.on( 'submit.preview', 'form', function( event ) {
     87                                var urlParser = document.createElement( 'a' );
     88                                urlParser.href = this.action;
     89
     90                                // If the link is not previewable, prevent the browser from navigating to it.
     91                                if ( ! api.isLinkPreviewable( urlParser ) ) {
     92                                        wp.a11y.speak( api.settings.l10n.formUnpreviewable );
     93                                        return;
     94                                }
    7295
    7396                                /*
    7497                                 * If the default wasn't prevented already (in which case the form
     
    82105                                 * external site in the preview.
    83106                                 */
    84107                                if ( ! event.isDefaultPrevented() && 'GET' === this.method.toUpperCase() ) {
    85                                         urlParser = document.createElement( 'a' );
    86                                         urlParser.href = this.action;
    87                                         if ( urlParser.search.substr( 1 ).length > 1 ) {
     108                                        if ( urlParser.search.length > 1 ) {
    88109                                                urlParser.search += '&';
    89110                                        }
    90111                                        urlParser.search += $( this ).serialize();
    91112                                        api.preview.send( 'url', urlParser.href );
    92113                                }
    93114
     115                                // Prevent default since navigation should be done via sending url message or via JS submit handler.
    94116                                event.preventDefault();
    95117                        });
    96118
    97                         this.window = $( window );
    98                         this.window.on( 'scroll.preview', debounce( function() {
    99                                 self.send( 'scroll', self.window.scrollTop() );
    100                         }, 200 ));
     119                        preview.window = $( window );
     120                        preview.window.on( 'scroll.preview', debounce( function() {
     121                                preview.send( 'scroll', preview.window.scrollTop() );
     122                        }, 200 ) );
    101123
    102                         this.bind( 'scroll', function( distance ) {
    103                                 self.window.scrollTop( distance );
     124                        preview.bind( 'scroll', function( distance ) {
     125                                preview.window.scrollTop( distance );
    104126                        });
    105127                }
    106128        });
    107129
     130        /**
     131         * Inject the changeset UUID into links in the document.
     132         *
     133         * @returns {void}
     134         */
     135        api.addLinkPreviewing = function addLinkPreviewing() {
     136                var linkSelectors = 'a[href], area';
     137
     138                // Inject links into initial document.
     139                $( document.body ).find( linkSelectors ).each( function() {
     140                        api.prepareLinkPreview( this );
     141                } );
     142
     143                // Inject links for new elements added to the page.
     144                if ( 'undefined' !== typeof MutationObserver ) {
     145                        api.mutationObserver = new MutationObserver( function( mutations ) {
     146                                _.each( mutations, function( mutation ) {
     147                                        $( mutation.target ).find( linkSelectors ).each( function() {
     148                                                api.prepareLinkPreview( this );
     149                                        } );
     150                                } );
     151                        } );
     152                        api.mutationObserver.observe( document.documentElement, {
     153                                childList: true,
     154                                subtree: true
     155                        } );
     156                } else {
     157
     158                        // If mutation observers aren't available, fallback to just-in-time injection.
     159                        $( document.documentElement ).on( 'click focus mouseover', linkSelectors, function() {
     160                                api.prepareLinkPreview( this );
     161                        } );
     162                }
     163        };
     164
     165        /**
     166         * Should the supplied link is previewable.
     167         *
     168         * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     169         * @param {string} element.search Query string.
     170         * @param {string} element.pathname Path.
     171         * @param {string} element.hostname Hostname.
     172         * @param {object} [options]
     173         * @param {object} [options.allowAdminAjax=false] Allow admin-ajax.php requests.
     174         * @returns {boolean} Is appropriate for changeset link.
     175         */
     176        api.isLinkPreviewable = function isLinkPreviewable( element, options ) {
     177                var hasMatchingHost, urlParser, args;
     178
     179                args = _.extend( {}, { allowAdminAjax: false }, options || {} );
     180
     181                if ( 'javascript:' === element.protocol ) { // jshint ignore:line
     182                        return true;
     183                }
     184
     185                // Only web URLs can be previewed.
     186                if ( 'https:' !== element.protocol && 'http:' !== element.protocol ) {
     187                        return false;
     188                }
     189
     190                urlParser = document.createElement( 'a' );
     191                hasMatchingHost = ! _.isUndefined( _.find( api.settings.url.allowed, function( allowedUrl ) {
     192                        urlParser.href = allowedUrl;
     193                        if ( urlParser.host === element.host && urlParser.protocol === element.protocol ) {
     194                                return true;
     195                        }
     196                        return false;
     197                } ) );
     198                if ( ! hasMatchingHost ) {
     199                        return false;
     200                }
     201
     202                // Skip wp login and signup pages.
     203                if ( /\/wp-(login|signup)\.php$/.test( element.pathname ) ) {
     204                        return false;
     205                }
     206
     207                // Allow links to admin ajax as faux frontend URLs.
     208                if ( /\/wp-admin\/admin-ajax\.php$/.test( element.pathname ) ) {
     209                        return args.allowAdminAjax;
     210                }
     211
     212                // Disallow links to admin, includes, and content.
     213                if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) {
     214                        return false;
     215                }
     216
     217                return true;
     218        };
     219
     220        /**
     221         * Inject the customize_changeset_uuid query param into links on the frontend.
     222         *
     223         * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     224         * @param {object} element.search Query string.
     225         * @returns {void}
     226         */
     227        api.prepareLinkPreview = function prepareLinkPreview( element ) {
     228                var queryParams;
     229
     230                // Skip links in admin bar.
     231                if ( $( element ).closest( '#wpadminbar' ).length ) {
     232                        return;
     233                }
     234
     235                // Make sure links in preview use HTTPS if parent frame uses HTTPS.
     236                if ( 'https' === api.preview.scheme.get() && 'http:' === element.protocol && -1 !== api.settings.url.allowedHosts.indexOf( element.host ) ) {
     237                        element.protocol = 'https:';
     238                }
     239
     240                if ( ! api.isLinkPreviewable( element ) ) {
     241                        $( element ).addClass( 'customize-unpreviewable' );
     242                        return;
     243                }
     244                $( element ).removeClass( 'customize-unpreviewable' );
     245
     246                queryParams = api.utils.parseQueryString( element.search.substring( 1 ) );
     247                queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     248                if ( ! api.settings.theme.active ) {
     249                        queryParams.customize_theme = api.settings.theme.stylesheet;
     250                }
     251                if ( api.settings.channel ) {
     252                        queryParams.customize_messenger_channel = api.settings.channel;
     253                }
     254                element.search = $.param( queryParams );
     255
     256                // Prevent links from breaking out of preview iframe.
     257                if ( api.settings.channel ) {
     258                        element.target = '_self';
     259                }
     260        };
     261
     262        /**
     263         * Inject the changeset UUID into Ajax requests.
     264         *
     265         * @access private
     266         * @return {void}
     267         */
     268        api.addRequestPreviewing = function addRequestPreviewing() {
     269                $.ajaxPrefilter( function prefilterAjax( options ) {
     270                        var urlParser, queryParams;
     271                        urlParser = document.createElement( 'a' );
     272                        urlParser.href = options.url;
     273
     274                        // Abort if the request is not for this site.
     275                        if ( ! api.isLinkPreviewable( urlParser, { allowAdminAjax: true } ) ) {
     276                                return;
     277                        }
     278
     279                        queryParams = api.utils.parseQueryString( urlParser.search.substring( 1 ) );
     280                        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     281                        if ( ! api.settings.theme.active ) {
     282                                queryParams.customize_theme = api.settings.theme.stylesheet;
     283                        }
     284                        if ( api.settings.channel ) {
     285                                queryParams.customize_messenger_channel = api.settings.channel;
     286                        }
     287                        urlParser.search = $.param( queryParams );
     288
     289                        options.url = urlParser.href;
     290                } );
     291        };
     292
     293        /**
     294         * Inject changeset UUID into forms, allowing preview to persist through submissions.
     295         *
     296         * @access private
     297         * @returns {void}
     298         */
     299        api.addFormPreviewing = function addFormPreviewing() {
     300
     301                // Inject inputs for forms in initial document.
     302                $( document.body ).find( 'form' ).each( function() {
     303                        api.prepareFormPreview( this );
     304                } );
     305
     306                // Inject inputs for new forms added to the page.
     307                if ( 'undefined' !== typeof MutationObserver ) {
     308                        api.mutationObserver = new MutationObserver( function( mutations ) {
     309                                _.each( mutations, function( mutation ) {
     310                                        $( mutation.target ).find( 'form' ).each( function() {
     311                                                api.prepareFormPreview( this );
     312                                        } );
     313                                } );
     314                        } );
     315                        api.mutationObserver.observe( document.documentElement, {
     316                                childList: true,
     317                                subtree: true
     318                        } );
     319                }
     320        };
     321
     322        /**
     323         * Inject changeset into form inputs.
     324         *
     325         * @param {HTMLFormElement} form Form.
     326         * @returns {void}
     327         */
     328        api.prepareFormPreview = function prepareFormPreview( form ) {
     329                var urlParser, stateParams = {};
     330
     331                if ( ! form.action ) {
     332                        form.action = location.href;
     333                }
     334
     335                urlParser = document.createElement( 'a' );
     336                urlParser.href = form.action;
     337
     338                // Make sure forms in preview use HTTPS if parent frame uses HTTPS.
     339                if ( 'https' === api.preview.scheme.get() && 'http:' === urlParser.protocol && -1 !== api.settings.url.allowedHosts.indexOf( urlParser.host ) ) {
     340                        urlParser.protocol = 'https:';
     341                        form.action = urlParser.href;
     342                }
     343
     344                if ( ! api.isLinkPreviewable( urlParser ) ) {
     345                        $( form ).addClass( 'customize-unpreviewable' );
     346                        return;
     347                }
     348                $( form ).removeClass( 'customize-unpreviewable' );
     349
     350                stateParams.customize_changeset_uuid = api.settings.changeset.uuid;
     351                if ( ! api.settings.theme.active ) {
     352                        stateParams.customize_theme = api.settings.theme.stylesheet;
     353                }
     354                if ( api.settings.channel ) {
     355                        stateParams.customize_messenger_channel = api.settings.channel;
     356                }
     357
     358                _.each( stateParams, function( value, name ) {
     359                        var input = $( form ).find( 'input[name="' + name + '"]' );
     360                        if ( input.length ) {
     361                                input.val( value );
     362                        } else {
     363                                $( form ).prepend( $( '<input>', {
     364                                        type: 'hidden',
     365                                        name: name,
     366                                        value: value
     367                                } ) );
     368                        }
     369                } );
     370
     371                // Prevent links from breaking out of preview iframe.
     372                if ( api.settings.channel ) {
     373                        form.target = '_self';
     374                }
     375        };
     376
     377        /**
     378         * Watch current URL and send keep-alive (heartbeat) messages to the parent.
     379         *
     380         * Keep the customizer pane notified that the preview is still alive
     381         * and that the user hasn't navigated to a non-customized URL.
     382         * These messages also keep the customizer updated on the current URL
     383         * for JS-driven sites that use history.pushState()/history.replaceState().
     384         *
     385         * @returns {void}
     386         */
     387        api.keepAliveCurrentUrl = function keepAliveCurrentUrl() {
     388                var currentUrl, urlParser, queryParams, needsParamRestoration = false;
     389
     390                urlParser = document.createElement( 'a' );
     391                urlParser.href = location.href;
     392                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     393
     394                if ( history.replaceState ) {
     395                        needsParamRestoration = ! queryParams.customize_changeset_uuid || ( ! api.settings.theme.active && ! queryParams.customize_theme ) || ( api.settings.channel && ! queryParams.customize_messenger_channel );
     396                }
     397
     398                // Scrub the URL of any customized state query params.
     399                _.each( api.settings.changeset.stateQueryParams, function( name ) {
     400                        delete queryParams[ name ];
     401                } );
     402                if ( _.isEmpty( queryParams ) ) {
     403                        urlParser.search = '';
     404                } else {
     405                        urlParser.search = '?' + $.param( queryParams );
     406                }
     407                urlParser.hash = '';
     408                currentUrl = urlParser.href;
     409
     410                // Ensure that the customized state params remain in the URL.
     411                if ( needsParamRestoration ) {
     412                        urlParser.href = location.href;
     413                        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     414                        if ( ! api.settings.theme.active ) {
     415                                queryParams.customize_changeset_uuid = api.settings.theme.stylesheet;
     416                        }
     417                        if ( api.settings.theme.channel ) {
     418                                queryParams.customize_messenger_channel = api.settings.channel;
     419                        }
     420                        urlParser.search = $.param( queryParams );
     421                        history.replaceState( {}, '', urlParser.href ); // @todo This is going to clobber any state in any JS app. The state needs to be captured.
     422                }
     423
     424                if ( api.settings.url.self !== currentUrl ) {
     425                        api.settings.url.self = currentUrl;
     426                        api.preview.send( 'ready', {
     427                                currentUrl: api.settings.url.self,
     428                                activePanels: api.settings.activePanels,
     429                                activeSections: api.settings.activeSections,
     430                                activeControls: api.settings.activeControls
     431                        } );
     432                } else {
     433                        api.preview.send( 'keep-alive' );
     434                }
     435        };
     436
    108437        $( function() {
    109438                var bg, setValue;
    110439
     
    118447                        channel: api.settings.channel
    119448                });
    120449
     450                api.addLinkPreviewing();
     451                api.addRequestPreviewing();
     452                api.addFormPreviewing();
     453
    121454                /**
    122455                 * Create/update a setting value.
    123456                 *
     
    171504                        api.preview.send( 'nonce', api.settings.nonce );
    172505
    173506                        api.preview.send( 'documentTitle', document.title );
     507
     508                        // Send scroll in case of loading via non-refresh.
     509                        api.preview.send( 'scroll', $( window ).scrollTop() );
    174510                });
    175511
    176512                api.preview.bind( 'saved', function( response ) {
     513                        var urlParser;
     514
     515                        if ( response.next_changeset_uuid ) {
     516                                api.settings.changeset.uuid = response.next_changeset_uuid;
     517
     518                                // Update UUIDs in links and forms.
     519                                $( document.body ).find( 'a[href], area' ).each( function() {
     520                                        api.prepareLinkPreview( this );
     521                                } );
     522                                $( document.body ).find( 'form' ).each( function() {
     523                                        api.prepareFormPreview( this );
     524                                } );
     525
     526                                // Replace the UUID in the URL.
     527                                urlParser = document.createElement( 'a' );
     528                                urlParser.href = location.href;
     529                                urlParser.search = urlParser.search.replace( /(\?|&)customize_changeset_uuid=[^&]+(&|$)/, '$1' );
     530                                if ( urlParser.search.length > 1 ) {
     531                                        urlParser.search += '&';
     532                                }
     533                                urlParser.search += 'customize_changeset_uuid=' + response.next_changeset_uuid;
     534
     535                                if ( history.replaceState ) {
     536                                        history.replaceState( {}, document.title, urlParser.href ); // @todo This is going to clobber any state in any JS app. The state needs to be captured.
     537                                }
     538                        }
     539
    177540                        api.trigger( 'saved', response );
    178541                } );
    179542
     
    191554                 * Send a message to the parent customize frame with a list of which
    192555                 * containers and controls are active.
    193556                 */
     557
    194558                api.preview.send( 'ready', {
     559                        currentUrl: api.settings.url.self,
    195560                        activePanels: api.settings.activePanels,
    196561                        activeSections: api.settings.activeSections,
    197                         activeControls: api.settings.activeControls,
    198                         settingValidities: api.settings.settingValidities
     562                        activeControls: api.settings.activeControls
    199563                } );
    200564
     565                // Send ready when URL changes via JS.
     566                setInterval( api.keepAliveCurrentUrl, 1000 );
     567
    201568                // Display a loading indicator when preview is reloading, and remove on failure.
    202569                api.preview.bind( 'loading-initiated', function () {
    203570                        $( 'body' ).addClass( 'wp-customizer-unloading' );
  • src/wp-includes/js/customize-preview-nav-menus.js

     
    106106                         * @returns {boolean}
    107107                         */
    108108                        isRelatedSetting: function( setting, newValue, oldValue ) {
    109                                 var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting;
     109                                var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting, _newValue, _oldValue, urlParser;
    110110                                if ( _.isString( setting ) ) {
    111111                                        setting = api( setting );
    112112                                }
     
    123123                                 */
    124124                                isNavMenuItemSetting = /^nav_menu_item\[/.test( setting.id );
    125125                                if ( isNavMenuItemSetting && _.isObject( newValue ) && _.isObject( oldValue ) ) {
    126                                         delete newValue.type_label;
    127                                         delete oldValue.type_label;
    128                                         if ( _.isEqual( oldValue, newValue ) ) {
     126                                        _newValue = _.clone( newValue );
     127                                        _oldValue = _.clone( oldValue );
     128                                        delete _newValue.type_label;
     129                                        delete _oldValue.type_label;
     130
     131                                        // Normalize URL scheme when parent frame is HTTPS to prevent selective refresh upon initial page load.
     132                                        if ( 'https' === api.preview.scheme.get() ) {
     133                                                urlParser = document.createElement( 'a' );
     134                                                urlParser.href = _newValue.url;
     135                                                urlParser.protocol = 'https:';
     136                                                _newValue.url = urlParser.href;
     137                                                urlParser.href = _oldValue.url;
     138                                                urlParser.protocol = 'https:';
     139                                                _oldValue.url = urlParser.href;
     140                                        }
     141
     142                                        if ( _.isEqual( _oldValue, _newValue ) ) {
    129143                                                return false;
    130144                                        }
    131145                                }
  • src/wp-includes/theme.php

     
    20662066 * Includes and instantiates the WP_Customize_Manager class.
    20672067 *
    20682068 * Loads the Customizer at plugins_loaded when accessing the customize.php admin
    2069  * page or when any request includes a wp_customize=on param, either as a GET
    2070  * query var or as POST data. This param is a signal for whether to bootstrap
    2071  * the Customizer when WordPress is loading, especially in the Customizer preview
     2069 * page or when any request includes a wp_customize=on param or a customize_changeset
     2070 * param (a UUID). This param is a signal for whether to bootstrap the Customizer when
     2071 * WordPress is loading, especially in the Customizer preview
    20722072 * or when making Customizer Ajax requests for widgets or menus.
    20732073 *
    20742074 * @since 3.4.0
     
    20762076 * @global WP_Customize_Manager $wp_customize
    20772077 */
    20782078function _wp_customize_include() {
    2079         if ( ! ( ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] )
    2080                 || ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) )
    2081         ) ) {
     2079
     2080        $is_customize_admin_page = ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) );
     2081        $should_include = (
     2082                $is_customize_admin_page
     2083                ||
     2084                ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] )
     2085                ||
     2086                ! empty( $_REQUEST['customize_changeset_uuid'] )
     2087        );
     2088
     2089        if ( ! $should_include ) {
    20822090                return;
    20832091        }
    20842092
    2085         require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
    2086         $GLOBALS['wp_customize'] = new WP_Customize_Manager();
     2093        $theme = null;
     2094        $changeset_uuid = null;
     2095        $messenger_channel = null;
     2096
     2097        if ( $is_customize_admin_page && isset( $_REQUEST['changeset_uuid'] ) ) {
     2098                $changeset_uuid = sanitize_key( wp_unslash( $_REQUEST['changeset_uuid'] ) );
     2099        } elseif ( ! empty( $_REQUEST['customize_changeset_uuid'] ) ) {
     2100                $changeset_uuid = sanitize_key( wp_unslash( $_REQUEST['customize_changeset_uuid'] ) );
     2101        }
     2102
     2103        if ( $is_customize_admin_page && isset( $_REQUEST['theme'] ) ) {
     2104                $theme = wp_unslash( $_REQUEST['theme'] );
     2105        } elseif ( isset( $_REQUEST['customize_theme'] ) ) {
     2106                $theme = wp_unslash( $_REQUEST['customize_theme'] );
     2107        }
     2108        if ( isset( $_REQUEST['customize_messenger_channel'] ) ) {
     2109                $messenger_channel = sanitize_key( wp_unslash( $_REQUEST['customize_messenger_channel'] ) );
     2110        }
     2111
     2112        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2113        $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel' ) );
     2114}
     2115
     2116/**
     2117 * Publish a snapshot's changes.
     2118 *
     2119 * @param int     $changeset_post_id Changeset post ID.
     2120 * @param WP_Post $changeset_post    Changeset post object.
     2121 */
     2122function _wp_customize_publish_changeset( $changeset_post_id, $changeset_post ) {
     2123        global $wp_customize;
     2124        if ( empty( $wp_customize ) ) {
     2125                require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2126                $wp_customize = new WP_Customize_Manager( $changeset_post->post_name );
     2127        }
     2128        if ( ! did_action( 'customize_register' ) ) {
     2129                /** This filter is documented in /wp-includes/class-wp-customize-manager.php */
     2130                do_action( 'customize_register', $wp_customize );
     2131        }
     2132        $wp_customize->publish_changeset_values( $changeset_post_id ) ;
     2133
     2134        /*
     2135         * Trash the changeset post if revisions are not enabled. Unpublished
     2136         * changesets by default get garbage collected due to the auto-draft status.
     2137         * When a changeset post is published, however, it would no longer get cleaned
     2138         * out. Ths is a problem when the changeset posts are never displayed anywhere,
     2139         * since they would just be endlessly piling up. So here we use the revisions
     2140         * feature to indicate whether or not a published changeset should get trashed
     2141         * and thus garbage collected.
     2142         *
     2143         * @todo There could be a better way to indicate that published changeset posts should be garbage-collected.
     2144         */
     2145        if ( ! wp_revisions_enabled( $changeset_post ) ) {
     2146                wp_trash_post( $changeset_post->ID );
     2147        }
     2148}
     2149
     2150/**
     2151 * Filters changeset post data upon insert to ensure post_name is intact.
     2152 *
     2153 * This is needed to prevent the post_name from being dropped when the post is
     2154 * transitioned into pending status by a contributor.
     2155 *
     2156 * @since 4.7.0
     2157 * @see wp_insert_post()
     2158 *
     2159 * @param array $post_data          An array of slashed post data.
     2160 * @param array $supplied_post_data An array of sanitized, but otherwise unmodified post data.
     2161 * @returns array Filtered data.
     2162 */
     2163function _wp_customize_changeset_filter_insert_post_data( $post_data, $supplied_post_data ) {
     2164        if ( isset( $post_data['post_type'] ) && 'customize_changeset' === $post_data['post_type'] ) {
     2165
     2166                // Prevent post_name from being dropped, such as when contributor saves a changeset post as pending.
     2167                if ( empty( $post_data['post_name'] ) && ! empty( $supplied_post_data['post_name'] ) ) {
     2168                        $post_data['post_name'] = $supplied_post_data['post_name'];
     2169                }
     2170
     2171                // @todo Let the post_name be immutable by setting $post_data['post_name'] to get_post( $post_data['ID'] )->post_name?
     2172                // @todo Otherwise, supply a UUID via wp_generate_uuid4() if it is empty?
     2173        }
     2174        return $post_data;
    20872175}
    20882176
    20892177/**
  • src/wp-includes/functions.php

     
    55295529
    55305530        return false;
    55315531}
     5532
     5533/**
     5534 * Generate a random UUID (version 4).
     5535 *
     5536 * @since 4.7.0
     5537 *
     5538 * @return string UUID.
     5539 */
     5540function wp_generate_uuid4() {
     5541        return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
     5542                mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ),
     5543                mt_rand( 0, 0xffff ),
     5544                mt_rand( 0, 0x0fff ) | 0x4000,
     5545                mt_rand( 0, 0x3fff ) | 0x8000,
     5546                mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff )
     5547        );
     5548}
  • src/wp-includes/class-wp-customize-widgets.php

     
    9393        public function __construct( $manager ) {
    9494                $this->manager = $manager;
    9595
    96                 // Skip useless hooks when the user can't manage widgets anyway.
     96                // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L420-L449
     97                add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
     98                add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 );
     99                add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 );
     100
     101                // Skip remaining hooks when the user can't manage widgets anyway.
    97102                if ( ! current_user_can( 'edit_theme_options' ) ) {
    98103                        return;
    99104                }
    100105
    101                 add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
    102                 add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 );
    103106                add_action( 'wp_loaded',                               array( $this, 'override_sidebars_widgets_for_theme_switch' ) );
    104107                add_action( 'customize_controls_init',                 array( $this, 'customize_controls_init' ) );
    105                 add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 );
    106108                add_action( 'customize_controls_enqueue_scripts',      array( $this, 'enqueue_scripts' ) );
    107109                add_action( 'customize_controls_print_styles',         array( $this, 'print_styles' ) );
    108110                add_action( 'customize_controls_print_scripts',        array( $this, 'print_scripts' ) );
     
    276278
    277279                $this->old_sidebars_widgets = wp_get_sidebars_widgets();
    278280                add_filter( 'customize_value_old_sidebars_widgets_data', array( $this, 'filter_customize_value_old_sidebars_widgets_data' ) );
     281                $this->manager->set_post_value( 'old_sidebars_widgets_data', $this->old_sidebars_widgets ); // Override any value cached in changeset.
    279282
    280283                // retrieve_widgets() looks at the global $sidebars_widgets
    281284                $sidebars_widgets = $this->old_sidebars_widgets;
  • src/wp-admin/export.php

     
    254254        </li>
    255255</ul>
    256256
    257 <?php foreach ( get_post_types( array( '_builtin' => false, 'can_export' => true ), 'objects' ) as $post_type ) : ?>
     257<?php foreach ( get_post_types( array( 'can_export' => true ), 'objects' ) as $post_type ) : if ( $post_type->_builtin && 'customize_changeset' !== $post_type->name ) { continue; } ?>
    258258<p><label><input type="radio" name="content" value="<?php echo esc_attr( $post_type->name ); ?>" /> <?php echo esc_html( $post_type->label ); ?></label></p>
    259259<?php endforeach; ?>
    260260
  • src/wp-admin/js/customize-controls.js

     
    2222         */
    2323        api.Setting = api.Value.extend({
    2424                initialize: function( id, value, options ) {
    25                         api.Value.prototype.initialize.call( this, value, options );
     25                        var setting = this;
     26                        api.Value.prototype.initialize.call( setting, value, options );
    2627
    27                         this.id = id;
    28                         this.transport = this.transport || 'refresh';
    29                         this._dirty = options.dirty || false;
    30                         this.notifications = new api.Values({ defaultConstructor: api.Notification });
     28                        setting.id = id;
     29                        setting.transport = setting.transport || 'refresh';
     30                        setting._dirty = options.dirty || false;
     31                        setting.notifications = new api.Values({ defaultConstructor: api.Notification });
    3132
    32                         // Whenever the setting's value changes, refresh the preview.
    33                         this.bind( this.preview );
     33                        setting.bind( setting.handleChange );
     34                },
     35
     36                /**
     37                 * Handle setting change.
     38                 *
     39                 * @since 4.7.0
     40                 *
     41                 * @param {mixed} value Value.
     42                 * @returns {void}
     43                 */
     44                handleChange: function( value ) {
     45                        var setting = this, promise, changes = {};
     46                        changes[ setting.id ] = {
     47                                value: value
     48                        };
     49                        promise = api.requestChangesetUpdate( changes );
     50                        setting.previewer.addPendingChangesetUpdateRequest( promise );
     51                        setting.preview();
    3452                },
    3553
    3654                /**
     
    6583        });
    6684
    6785        /**
    68          * Utility function namespace
     86         * Timeout ID for the current debounced call to requestChangesetUpdate.
     87         *
     88         * @type {int|null}
     89         * @private
     90         */
     91        api._updateChangesetTimeoutId = null;
     92
     93        /**
     94         * Staging for changeset changes added by calls to requestChangesetUpdate.
     95         *
     96         * @since 4.7.0
     97         * @type {object|null}
     98         * @private
     99         */
     100        api._pendingUpdateChanges = null;
     101
     102        /**
     103         * Current jqXHR made from a call to requestChangesetUpdate.
     104         *
     105         * @type {jQuery.ajax|null}
     106         * @private
     107         */
     108        api._currentUpdateRequest = null;
     109
     110        /**
     111         * Deferred returned by debounced calls to requestChangesetUpdate.
     112         *
     113         * @type {jQuery.Deferred|null}
     114         * @private
    69115         */
    70         api.utils = {};
     116        api._pendingChangesetUpdateRequestDeferred = null;
     117
     118        /**
     119         * Request updates to the changeset.
     120         *
     121         * This is implemented in the same way as wp.customize.selectiveRefresh.requestPartial()
     122         * in that it will combine multiple calls (debounce) into a single request.
     123         *
     124         * @param {object} changes Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
     125         * @returns {jQuery.Promise}
     126         */
     127        api.requestChangesetUpdate = function requestChangesetUpdate( changes ) {
     128                var currentDeferred, nextDeferred;
     129
     130                // Make sure the first request to update the changes will include any initially-dirty settings.
     131                if ( null === api._pendingUpdateChanges ) {
     132                        api._pendingUpdateChanges = {};
     133                        api.each( function( setting ) {
     134                                if ( setting._dirty ) {
     135                                        api._pendingUpdateChanges[ setting.id ] = { value: setting.get() };
     136                                }
     137                        } );
     138                }
     139
     140                if ( api._pendingChangesetUpdateRequestDeferred ) {
     141                        currentDeferred = api._pendingChangesetUpdateRequestDeferred;
     142                } else {
     143                        currentDeferred = new $.Deferred();
     144                        api._pendingChangesetUpdateRequestDeferred = currentDeferred;
     145
     146                        // Make sure that publishing a changeset waits for all changeset update requests to complete.
     147                        api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
     148                        currentDeferred.always( function() {
     149                                api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
     150                        } );
     151                }
     152
     153                // Store the changes in a object containing all of the pending settings for the next request.
     154                _.each( changes, function( settingParams, settingId ) {
     155                        if ( null === settingParams ) {
     156
     157                                // When null is passed as the change, the result will be the removal of the setting from the changeset. A revert.
     158                                api._pendingUpdateChanges[ settingId ] = null;
     159                        } else if ( _.isObject( settingParams ) ) {
     160                                if ( _.isUndefined( api._pendingUpdateChanges[ settingId ] ) ) {
     161                                        api._pendingUpdateChanges[ settingId ] = {};
     162                                }
     163                                _.extend( api._pendingUpdateChanges[ settingId ], settingParams );
     164                        } else {
     165                                throw new Error( 'Unexpected change for ' + settingId );
     166                        }
     167                } );
     168
     169                /*
     170                 * If there is a changeset update request currently being made, wait until it completes.
     171                 * No need to pass along the changes because they're already on _pendingUpdateChanges.
     172                 * Return a new promise that will resolve/reject with the next requests's response.
     173                 */
     174                if ( api._currentUpdateRequest ) {
     175                        nextDeferred = $.Deferred();
     176                        api._currentUpdateRequest.always( function oncePendingUpdateRequestCompletes() {
     177                                api.requestChangesetUpdate( {} ).then(
     178                                        function doneNextRequest( data ) {
     179                                                nextDeferred.resolve( data );
     180                                        },
     181                                        function failNextRequest( data ) {
     182                                                nextDeferred.reject( data );
     183                                        }
     184                                );
     185                        } );
     186                        return nextDeferred.promise();
     187                }
     188
     189                // Reset the timeout for the debounced call.
     190                if ( api._updateChangesetTimeoutId ) {
     191                        clearTimeout( api._updateChangesetTimeoutId );
     192                }
     193
     194                api._updateChangesetTimeoutId = setTimeout( function requestAjaxChangesetUpdate() {
     195                        var pendingChanges, requestDeferred, request;
     196
     197                        pendingChanges = _.clone( api._pendingUpdateChanges );
     198                        api._pendingUpdateChanges = {};
     199
     200                        // Allow plugins to attach additional params to the settings.
     201                        api.trigger( 'changeset-save', pendingChanges );
     202
     203                        requestDeferred = api._pendingChangesetUpdateRequestDeferred;
     204                        api._pendingChangesetUpdateRequestDeferred = null;
     205
     206                        request = wp.ajax.post( 'customize_save', {
     207                                wp_customize: 'on',
     208                                customize_theme: api.settings.theme.stylesheet,
     209                                nonce: api.settings.nonce.save,
     210                                customize_changeset_uuid: api.settings.changeset.uuid,
     211                                customize_changeset_data: JSON.stringify( pendingChanges )
     212                        } );
     213                        api._currentUpdateRequest = request;
     214
     215                        request.done( function requestChangesetUpdateDone( data ) {
     216                                api.state( 'changesetStatus' ).set( data.changeset_status );
     217                                requestDeferred.resolve( data );
     218
     219                                api.trigger( 'changeset-saved', data );
     220                                api.previewer.send( 'changeset-saved', data );
     221                        } );
     222                        request.fail( function requestChangesetUpdateFail( data ) {
     223
     224                                api.trigger( 'changeset-error', data );
     225
     226                                // Make the changes pending again, merging the changes sent in the request with any new pending changes.
     227                                _.each( pendingChanges, function( settingParams, settingId ) {
     228                                        if ( _.isObject( api._pendingUpdateChanges[ settingId ] ) ) {
     229                                                api._pendingUpdateChanges[ settingId ] = _.extend(
     230                                                        settingParams,
     231                                                        api._pendingUpdateChanges[ settingId ]
     232                                                );
     233                                        } else if ( null !== api._pendingUpdateChanges[ settingId ] ) {
     234                                                api._pendingUpdateChanges[ settingId ] = settingParams;
     235                                        }
     236                                } );
     237
     238                                requestDeferred.reject( data );
     239                        } );
     240                        request.always( function( data ) {
     241
     242                                // Allow another request to be made.
     243                                api._currentUpdateRequest = null;
     244
     245                                if ( data.setting_validities ) {
     246                                        api._handleSettingValidities( {
     247                                                settingValidities: data.setting_validities
     248                                        } );
     249                                }
     250                        } );
     251                }, api.previewer.refreshBuffer ); // @todo Let this come from api.settings.updateChangesetBuffer.
     252
     253                return currentDeferred.promise();
     254        };
    71255
    72256        /**
    73257         * Watch all changes to Value properties, and bubble changes to parent Values instance
     
    12161400                },
    12171401
    12181402                /**
     1403                 * Get the url to preview a given theme switch.
     1404                 *
     1405                 * @param {string} themeId Theme ID.
     1406                 * @returns {string} Customize URL.
     1407                 */
     1408                getThemePreviewUrl: function( themeId ) {
     1409                        var urlParser = document.createElement( 'a' );
     1410                        urlParser.href = location.href;
     1411                        urlParser.search = urlParser.search.replace( /(\?|&)theme=[^&]+(&|$)/, '$1' );
     1412                        if ( urlParser.search.length > 1 ) {
     1413                                urlParser.search += '&';
     1414                        }
     1415                        urlParser.search += 'theme=' + themeId;
     1416                        return urlParser.href;
     1417                },
     1418
     1419                /**
    12191420                 * Render & show the theme details for a given theme model.
    12201421                 *
    12211422                 * @since 4.2.0
     
    12321433                        $( 'body' ).addClass( 'modal-open' );
    12331434                        section.containFocus( section.overlay );
    12341435                        section.updateLimits();
     1436                        section.overlay.find( '.inactive-theme > a' ).prop( 'href', section.getThemePreviewUrl( theme.id ) );
    12351437                        callback();
    12361438                },
    12371439
     
    22272429                        wp.ajax.post( 'custom-background-add', {
    22282430                                nonce: _wpCustomizeBackground.nonces.add,
    22292431                                wp_customize: 'on',
    2230                                 theme: api.settings.theme.stylesheet,
     2432                                customize_theme: api.settings.theme.stylesheet,
    22312433                                attachment_id: this.params.attachment.id
    22322434                        } );
    22332435                }
     
    28693071
    28703072                        // Bind details view trigger.
    28713073                        control.container.on( 'click keydown touchend', '.theme', function( event ) {
     3074                                var previewUrl;
     3075
    28723076                                if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
    28733077                                        return;
    28743078                                }
     
    28833087                                        return;
    28843088                                }
    28853089
    2886                                 var previewUrl = $( this ).data( 'previewUrl' );
     3090                                previewUrl = api.ThemesSection.prototype.getThemePreviewUrl( control.params.theme.id );
    28873091
    28883092                                $( '.wp-full-overlay' ).addClass( 'customize-loading' );
    28893093
     3094                                // Remove AYS if the changes can persist via the changeset UUID in the URL.
     3095                                if ( history.replaceState && 0 === api.state( 'processing' ).get() ) {
     3096                                        $( window ).off( 'beforeunload.customize-confirm' );
     3097                                }
     3098
    28903099                                window.parent.location = previewUrl;
    28913100                        });
    28923101
     
    29543163                 * Initialize the PreviewFrame.
    29553164                 *
    29563165                 * @param {object} params.container
    2957                  * @param {object} params.signature
    29583166                 * @param {object} params.previewUrl
    29593167                 * @param {object} params.query
    29603168                 * @param {object} options
     
    29693177                        deferred.promise( this );
    29703178
    29713179                        this.container = params.container;
    2972                         this.signature = params.signature;
    29733180
    29743181                        $.extend( params, { channel: api.PreviewFrame.uuid() });
    29753182
     
    29893196                 *                          the request.
    29903197                 */
    29913198                run: function( deferred ) {
    2992                         var self  = this,
     3199                        var previewFrame = this,
    29933200                                loaded = false,
    2994                                 ready  = false;
     3201                                ready = false,
     3202                                readyData = null,
     3203                                urlParser,
     3204                                params;
    29953205
    2996                         if ( this._ready ) {
    2997                                 this.unbind( 'ready', this._ready );
     3206                        if ( previewFrame._ready ) {
     3207                                previewFrame.unbind( 'ready', previewFrame._ready );
    29983208                        }
    29993209
    3000                         this._ready = function() {
     3210                        previewFrame._ready = function( data ) {
    30013211                                ready = true;
    3002 
    3003                                 if ( loaded ) {
    3004                                         deferred.resolveWith( self );
    3005                                 }
    3006                         };
    3007 
    3008                         this.bind( 'ready', this._ready );
    3009 
    3010                         this.bind( 'ready', function ( data ) {
    3011 
    3012                                 this.container.addClass( 'iframe-ready' );
    3013 
     3212                                readyData = data;
     3213                                previewFrame.container.addClass( 'iframe-ready' );
    30143214                                if ( ! data ) {
    30153215                                        return;
    30163216                                }
    30173217
    3018                                 /*
    3019                                  * Walk over all panels, sections, and controls and set their
    3020                                  * respective active states to true if the preview explicitly
    3021                                  * indicates as such.
    3022                                  */
    3023                                 var constructs = {
    3024                                         panel: data.activePanels,
    3025                                         section: data.activeSections,
    3026                                         control: data.activeControls
    3027                                 };
    3028                                 _( constructs ).each( function ( activeConstructs, type ) {
    3029                                         api[ type ].each( function ( construct, id ) {
    3030                                                 var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
    3031 
    3032                                                 /*
    3033                                                  * If the construct was created statically in PHP (not dynamically in JS)
    3034                                                  * then consider a missing (undefined) value in the activeConstructs to
    3035                                                  * mean it should be deactivated (since it is gone). But if it is
    3036                                                  * dynamically created then only toggle activation if the value is defined,
    3037                                                  * as this means that the construct was also then correspondingly
    3038                                                  * created statically in PHP and the active callback is available.
    3039                                                  * Otherwise, dynamically-created constructs should normally have
    3040                                                  * their active states toggled in JS rather than from PHP.
    3041                                                  */
    3042                                                 if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
    3043                                                         if ( activeConstructs[ id ] ) {
    3044                                                                 construct.activate();
    3045                                                         } else {
    3046                                                                 construct.deactivate();
    3047                                                         }
    3048                                                 }
    3049                                         } );
    3050                                 } );
    3051 
    3052                                 if ( data.settingValidities ) {
    3053                                         api._handleSettingValidities( {
    3054                                                 settingValidities: data.settingValidities,
    3055                                                 focusInvalidControl: false
    3056                                         } );
     3218                                if ( loaded ) {
     3219                                        deferred.resolveWith( previewFrame, [ data ] );
    30573220                                }
    3058                         } );
     3221                        };
    30593222
    3060                         this.request = $.ajax( this.previewUrl(), {
    3061                                 type: 'POST',
    3062                                 data: this.query,
    3063                                 xhrFields: {
    3064                                         withCredentials: true
    3065                                 }
    3066                         } );
     3223                        previewFrame.bind( 'ready', previewFrame._ready );
    30673224
    3068                         this.request.fail( function() {
    3069                                 deferred.rejectWith( self, [ 'request failure' ] );
    3070                         });
     3225                        urlParser = document.createElement( 'a' );
     3226                        urlParser.href = this.previewUrl();
     3227                        if ( urlParser.search.length > 1 ) {
     3228                                urlParser.search += '&';
     3229                        }
     3230
     3231                        // @todo Logic duplicated.
     3232                        params = _.clone( this.query );
     3233                        delete params.customized;
     3234                        delete params.wp_customize;
     3235                        delete params.nonce;
     3236
     3237                        urlParser.search += $.param( params );
     3238                        previewFrame.iframe = $( '<iframe />', {
     3239                                title: api.l10n.previewIframeTitle,
     3240                                src: urlParser.href
     3241                        } );
     3242                        previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
     3243                        previewFrame.iframe.appendTo( previewFrame.container );
     3244                        previewFrame.targetWindow( previewFrame.iframe[0].contentWindow );
    30713245
    3072                         this.request.done( function( response ) {
    3073                                 var location = self.request.getResponseHeader('Location'),
    3074                                         signature = self.signature,
    3075                                         index;
    3076 
    3077                                 // Check if the location response header differs from the current URL.
    3078                                 // If so, the request was redirected; try loading the requested page.
    3079                                 if ( location && location !== self.previewUrl() ) {
    3080                                         deferred.rejectWith( self, [ 'redirect', location ] );
    3081                                         return;
    3082                                 }
     3246                        previewFrame.bind( 'iframe-loading-error', function( error ) {
     3247                                previewFrame.iframe.remove();
    30833248
    30843249                                // Check if the user is not logged in.
    3085                                 if ( '0' === response ) {
    3086                                         self.login( deferred );
     3250                                if ( 0 === error ) {
     3251                                        previewFrame.login( deferred );
    30873252                                        return;
    30883253                                }
    30893254
    30903255                                // Check for cheaters.
    3091                                 if ( '-1' === response ) {
    3092                                         deferred.rejectWith( self, [ 'cheatin' ] );
    3093                                         return;
    3094                                 }
    3095 
    3096                                 // Check for a signature in the request.
    3097                                 index = response.lastIndexOf( signature );
    3098                                 if ( -1 === index || index < response.lastIndexOf('</html>') ) {
    3099                                         deferred.rejectWith( self, [ 'unsigned' ] );
     3256                                if ( -1 === error ) {
     3257                                        deferred.rejectWith( previewFrame, [ 'cheatin' ] );
    31003258                                        return;
    31013259                                }
    31023260
    3103                                 // Strip the signature from the request.
    3104                                 response = response.slice( 0, index ) + response.slice( index + signature.length );
    3105 
    3106                                 // Create the iframe and inject the html content.
    3107                                 self.iframe = $( '<iframe />', { 'title': api.l10n.previewIframeTitle } ).appendTo( self.container );
    3108                                 self.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
    3109 
    3110                                 // Bind load event after the iframe has been added to the page;
    3111                                 // otherwise it will fire when injected into the DOM.
    3112                                 self.iframe.one( 'load', function() {
    3113                                         loaded = true;
    3114 
    3115                                         if ( ready ) {
    3116                                                 deferred.resolveWith( self );
    3117                                         } else {
    3118                                                 setTimeout( function() {
    3119                                                         deferred.rejectWith( self, [ 'ready timeout' ] );
    3120                                                 }, self.sensitivity );
    3121                                         }
    3122                                 });
     3261                                deferred.rejectWith( previewFrame, [ 'request failure' ] );
     3262                        } );
    31233263
    3124                                 self.targetWindow( self.iframe[0].contentWindow );
     3264                        previewFrame.iframe.one( 'load', function() {
     3265                                loaded = true;
    31253266
    3126                                 self.targetWindow().document.open();
    3127                                 self.targetWindow().document.write( response );
    3128                                 self.targetWindow().document.close();
     3267                                if ( ready ) {
     3268                                        deferred.resolveWith( previewFrame, [ readyData ] );
     3269                                } else {
     3270                                        setTimeout( function() {
     3271                                                deferred.rejectWith( previewFrame, [ 'ready timeout' ] );
     3272                                        }, previewFrame.sensitivity );
     3273                                }
    31293274                        });
    31303275                },
    31313276
     
    31643309
    31653310                destroy: function() {
    31663311                        api.Messenger.prototype.destroy.call( this );
    3167                         this.request.abort();
     3312                        // @todo this.request.abort();
    31683313
    31693314                        if ( this.iframe )
    31703315                                this.iframe.remove();
     
    32173362                 *                                    frame to be placed.
    32183363                 * @param {string} params.form
    32193364                 * @param {string} params.previewUrl  The URL to preview.
    3220                  * @param {string} params.signature
    32213365                 * @param {object} options
    32223366                 */
    32233367                initialize: function( params, options ) {
    3224                         var self = this,
    3225                                 rscheme = /^https?/;
     3368                        var previewer = this,
     3369                                urlParser = document.createElement( 'a' );
    32263370
    3227                         $.extend( this, options || {} );
    3228                         this.deferred = {
     3371                        $.extend( previewer, options || {} );
     3372                        previewer.deferred = {
    32293373                                active: $.Deferred()
    32303374                        };
     3375                        previewer.pendingChangesetUpdateRequests = [];
    32313376
    3232                         /*
    3233                          * Wrap this.refresh to prevent it from hammering the servers:
    3234                          *
    3235                          * If refresh is called once and no other refresh requests are
    3236                          * loading, trigger the request immediately.
    3237                          *
    3238                          * If refresh is called while another refresh request is loading,
    3239                          * debounce the refresh requests:
    3240                          * 1. Stop the loading request (as it is instantly outdated).
    3241                          * 2. Trigger the new request once refresh hasn't been called for
    3242                          *    self.refreshBuffer milliseconds.
    3243                          */
    3244                         this.refresh = (function( self ) {
    3245                                 var refresh  = self.refresh,
    3246                                         callback = function() {
    3247                                                 timeout = null;
    3248                                                 refresh.call( self );
    3249                                         },
    3250                                         timeout;
    3251 
    3252                                 return function() {
    3253                                         if ( typeof timeout !== 'number' ) {
    3254                                                 if ( self.loading ) {
    3255                                                         self.abort();
    3256                                                 } else {
    3257                                                         return callback();
    3258                                                 }
    3259                                         }
    3260 
    3261                                         clearTimeout( timeout );
    3262                                         timeout = setTimeout( callback, self.refreshBuffer );
    3263                                 };
    3264                         })( this );
     3377                        // Debounce to prevent hammering server and then wait for any pending update requests.
     3378                        previewer.refresh = _.debounce(
     3379                                ( function( originalRefresh ) {
     3380                                        return function() {
     3381                                                var refreshOnceChangesetRequestsComplete = function() {
     3382                                                        $.when.apply( $, previewer.pendingChangesetUpdateRequests ).then( function() {
     3383                                                                // Check for any newly-pending requests and then wait for them as well.
     3384                                                                var pendingCount = _.filter( previewer.pendingChangesetUpdateRequests, function( request ) {
     3385                                                                        return 'pending' === request.state();
     3386                                                                } ).length;
     3387                                                                if ( pendingCount > 0 ) {
     3388                                                                        refreshOnceChangesetRequestsComplete();
     3389                                                                } else {
     3390                                                                        originalRefresh.call( previewer );
     3391                                                                }
     3392                                                        } );
     3393                                                };
     3394                                                refreshOnceChangesetRequestsComplete();
     3395                                        };
     3396                                }( previewer.refresh ) ),
     3397                                previewer.refreshBuffer
     3398                        );
    32653399
    3266                         this.container   = api.ensure( params.container );
    3267                         this.allowedUrls = params.allowedUrls;
    3268                         this.signature   = params.signature;
     3400                        previewer.container   = api.ensure( params.container );
     3401                        previewer.allowedUrls = params.allowedUrls;
    32693402
    32703403                        params.url = window.location.href;
    32713404
    3272                         api.Messenger.prototype.initialize.call( this, params );
     3405                        api.Messenger.prototype.initialize.call( previewer, params );
    32733406
    3274                         this.add( 'scheme', this.origin() ).link( this.origin ).setter( function( to ) {
    3275                                 var match = to.match( rscheme );
    3276                                 return match ? match[0] : '';
    3277                         });
     3407                        urlParser.href = previewer.origin();
     3408                        previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
    32783409
    32793410                        // Limit the URL to internal, front-end links.
    32803411                        //
     
    32843415                        // are on different domains to avoid the case where the front end doesn't have
    32853416                        // ssl certs.
    32863417
    3287                         this.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
    3288                                 var result, urlParser;
     3418                        previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
     3419                                var result, urlParser, newPreviewUrl, schemeMatchingPreviewUrl, queryParams;
    32893420                                urlParser = document.createElement( 'a' );
    32903421                                urlParser.href = to;
    32913422
     
    32943425                                        return null;
    32953426                                }
    32963427
     3428                                // Remove state query params.
     3429                                if ( urlParser.search.length > 1 ) {
     3430                                        queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     3431                                        delete queryParams.customize_changeset_uuid;
     3432                                        delete queryParams.customize_theme;
     3433                                        delete queryParams.customize_messenger_channel;
     3434                                        if ( _.isEmpty( queryParams ) ) {
     3435                                                urlParser.search = '';
     3436                                        } else {
     3437                                                urlParser.search = $.param( queryParams );
     3438                                        }
     3439                                }
     3440
     3441                                newPreviewUrl = urlParser.href;
     3442                                urlParser.protocol = previewer.scheme.get() + ':';
     3443                                schemeMatchingPreviewUrl = urlParser.href;
     3444
    32973445                                // Attempt to match the URL to the control frame's scheme
    32983446                                // and check if it's allowed. If not, try the original URL.
    3299                                 $.each([ to.replace( rscheme, self.scheme() ), to ], function( i, url ) {
    3300                                         $.each( self.allowedUrls, function( i, allowed ) {
     3447                                $.each( [ schemeMatchingPreviewUrl, newPreviewUrl ], function( i, url ) {
     3448                                        $.each( previewer.allowedUrls, function( i, allowed ) {
    33013449                                                var path;
    33023450
    33033451                                                allowed = allowed.replace( /\/+$/, '' );
     
    33083456                                                        return false;
    33093457                                                }
    33103458                                        });
    3311                                         if ( result )
     3459                                        if ( result ) {
    33123460                                                return false;
     3461                                        }
    33133462                                });
    33143463
    33153464                                // If we found a matching result, return it. If not, bail.
    33163465                                return result ? result : null;
    33173466                        });
    33183467
    3319                         // Refresh the preview when the URL is changed (but not yet).
    3320                         this.previewUrl.bind( this.refresh );
     3468                        // Change preview iframe URL when the previewUrl changes.
     3469                        previewer.setIframeSrc = _.bind( previewer.setIframeSrc, previewer );
     3470                        previewer.setIframeSrcToPreviewUrl = _.bind( previewer.setIframeSrcToPreviewUrl, previewer );
     3471                        previewer.bind( 'ready', previewer.ready );
     3472
     3473                        // Start listening for keep-alive messages when iframe first loads.
     3474                        previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) );
    33213475
    3322                         this.scroll = 0;
    3323                         this.bind( 'scroll', function( distance ) {
    3324                                 this.scroll = distance;
     3476                        previewer.bind( 'synced', function() {
     3477                                previewer.send( 'active' );
     3478                        } );
     3479
     3480                        previewer.scroll = 0;
     3481                        previewer.bind( 'scroll', function( distance ) {
     3482                                previewer.scroll = distance;
    33253483                        });
    33263484
    33273485                        // Update the URL when the iframe sends a URL message.
    3328                         this.bind( 'url', this.previewUrl );
     3486                        previewer.bind( 'url', previewer.previewUrl );
    33293487
    33303488                        // Update the document title when the preview changes.
    3331                         this.bind( 'documentTitle', function ( title ) {
     3489                        previewer.bind( 'documentTitle', function ( title ) {
    33323490                                api.setDocumentTitle( title );
    33333491                        } );
    33343492                },
    33353493
    33363494                /**
     3495                 * Handle changes to previewUrl to updating the iframe url once any pending processing has completed.
     3496                 *
     3497                 * This will call setIframeSrc once the the customizer processing state has turned to 0,
     3498                 * or if it is 0 upon invocation. This ensures that the changeset will have been written
     3499                 * before the iframe attempts to load so that the changes will be available when the
     3500                 * request is made. Also, if there is already such a deferred setting of the iframe src
     3501                 * queued up, the function will short-circuit and let the existing pending call handle
     3502                 * the updating of the iframe src.
     3503                 *
     3504                 * @since 4.7.0
     3505                 * @access private
     3506                 *
     3507                 * @returns {void}
     3508                 */
     3509                setIframeSrcToPreviewUrl: ( function() {
     3510                        var onceProcessed;
     3511
     3512                        return function setIframeSrcToPreviewUrl() {
     3513                                var previewer = this, processing;
     3514
     3515                                // Short-circuit if we're still waiting for processing to complete.
     3516                                if ( onceProcessed ) {
     3517                                        return;
     3518                                }
     3519
     3520                                processing = api.state( 'processing' );
     3521                                if ( 0 === processing.get() ) {
     3522                                        previewer.setIframeSrc( previewer.previewUrl.get() );
     3523                                } else {
     3524                                        onceProcessed = function( processingCount ) {
     3525                                                if ( 0 === processingCount ) {
     3526                                                        processing.unbind( onceProcessed );
     3527                                                        onceProcessed = null;
     3528                                                        previewer.setIframeSrc( previewer.previewUrl.get() );
     3529                                                }
     3530                                        };
     3531                                        processing.bind( onceProcessed );
     3532                                }
     3533                        };
     3534                } )(),
     3535
     3536                /**
     3537                 * Handle the preview receiving the ready message.
     3538                 *
     3539                 * @since 4.7.0
     3540                 *
     3541                 * @param {object} data - Data from preview.
     3542                 * @param {string} data.currentUrl - Current URL.
     3543                 * @param {object} data.activePanels - Active panels.
     3544                 * @param {object} data.activeSections Active sections.
     3545                 * @param {object} data.activeControls Active controls.
     3546                 * @returns {void}
     3547                 */
     3548                ready: function( data ) {
     3549                        var previewer = this, synced = {}, constructs;
     3550
     3551                        synced.settings = api.get();
     3552                        if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) {
     3553                                synced.scroll = previewer.scroll;
     3554                        }
     3555                        previewer.send( 'sync', synced );
     3556
     3557                        // Set the previewUrl without causing the url to set the iframe.
     3558                        if ( data.currentUrl ) {
     3559                                previewer.previewUrl.unbind( previewer.setIframeSrcToPreviewUrl );
     3560                                previewer.previewUrl.set( data.currentUrl );
     3561                                previewer.previewUrl.bind( previewer.setIframeSrcToPreviewUrl );
     3562                        }
     3563
     3564                        /*
     3565                         * Walk over all panels, sections, and controls and set their
     3566                         * respective active states to true if the preview explicitly
     3567                         * indicates as such.
     3568                         */
     3569                        constructs = {
     3570                                panel: data.activePanels,
     3571                                section: data.activeSections,
     3572                                control: data.activeControls
     3573                        };
     3574                        _( constructs ).each( function ( activeConstructs, type ) {
     3575                                api[ type ].each( function ( construct, id ) {
     3576                                        var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
     3577
     3578                                        /*
     3579                                         * If the construct was created statically in PHP (not dynamically in JS)
     3580                                         * then consider a missing (undefined) value in the activeConstructs to
     3581                                         * mean it should be deactivated (since it is gone). But if it is
     3582                                         * dynamically created then only toggle activation if the value is defined,
     3583                                         * as this means that the construct was also then correspondingly
     3584                                         * created statically in PHP and the active callback is available.
     3585                                         * Otherwise, dynamically-created constructs should normally have
     3586                                         * their active states toggled in JS rather than from PHP.
     3587                                         */
     3588                                        if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
     3589                                                if ( activeConstructs[ id ] ) {
     3590                                                        construct.activate();
     3591                                                } else {
     3592                                                        construct.deactivate();
     3593                                                }
     3594                                        }
     3595                                } );
     3596                        } );
     3597
     3598                        if ( data.settingValidities ) {
     3599                                api._handleSettingValidities( {
     3600                                        settingValidities: data.settingValidities,
     3601                                        focusInvalidControl: false
     3602                                } );
     3603                        }
     3604                },
     3605
     3606                /**
     3607                 * Keep the preview alive by listening for ready and keep-alive messages.
     3608                 *
     3609                 * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL.
     3610                 *
     3611                 * @since 4.7.0
     3612                 *
     3613                 * @returns {void}
     3614                 */
     3615                keepPreviewAlive: function keepPreviewAlive() {
     3616                        var previewer = this, heartbeatTick, timeoutId, handleMissingHeartbeat, scheduleHeartbeatCheck, lastKnownUrl;
     3617
     3618                        lastKnownUrl = previewer.previewUrl.get();
     3619                        previewer.previewUrl.bind( function( url ) {
     3620                                lastKnownUrl = url;
     3621                        } );
     3622
     3623                        // @todo What if a page load takes more than 3 seconds?
     3624                        scheduleHeartbeatCheck = function() {
     3625                                timeoutId = setTimeout( handleMissingHeartbeat, 3000 ); // @todo Let interval be a parameter.
     3626                        };
     3627                        heartbeatTick = function() {
     3628                                clearTimeout( timeoutId );
     3629                                scheduleHeartbeatCheck();
     3630                        };
     3631                        handleMissingHeartbeat = function() {
     3632
     3633                                // @todo Instead of changing the URL if the iframe, there should be a notification to prompt the user to return to a customize preview.
     3634                                previewer.setIframeSrc( lastKnownUrl );
     3635                        };
     3636                        scheduleHeartbeatCheck();
     3637
     3638                        previewer.bind( 'ready', heartbeatTick );
     3639                        previewer.bind( 'keep-alive', heartbeatTick );
     3640                },
     3641
     3642                /**
     3643                 * Set the iframe's src URL.
     3644                 *
     3645                 * @since 4.7.0
     3646                 *
     3647                 * @param {string} url URL.
     3648                 * @returns {void}
     3649                 */
     3650                setIframeSrc: function( url ) {
     3651                        var previewer = this, urlParser, params, oldParams, newParams;
     3652
     3653                        urlParser = document.createElement( 'a' );
     3654
     3655                        // @todo Duplication.
     3656                        params = previewer.query();
     3657                        delete params.customized;
     3658                        delete params.wp_customize;
     3659                        delete params.nonce;
     3660                        params.customize_messenger_channel = previewer.channel();
     3661
     3662                        urlParser.href = url;
     3663                        oldParams = api.utils.parseQueryString( urlParser.search.substring( 1 ) );
     3664                        newParams = _.extend( {}, oldParams, params );
     3665
     3666                        // @todo We may need to add a new param for customize_persistent_query_params as _.keys( newParams ).
     3667                        urlParser.search = $.param( newParams );
     3668                        previewer.preview.iframe.prop( 'src', urlParser.href );
     3669                },
     3670
     3671                /**
     3672                 * Add pending changeset update request promise.
     3673                 *
     3674                 * @since 4.7.0
     3675                 *
     3676                 * @param {jQuery.Promise} promise Promise.
     3677                 * @returns {void}
     3678                 */
     3679                addPendingChangesetUpdateRequest: function( promise ) {
     3680                        var previewer = this;
     3681                        previewer.pendingChangesetUpdateRequests.push( promise );
     3682                        promise.always( function() {
     3683                                var i = _.indexOf( previewer.pendingChangesetUpdateRequests, promise );
     3684                                if ( -1 !== i ) {
     3685                                        previewer.pendingChangesetUpdateRequests.splice( i, 1 );
     3686                                }
     3687                        } );
     3688                },
     3689
     3690                /**
    33373691                 * Query string data sent with each preview request.
    33383692                 *
    33393693                 * @abstract
     
    33483702                },
    33493703
    33503704                /**
    3351                  * Refresh the preview.
     3705                 * Refresh the preview seamlessly.
    33523706                 */
    33533707                refresh: function() {
    3354                         var self = this;
     3708                        var previewer = this;
    33553709
    33563710                        // Display loading indicator
    3357                         this.send( 'loading-initiated' );
     3711                        previewer.send( 'loading-initiated' );
    33583712
    3359                         this.abort();
     3713                        previewer.abort();
    33603714
    3361                         this.loading = new api.PreviewFrame({
    3362                                 url:        this.url(),
    3363                                 previewUrl: this.previewUrl(),
    3364                                 query:      this.query() || {},
    3365                                 container:  this.container,
    3366                                 signature:  this.signature
    3367                         });
    3368 
    3369                         this.loading.done( function() {
    3370                                 // 'this' is the loading frame
    3371                                 this.bind( 'synced', function() {
    3372                                         if ( self.preview )
    3373                                                 self.preview.destroy();
    3374                                         self.preview = this;
    3375                                         delete self.loading;
     3715                        previewer.loading = new api.PreviewFrame({
     3716                                url:        previewer.url(),
     3717                                previewUrl: previewer.previewUrl(),
     3718                                query:      previewer.query() || {},
     3719                                container:  previewer.container
     3720                        });
    33763721
    3377                                         self.targetWindow( this.targetWindow() );
    3378                                         self.channel( this.channel() );
     3722                        previewer.loading.done( function( readyData ) {
     3723                                var loadingFrame = this, previousPreview, onceSynced;
    33793724
    3380                                         self.deferred.active.resolve();
    3381                                         self.send( 'active' );
    3382                                 });
     3725                                previousPreview = previewer.preview;
     3726                                previewer.preview = loadingFrame;
     3727                                previewer.targetWindow( loadingFrame.targetWindow() );
     3728                                previewer.channel( loadingFrame.channel() );
    33833729
    3384                                 this.send( 'sync', {
    3385                                         scroll:   self.scroll,
    3386                                         settings: api.get()
    3387                                 });
     3730                                onceSynced = function() {
     3731                                        loadingFrame.unbind( 'synced', onceSynced );
     3732                                        if ( previousPreview ) {
     3733                                                previousPreview.destroy();
     3734                                        }
     3735                                        previewer.deferred.active.resolve();
     3736                                        delete previewer.loading;
     3737                                };
     3738                                loadingFrame.bind( 'synced', onceSynced );
     3739
     3740                                // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh.
     3741                                previewer.trigger( 'ready', readyData );
    33883742                        });
    33893743
    3390                         this.loading.fail( function( reason, location ) {
    3391                                 self.send( 'loading-failed' );
    3392                                 if ( 'redirect' === reason && location ) {
    3393                                         self.previewUrl( location );
    3394                                 }
     3744                        previewer.loading.fail( function( reason ) {
     3745                                previewer.send( 'loading-failed' );
    33953746
    33963747                                if ( 'logged out' === reason ) {
    3397                                         if ( self.preview ) {
    3398                                                 self.preview.destroy();
    3399                                                 delete self.preview;
     3748                                        if ( previewer.preview ) {
     3749                                                previewer.preview.destroy();
     3750                                                delete previewer.preview;
    34003751                                        }
    34013752
    3402                                         self.login().done( self.refresh );
     3753                                        previewer.login().done( previewer.refresh );
    34033754                                }
    34043755
    34053756                                if ( 'cheatin' === reason ) {
    3406                                         self.cheatin();
     3757                                        previewer.cheatin();
    34073758                                }
    34083759                        });
    34093760                },
     
    34633814
    34643815                        request = wp.ajax.post( 'customize_refresh_nonces', {
    34653816                                wp_customize: 'on',
    3466                                 theme: api.settings.theme.stylesheet
     3817                                customize_theme: api.settings.theme.stylesheet
    34673818                        });
    34683819
    34693820                        request.done( function( response ) {
     
    37224073                        container:   '#customize-preview',
    37234074                        form:        '#customize-controls',
    37244075                        previewUrl:  api.settings.url.preview,
    3725                         allowedUrls: api.settings.url.allowed,
    3726                         signature:   'WP_CUSTOMIZER_SIGNATURE'
     4076                        allowedUrls: api.settings.url.allowed
    37274077                }, {
    37284078
    37294079                        nonce: api.settings.nonce,
     
    37434093
    37444094                                return {
    37454095                                        wp_customize: 'on',
    3746                                         theme:      api.settings.theme.stylesheet,
     4096                                        customize_theme: api.settings.theme.stylesheet,
    37474097                                        customized: JSON.stringify( dirtyCustomized ),
    3748                                         nonce:      this.nonce.preview
     4098                                        nonce: this.nonce.preview,
     4099                                        customize_changeset_uuid: api.settings.changeset.uuid
    37494100                                };
    37504101                        },
    37514102
    3752                         save: function() {
    3753                                 var self = this,
     4103                        /**
     4104                         * Save (and publish) the customizer changeset.
     4105                         *
     4106                         * @param {object} [args] Args.
     4107                         * @param {string} [args.status=publish] Status.
     4108                         * @param {string} [args.date] Date, in local time.
     4109                         * @param {string} [args.title] Title
     4110                         *
     4111                         * @returns {jQuery.promise}
     4112                         */
     4113                        save: function( args ) {
     4114                                var previewer = this,
     4115                                        deferred = $.Deferred(),
     4116                                        changesetStatus = 'publish',
    37544117                                        processing = api.state( 'processing' ),
    37554118                                        submitWhenDoneProcessing,
    37564119                                        submit,
     
    37584121                                        invalidSettings = [],
    37594122                                        invalidControls;
    37604123
    3761                                 body.addClass( 'saving' );
     4124                                if ( args && args.status ) {
     4125                                        changesetStatus = args.status;
     4126                                }
     4127
     4128                                if ( api.state( 'saving' ).get() ) {
     4129                                        deferred.reject( 'already_saving' );
     4130                                        deferred.promise();
     4131                                }
     4132
     4133                                api.state( 'changesetStatus' ).set( changesetStatus );
     4134                                api.state( 'saving' ).set( true );
    37624135
    37634136                                function captureSettingModifiedDuringSave( setting ) {
    37644137                                        modifiedWhileSaving[ setting.id ] = true;
     
    37664139                                api.bind( 'change', captureSettingModifiedDuringSave );
    37674140
    37684141                                submit = function () {
    3769                                         var request, query;
     4142                                        var request, query, settingInvalidities = {};
    37704143
    37714144                                        /*
    37724145                                         * Block saving if there are any settings that are marked as
     
    37754148                                         */
    37764149                                        api.each( function( setting ) {
    37774150                                                setting.notifications.each( function( notification ) {
    3778                                                         if ( 'error' === notification.type && ! notification.fromServer ) {
     4151                                                        if ( 'error' === notification.type && ! notification.fromServer ) { // @todo Eliminate the fromServer now that changeset is updated with each change?
    37794152                                                                invalidSettings.push( setting.id );
     4153                                                                if ( ! settingInvalidities[ setting.id ] ) {
     4154                                                                        settingInvalidities[ setting.id ] = {};
     4155                                                                }
     4156                                                                settingInvalidities[ setting.id ][ notification.code ] = notification;
    37804157                                                        }
    37814158                                                } );
    37824159                                        } );
    37834160                                        invalidControls = api.findControlsForSettings( invalidSettings );
    37844161                                        if ( ! _.isEmpty( invalidControls ) ) {
    37854162                                                _.values( invalidControls )[0][0].focus();
    3786                                                 body.removeClass( 'saving' );
    37874163                                                api.unbind( 'change', captureSettingModifiedDuringSave );
    3788                                                 return;
     4164                                                deferred.rejectWith( previewer, [
     4165                                                        { setting_invalidities: settingInvalidities }
     4166                                                ] );
     4167                                                api.state( 'saving' ).set( false );
     4168                                                return deferred.promise();
    37894169                                        }
    37904170
    3791                                         query = $.extend( self.query(), {
    3792                                                 nonce:  self.nonce.save
     4171                                        query = $.extend( previewer.query(), {
     4172                                                nonce: previewer.nonce.save,
     4173                                                customize_changeset_status: api.state( 'changesetStatus' ).get()
    37934174                                        } );
     4175                                        if ( args && args.date ) {
     4176                                                query.customize_changeset_date = args.date;
     4177                                        }
     4178                                        if ( args && args.title ) {
     4179                                                query.customize_changeset_title = args.title;
     4180                                        }
     4181
     4182                                        /*
     4183                                         * Note that the dirty customized values will have already been set in the
     4184                                         * changeset and so technically query.customized could be deleted. However,
     4185                                         * it is remaining here to make sure that any settings that got updated
     4186                                         * quietly which may have not triggered an update request will also get
     4187                                         * included in the values that get saved to the changeset. This will ensure
     4188                                         * that values that get injected via the saved event will be included in
     4189                                         * the changeset. This also ensures that setting values that were invalid
     4190                                         * will get re-validated, perhaps in the case of settings that are invalid
     4191                                         * due to dependencies on other settings.
     4192                                         */
    37944193                                        request = wp.ajax.post( 'customize_save', query );
    37954194
    37964195                                        // Disable save button during the save request.
     
    37994198                                        api.trigger( 'save', request );
    38004199
    38014200                                        request.always( function () {
    3802                                                 body.removeClass( 'saving' );
     4201                                                api.state( 'saving' ).set( false );
    38034202                                                saveBtn.prop( 'disabled', false );
    38044203                                                api.unbind( 'change', captureSettingModifiedDuringSave );
    38054204                                        } );
    38064205
    38074206                                        request.fail( function ( response ) {
     4207
    38084208                                                if ( '0' === response ) {
    38094209                                                        response = 'not_logged_in';
    38104210                                                } else if ( '-1' === response ) {
     
    38134213                                                }
    38144214
    38154215                                                if ( 'invalid_nonce' === response ) {
    3816                                                         self.cheatin();
     4216                                                        previewer.cheatin();
    38174217                                                } else if ( 'not_logged_in' === response ) {
    3818                                                         self.preview.iframe.hide();
    3819                                                         self.login().done( function() {
    3820                                                                 self.save();
    3821                                                                 self.preview.iframe.show();
     4218                                                        previewer.preview.iframe.hide();
     4219                                                        previewer.login().done( function() {
     4220                                                                previewer.save();
     4221                                                                previewer.preview.iframe.show();
    38224222                                                        } );
    38234223                                                }
    38244224
     
    38294229                                                        } );
    38304230                                                }
    38314231
     4232                                                deferred.rejectWith( previewer, [ response ] );
    38324233                                                api.trigger( 'error', response );
    38334234                                        } );
    38344235
     
    38414242                                                        }
    38424243                                                } );
    38434244
    3844                                                 api.previewer.send( 'saved', response );
     4245                                                previewer.send( 'saved', response );
     4246
     4247                                                api.state( 'changesetStatus' ).set( response.changeset_status );
     4248                                                if ( 'publish' === response.changeset_status ) {
     4249                                                        api.state( 'changesetStatus' ).set( '' );
     4250                                                        api.settings.changeset.uuid = response.next_changeset_uuid;
     4251                                                        parent.send( 'changeset-uuid', api.settings.changeset.uuid );
     4252                                                }
    38454253
    38464254                                                if ( response.setting_validities ) {
    38474255                                                        api._handleSettingValidities( {
     
    38504258                                                        } );
    38514259                                                }
    38524260
     4261                                                deferred.resolveWith( previewer, [ response ] );
    38534262                                                api.trigger( 'saved', response );
    38544263
    38554264                                                // Restore the global dirty state if any settings were modified during save.
     
    38714280                                        api.state.bind( 'change', submitWhenDoneProcessing );
    38724281                                }
    38734282
     4283                                return deferred.promise();
    38744284                        }
    38754285                });
    38764286
     
    39634373                        values.bind( 'remove', debouncedReflowPaneContents );
    39644374                } );
    39654375
    3966                 // Check if preview url is valid and load the preview frame.
    3967                 if ( api.previewer.previewUrl() ) {
    3968                         api.previewer.refresh();
    3969                 } else {
    3970                         api.previewer.previewUrl( api.settings.url.home );
    3971                 }
    3972 
    39734376                // Save and activated states
    39744377                (function() {
    39754378                        var state = new api.Values(),
    39764379                                saved = state.create( 'saved' ),
     4380                                saving = state.create( 'saving' ),
    39774381                                activated = state.create( 'activated' ),
    39784382                                processing = state.create( 'processing' ),
    3979                                 paneVisible = state.create( 'paneVisible' );
     4383                                paneVisible = state.create( 'paneVisible' ),
     4384                                changesetStatus = state.create( 'changesetStatus' );
    39804385
    39814386                        state.bind( 'change', function() {
     4387                                var canSave;
     4388
    39824389                                if ( ! activated() ) {
    3983                                         saveBtn.val( api.l10n.activate ).prop( 'disabled', false );
     4390                                        saveBtn.val( api.l10n.activate );
    39844391                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    39854392
    3986                                 } else if ( saved() ) {
    3987                                         saveBtn.val( api.l10n.saved ).prop( 'disabled', true );
     4393                                } else if ( '' === changesetStatus.get() ) {
     4394                                        saveBtn.val( api.l10n.saved );
    39884395                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
    39894396
    39904397                                } else {
    3991                                         saveBtn.val( api.l10n.save ).prop( 'disabled', false );
     4398                                        saveBtn.val( api.l10n.save );
    39924399                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    39934400                                }
     4401
     4402                                /*
     4403                                 * Save (publish) button should be enabled if saving is not currently happening,
     4404                                 * and if the theme is not active or the changeset exists but is not published.
     4405                                 */
     4406                                canSave = ! saving() && ( ! activated() || ( '' !== changesetStatus() && 'publish' !== changesetStatus() ) );
     4407
     4408                                saveBtn.prop( 'disabled', ! canSave );
    39944409                        });
    39954410
    39964411                        // Set default states.
    39974412                        saved( true );
     4413                        saving( false );
    39984414                        activated( api.settings.theme.active );
    39994415                        processing( 0 );
    40004416                        paneVisible( true );
     4417                        changesetStatus( api.settings.changeset.status );
    40014418
    40024419                        api.bind( 'change', function() {
    40034420                                state('saved').set( false );
    40044421                        });
    40054422
    4006                         api.bind( 'saved', function() {
     4423                        saving.bind( function( isSaving ) {
     4424                                body.toggleClass( 'saving', isSaving );
     4425                        } );
     4426
     4427                        api.bind( 'saved', function( response ) {
    40074428                                state('saved').set( true );
    4008                                 state('activated').set( true );
     4429                                if ( 'publish' === response.changeset_status ) {
     4430                                        state( 'activated' ).set( true );
     4431                                }
    40094432                        });
    40104433
    40114434                        activated.bind( function( to ) {
     
    40144437                                }
    40154438                        });
    40164439
     4440                        changesetStatus.bind( function( newStatus, oldStatus ) {
     4441                                var urlParser;
     4442
     4443                                if ( ! history.replaceState ) {
     4444                                        return;
     4445                                }
     4446
     4447                                // Abort if not a transition between existing and non-existing.
     4448                                if ( newStatus && oldStatus ) {
     4449                                        return;
     4450                                }
     4451
     4452                                urlParser = document.createElement( 'a' );
     4453                                urlParser.href = location.href;
     4454                                urlParser.search = urlParser.search.replace( /(\?|&)changeset_uuid=[^&]+(&|$)/, '$1' );
     4455                                urlParser.search = urlParser.search.replace( /&+$/, '' );
     4456                                if ( '' !== newStatus ) {
     4457                                        if ( urlParser.search.length > 1 ) {
     4458                                                urlParser.search += '&';
     4459                                        }
     4460                                        urlParser.search += 'changeset_uuid=' + api.settings.changeset.uuid;
     4461                                }
     4462                                history.replaceState( {}, document.title, urlParser.href );
     4463                        } );
     4464
    40174465                        // Expose states to the API.
    40184466                        api.state = state;
    40194467                }());
    40204468
     4469                // Check if preview url is valid and load the preview frame.
     4470                if ( api.previewer.previewUrl() ) {
     4471                        api.previewer.refresh();
     4472                } else {
     4473                        api.previewer.previewUrl( api.settings.url.home );
     4474                }
     4475
    40214476                // Button bindings.
    40224477                saveBtn.click( function( event ) {
    40234478                        api.previewer.save();
     
    41694624                });
    41704625
    41714626                // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
    4172                 $( window ).on( 'beforeunload', function () {
     4627                $( window ).on( 'beforeunload.customize-confirm', function () {
    41734628                        if ( ! api.state( 'saved' )() ) {
    41744629                                setTimeout( function() {
    41754630                                        overlay.removeClass( 'customize-loading' );
     
    41904645                        parent.send( 'title', newTitle );
    41914646                });
    41924647
     4648                parent.send( 'changeset-uuid', api.settings.changeset.uuid );
     4649
    41934650                // Initialize the connection with the parent frame.
    41944651                parent.send( 'ready' );
    41954652
  • src/wp-admin/js/customize-widgets.js

     
    11541154                        params.action = 'update-widget';
    11551155                        params.wp_customize = 'on';
    11561156                        params.nonce = api.settings.nonce['update-widget'];
    1157                         params.theme = api.settings.theme.stylesheet;
     1157                        params.customize_theme = api.settings.theme.stylesheet;
    11581158                        params.customized = wp.customize.previewer.query().customized;
    11591159
    11601160                        data = $.param( params );
  • src/wp-admin/customize.php

     
    2020        );
    2121}
    2222
     23/**
     24 * @global WP_Scripts           $wp_scripts
     25 * @global WP_Customize_Manager $wp_customize
     26 */
     27global $wp_scripts, $wp_customize;
     28
     29if ( $wp_customize->changeset_post_id() ) {
     30        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) {
     31                wp_die(
     32                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     33                        '<p>' . __( 'Sorry, you are not allowed to edit this changeset.' ) . '</p>',
     34                        403
     35                );
     36        }
     37        if ( in_array( get_post_status( $wp_customize->changeset_post_id() ), array( 'publish', 'trash' ), true ) ) {
     38                wp_die(
     39                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     40                        '<p>' . __( 'This changeset has already been published and cannot be further modified.' ) . '</p>',
     41                        403
     42                );
     43        }
     44}
     45
     46
    2347wp_reset_vars( array( 'url', 'return', 'autofocus' ) );
    2448if ( ! empty( $url ) ) {
    2549        $wp_customize->set_preview_url( wp_unslash( $url ) );
     
    3155        $wp_customize->set_autofocus( wp_unslash( $autofocus ) );
    3256}
    3357
    34 /**
    35  * @global WP_Scripts           $wp_scripts
    36  * @global WP_Customize_Manager $wp_customize
    37  */
    38 global $wp_scripts, $wp_customize;
    39 
    4058$registered = $wp_scripts->registered;
    4159$wp_scripts = new WP_Scripts;
    4260$wp_scripts->registered = $registered;
     
    115133                <div id="customize-header-actions" class="wp-full-overlay-header">
    116134                        <?php
    117135                        $save_text = $wp_customize->is_theme_active() ? __( 'Save &amp; Publish' ) : __( 'Save &amp; Activate' );
    118                         submit_button( $save_text, 'primary save', 'save', false );
     136                        $save_attrs = array();
     137                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     138                                $save_attrs['style'] = 'display: none';
     139                        }
     140                        submit_button( $save_text, 'primary save', 'save', false, $save_attrs );
    119141                        ?>
    120142                        <span class="spinner"></span>
    121143                        <button type="button" class="customize-controls-preview-toggle">