WordPress.org

Make WordPress Core

Changeset 38810


Ignore:
Timestamp:
10/18/2016 08:04:36 PM (20 months ago)
Author:
westonruter
Message:

Customize: Implement customized state persistence with changesets.

Includes infrastructure developed in the Customize Snapshots feature plugin.

See https://make.wordpress.org/core/2016/10/12/customize-changesets-technical-design-decisions/

Props westonruter, valendesigns, utkarshpatel, stubgo, lgedeon, ocean90, ryankienstra, mihai2u, dlh, aaroncampbell, jonathanbardo, jorbin.
See #28721.
See #31089.
Fixes #30937.
Fixes #31517.
Fixes #30028.
Fixes #23225.
Fixes #34142.
Fixes #36485.

Location:
trunk
Files:
1 added
31 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/customize.php

    r38672 r38810  
    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            '<p><a href="' . esc_url( remove_query_arg( 'changeset_uuid' ) ) . '">' . __( 'Customize New Changes' ) . '</a></p>',
     42            403
     43        );
     44    }
     45}
     46
     47
    2348wp_reset_vars( array( 'url', 'return', 'autofocus' ) );
    2449if ( ! empty( $url ) ) {
     
    3156    $wp_customize->set_autofocus( wp_unslash( $autofocus ) );
    3257}
    33 
    34 /**
    35  * @global WP_Scripts           $wp_scripts
    36  * @global WP_Customize_Manager $wp_customize
    37  */
    38 global $wp_scripts, $wp_customize;
    3958
    4059$registered = $wp_scripts->registered;
     
    116135            <?php
    117136            $save_text = $wp_customize->is_theme_active() ? __( 'Save &amp; Publish' ) : __( 'Save &amp; Activate' );
    118             submit_button( $save_text, 'primary save', 'save', false );
     137            $save_attrs = array();
     138            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     139                $save_attrs['style'] = 'display: none';
     140            }
     141            submit_button( $save_text, 'primary save', 'save', false, $save_attrs );
    119142            ?>
    120143            <span class="spinner"></span>
  • trunk/src/wp-admin/js/customize-controls.js

    r38742 r38810  
    2323    api.Setting = api.Value.extend({
    2424        initialize: function( id, value, options ) {
    25             api.Value.prototype.initialize.call( this, value, options );
    26 
    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 });
     25            var setting = this;
     26            api.Value.prototype.initialize.call( setting, value, options );
     27
     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
    3233            // Whenever the setting's value changes, refresh the preview.
    33             this.bind( this.preview );
     34            setting.bind( setting.preview );
    3435        },
    3536
    3637        /**
    3738         * Refresh the preview, respective of the setting's refresh policy.
     39         *
     40         * If the preview hasn't sent a keep-alive message and is likely
     41         * disconnected by having navigated to a non-allowed URL, then the
     42         * refresh transport will be forced when postMessage is the transport.
     43         * Note that postMessage does not throw an error when the recipient window
     44         * fails to match the origin window, so using try/catch around the
     45         * previewer.send() call to then fallback to refresh will not work.
     46         *
     47         * @since 3.4.0
    3848         */
    3949        preview: function() {
    40             switch ( this.transport ) {
    41                 case 'refresh':
    42                     return this.previewer.refresh();
    43                 case 'postMessage':
    44                     return this.previewer.send( 'setting', [ this.id, this() ] );
     50            var setting = this, transport;
     51            transport = setting.transport;
     52
     53            if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) {
     54                transport = 'refresh';
     55            }
     56
     57            if ( 'postMessage' === transport ) {
     58                return setting.previewer.send( 'setting', [ setting.id, setting() ] );
     59            } else if ( 'refresh' === transport ) {
     60                return setting.previewer.refresh();
    4561            }
    4662        },
     
    6682
    6783    /**
    68      * Utility function namespace
     84     * Current change count.
     85     *
     86     * @since 4.7.0
     87     * @type {number}
     88     * @protected
    6989     */
    70     api.utils = {};
     90    api._latestRevision = 0;
     91
     92    /**
     93     * Last revision that was saved.
     94     *
     95     * @since 4.7.0
     96     * @type {number}
     97     * @protected
     98     */
     99    api._lastSavedRevision = 0;
     100
     101    /**
     102     * Latest revisions associated with the updated setting.
     103     *
     104     * @since 4.7.0
     105     * @type {object}
     106     * @protected
     107     */
     108    api._latestSettingRevisions = {};
     109
     110    /*
     111     * Keep track of the revision associated with each updated setting so that
     112     * requestChangesetUpdate knows which dirty settings to include. Also, once
     113     * ready is triggered and all initial settings have been added, increment
     114     * revision for each newly-created initially-dirty setting so that it will
     115     * also be included in changeset update requests.
     116     */
     117    api.bind( 'change', function incrementChangedSettingRevision( setting ) {
     118        api._latestRevision += 1;
     119        api._latestSettingRevisions[ setting.id ] = api._latestRevision;
     120    } );
     121    api.bind( 'ready', function() {
     122        api.bind( 'add', function incrementCreatedSettingRevision( setting ) {
     123            if ( setting._dirty ) {
     124                api._latestRevision += 1;
     125                api._latestSettingRevisions[ setting.id ] = api._latestRevision;
     126            }
     127        } );
     128    } );
     129
     130    /**
     131     * Get the dirty setting values.
     132     *
     133     * @param {object} [options] Options.
     134     * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes).
     135     * @returns {object} Dirty setting values.
     136     */
     137    api.dirtyValues = function dirtyValues( options ) {
     138        var values = {};
     139        api.each( function( setting ) {
     140            var settingRevision;
     141
     142            if ( ! setting._dirty ) {
     143                return;
     144            }
     145
     146            settingRevision = api._latestSettingRevisions[ setting.id ];
     147
     148            // Skip including settings that have already been included in the changeset, if only requesting unsaved.
     149            if ( ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) {
     150                return;
     151            }
     152
     153            values[ setting.id ] = setting.get();
     154        } );
     155        return values;
     156    };
     157
     158    /**
     159     * Request updates to the changeset.
     160     *
     161     * @param {object} [changes] Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
     162     *                           If not provided, then the changes will still be obtained from unsaved dirty settings.
     163     * @returns {jQuery.Promise}
     164     */
     165    api.requestChangesetUpdate = function requestChangesetUpdate( changes ) {
     166        var deferred, request, submittedChanges = {}, data;
     167        deferred = new $.Deferred();
     168
     169        if ( changes ) {
     170            _.extend( submittedChanges, changes );
     171        }
     172
     173        // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes.
     174        _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) {
     175            if ( ! changes || null !== changes[ settingId ] ) {
     176                submittedChanges[ settingId ] = _.extend(
     177                    {},
     178                    submittedChanges[ settingId ] || {},
     179                    { value: dirtyValue }
     180                );
     181            }
     182        } );
     183
     184        // Short-circuit when there are no pending changes.
     185        if ( _.isEmpty( submittedChanges ) ) {
     186            deferred.resolve( {} );
     187            return deferred.promise();
     188        }
     189
     190        // Make sure that publishing a changeset waits for all changeset update requests to complete.
     191        api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
     192        deferred.always( function() {
     193            api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
     194        } );
     195
     196        // Allow plugins to attach additional params to the settings.
     197        api.trigger( 'changeset-save', submittedChanges );
     198
     199        // Ensure that if any plugins add data to save requests by extending query() that they get included here.
     200        data = api.previewer.query( { excludeCustomizedSaved: true } );
     201        delete data.customized; // Being sent in customize_changeset_data instead.
     202        _.extend( data, {
     203            nonce: api.settings.nonce.save,
     204            customize_theme: api.settings.theme.stylesheet,
     205            customize_changeset_data: JSON.stringify( submittedChanges )
     206        } );
     207
     208        request = wp.ajax.post( 'customize_save', data );
     209
     210        request.done( function requestChangesetUpdateDone( data ) {
     211            var savedChangesetValues = {};
     212
     213            // Ensure that all settings updated subsequently will be included in the next changeset update request.
     214            api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision );
     215
     216            api.state( 'changesetStatus' ).set( data.changeset_status );
     217            deferred.resolve( data );
     218            api.trigger( 'changeset-saved', data );
     219
     220            if ( data.setting_validities ) {
     221                _.each( data.setting_validities, function( validity, settingId ) {
     222                    if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) {
     223                        savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value;
     224                    }
     225                } );
     226            }
     227
     228            api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) );
     229        } );
     230        request.fail( function requestChangesetUpdateFail( data ) {
     231            deferred.reject( data );
     232            api.trigger( 'changeset-error', data );
     233        } );
     234        request.always( function( data ) {
     235            if ( data.setting_validities ) {
     236                api._handleSettingValidities( {
     237                    settingValidities: data.setting_validities
     238                } );
     239            }
     240        } );
     241
     242        return deferred.promise();
     243    };
    71244
    72245    /**
     
    12171390
    12181391        /**
     1392         * Load theme preview.
     1393         *
     1394         * @since 4.7.0
     1395         *
     1396         * @param {string} themeId Theme ID.
     1397         * @returns {jQuery.promise} Promise.
     1398         */
     1399        loadThemePreview: function( themeId ) {
     1400            var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser;
     1401
     1402            urlParser = document.createElement( 'a' );
     1403            urlParser.href = location.href;
     1404            urlParser.search = $.param( _.extend(
     1405                api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     1406                {
     1407                    theme: themeId,
     1408                    changeset_uuid: api.settings.changeset.uuid
     1409                }
     1410            ) );
     1411
     1412            overlay = $( '.wp-full-overlay' );
     1413            overlay.addClass( 'customize-loading' );
     1414
     1415            onceProcessingComplete = function() {
     1416                var request;
     1417                if ( api.state( 'processing' ).get() > 0 ) {
     1418                    return;
     1419                }
     1420
     1421                api.state( 'processing' ).unbind( onceProcessingComplete );
     1422
     1423                request = api.requestChangesetUpdate();
     1424                request.done( function() {
     1425                    $( window ).off( 'beforeunload.customize-confirm' );
     1426                    window.location.href = urlParser.href;
     1427                } );
     1428                request.fail( function() {
     1429                    overlay.removeClass( 'customize-loading' );
     1430                } );
     1431            };
     1432
     1433            if ( 0 === api.state( 'processing' ).get() ) {
     1434                onceProcessingComplete();
     1435            } else {
     1436                api.state( 'processing' ).bind( onceProcessingComplete );
     1437            }
     1438
     1439            return deferred.promise();
     1440        },
     1441
     1442        /**
    12191443         * Render & show the theme details for a given theme model.
    12201444         *
     
    12241448         */
    12251449        showDetails: function ( theme, callback ) {
    1226             var section = this;
     1450            var section = this, link;
    12271451            callback = callback || function(){};
    12281452            section.currentTheme = theme.id;
     
    12331457            section.containFocus( section.overlay );
    12341458            section.updateLimits();
     1459
     1460            link = section.overlay.find( '.inactive-theme > a' );
     1461
     1462            link.on( 'click', function( event ) {
     1463                event.preventDefault();
     1464
     1465                // Short-circuit if request is currently being made.
     1466                if ( link.hasClass( 'disabled' ) ) {
     1467                    return;
     1468                }
     1469                link.addClass( 'disabled' );
     1470
     1471                section.loadThemePreview( theme.id ).fail( function() {
     1472                    link.removeClass( 'disabled' );
     1473                } );
     1474            } );
    12351475            callback();
    12361476        },
     
    22282468                nonce: _wpCustomizeBackground.nonces.add,
    22292469                wp_customize: 'on',
    2230                 theme: api.settings.theme.stylesheet,
     2470                customize_theme: api.settings.theme.stylesheet,
    22312471                attachment_id: this.params.attachment.id
    22322472            } );
     
    25932833            // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme.
    25942834            wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on';
    2595             wp.media.controller.Cropper.prototype.defaults.doCropArgs.theme = api.settings.theme.stylesheet;
     2835            wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet;
    25962836        },
    25972837
     
    28843124                }
    28853125
    2886                 var previewUrl = $( this ).data( 'previewUrl' );
    2887 
    2888                 $( '.wp-full-overlay' ).addClass( 'customize-loading' );
    2889 
    2890                 window.parent.location = previewUrl;
     3126                api.section( control.section() ).loadThemePreview( control.params.theme.id );
    28913127            });
    28923128
     
    29493185     */
    29503186    api.PreviewFrame = api.Messenger.extend({
    2951         sensitivity: 2000,
     3187        sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity.
    29523188
    29533189        /**
     
    29553191         *
    29563192         * @param {object} params.container
    2957          * @param {object} params.signature
    29583193         * @param {object} params.previewUrl
    29593194         * @param {object} params.query
     
    29703205
    29713206            this.container = params.container;
    2972             this.signature = params.signature;
    29733207
    29743208            $.extend( params, { channel: api.PreviewFrame.uuid() });
     
    29903224         */
    29913225        run: function( deferred ) {
    2992             var self  = this,
     3226            var previewFrame = this,
    29933227                loaded = false,
    2994                 ready  = false;
    2995 
    2996             if ( this._ready ) {
    2997                 this.unbind( 'ready', this._ready );
    2998             }
    2999 
    3000             this._ready = function() {
     3228                ready = false,
     3229                readyData = null,
     3230                hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized,
     3231                urlParser,
     3232                params,
     3233                form;
     3234
     3235            if ( previewFrame._ready ) {
     3236                previewFrame.unbind( 'ready', previewFrame._ready );
     3237            }
     3238
     3239            previewFrame._ready = function( data ) {
    30013240                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 
     3241                readyData = data;
     3242                previewFrame.container.addClass( 'iframe-ready' );
    30143243                if ( ! data ) {
    30153244                    return;
    30163245                }
    30173246
    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                     } );
     3247                if ( loaded ) {
     3248                    deferred.resolveWith( previewFrame, [ data ] );
     3249                }
     3250            };
     3251
     3252            previewFrame.bind( 'ready', previewFrame._ready );
     3253
     3254            urlParser = document.createElement( 'a' );
     3255            urlParser.href = previewFrame.previewUrl();
     3256
     3257            params = _.extend(
     3258                api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     3259                {
     3260                    customize_changeset_uuid: previewFrame.query.customize_changeset_uuid,
     3261                    customize_theme: previewFrame.query.customize_theme,
     3262                    customize_messenger_channel: previewFrame.query.customize_messenger_channel
     3263                }
     3264            );
     3265
     3266            urlParser.search = $.param( params );
     3267            previewFrame.iframe = $( '<iframe />', {
     3268                title: api.l10n.previewIframeTitle,
     3269                name: 'customize-' + previewFrame.channel()
     3270            } );
     3271            previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
     3272
     3273            if ( ! hasPendingChangesetUpdate ) {
     3274                previewFrame.iframe.attr( 'src', urlParser.href );
     3275            } else {
     3276                previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes.
     3277            }
     3278
     3279            previewFrame.iframe.appendTo( previewFrame.container );
     3280            previewFrame.targetWindow( previewFrame.iframe[0].contentWindow );
     3281
     3282            /*
     3283             * Submit customized data in POST request to preview frame window since
     3284             * there are setting value changes not yet written to changeset.
     3285             */
     3286            if ( hasPendingChangesetUpdate ) {
     3287                form = $( '<form>', {
     3288                    action: urlParser.href,
     3289                    target: previewFrame.iframe.attr( 'name' ),
     3290                    method: 'post',
     3291                    hidden: 'hidden'
    30503292                } );
    3051 
    3052                 if ( data.settingValidities ) {
    3053                     api._handleSettingValidities( {
    3054                         settingValidities: data.settingValidities,
    3055                         focusInvalidControl: false
    3056                     } );
    3057                 }
     3293                form.append( $( '<input>', {
     3294                    type: 'hidden',
     3295                    name: '_method',
     3296                    value: 'GET'
     3297                } ) );
     3298                _.each( previewFrame.query, function( value, key ) {
     3299                    form.append( $( '<input>', {
     3300                        type: 'hidden',
     3301                        name: key,
     3302                        value: value
     3303                    } ) );
     3304                } );
     3305                previewFrame.container.append( form );
     3306                form.submit();
     3307                form.remove(); // No need to keep the form around after submitted.
     3308            }
     3309
     3310            previewFrame.bind( 'iframe-loading-error', function( error ) {
     3311                previewFrame.iframe.remove();
     3312
     3313                // Check if the user is not logged in.
     3314                if ( 0 === error ) {
     3315                    previewFrame.login( deferred );
     3316                    return;
     3317                }
     3318
     3319                // Check for cheaters.
     3320                if ( -1 === error ) {
     3321                    deferred.rejectWith( previewFrame, [ 'cheatin' ] );
     3322                    return;
     3323                }
     3324
     3325                deferred.rejectWith( previewFrame, [ 'request failure' ] );
    30583326            } );
    30593327
    3060             this.request = $.ajax( this.previewUrl(), {
    3061                 type: 'POST',
    3062                 data: this.query,
    3063                 xhrFields: {
    3064                     withCredentials: true
    3065                 }
    3066             } );
    3067 
    3068             this.request.fail( function() {
    3069                 deferred.rejectWith( self, [ 'request failure' ] );
    3070             });
    3071 
    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                 }
    3083 
    3084                 // Check if the user is not logged in.
    3085                 if ( '0' === response ) {
    3086                     self.login( deferred );
    3087                     return;
    3088                 }
    3089 
    3090                 // 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' ] );
    3100                     return;
    3101                 }
    3102 
    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                 });
    3123 
    3124                 self.targetWindow( self.iframe[0].contentWindow );
    3125 
    3126                 self.targetWindow().document.open();
    3127                 self.targetWindow().document.write( response );
    3128                 self.targetWindow().document.close();
     3328            previewFrame.iframe.one( 'load', function() {
     3329                loaded = true;
     3330
     3331                if ( ready ) {
     3332                    deferred.resolveWith( previewFrame, [ readyData ] );
     3333                } else {
     3334                    setTimeout( function() {
     3335                        deferred.rejectWith( previewFrame, [ 'ready timeout' ] );
     3336                    }, previewFrame.sensitivity );
     3337                }
    31293338            });
    31303339        },
     
    31653374        destroy: function() {
    31663375            api.Messenger.prototype.destroy.call( this );
    3167             this.request.abort();
    3168 
    3169             if ( this.iframe )
     3376
     3377            if ( this.iframe ) {
    31703378                this.iframe.remove();
    3171 
    3172             delete this.request;
     3379            }
     3380
    31733381            delete this.iframe;
    31743382            delete this.targetWindow;
     
    31773385
    31783386    (function(){
    3179         var uuid = 0;
    3180         /**
    3181          * Create a universally unique identifier.
    3182          *
    3183          * @return {int}
     3387        var id = 0;
     3388        /**
     3389         * Return an incremented ID for a preview messenger channel.
     3390         *
     3391         * This function is named "uuid" for historical reasons, but it is a
     3392         * misnomer as it is not an actual UUID, and it is not universally unique.
     3393         * This is not to be confused with `api.settings.changeset.uuid`.
     3394         *
     3395         * @return {string}
    31843396         */
    31853397        api.PreviewFrame.uuid = function() {
    3186             return 'preview-' + uuid++;
     3398            return 'preview-' + String( id++ );
    31873399        };
    31883400    }());
     
    32103422     */
    32113423    api.Previewer = api.Messenger.extend({
    3212         refreshBuffer: 250,
     3424        refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh.
    32133425
    32143426        /**
     
    32183430         * @param {string} params.form
    32193431         * @param {string} params.previewUrl  The URL to preview.
    3220          * @param {string} params.signature
    32213432         * @param {object} options
    32223433         */
    32233434        initialize: function( params, options ) {
    3224             var self = this,
    3225                 rscheme = /^https?/;
    3226 
    3227             $.extend( this, options || {} );
    3228             this.deferred = {
     3435            var previewer = this,
     3436                urlParser = document.createElement( 'a' );
     3437
     3438            $.extend( previewer, options || {} );
     3439            previewer.deferred = {
    32293440                active: $.Deferred()
    32303441            };
    32313442
    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();
     3443            // Debounce to prevent hammering server and then wait for any pending update requests.
     3444            previewer.refresh = _.debounce(
     3445                ( function( originalRefresh ) {
     3446                    return function() {
     3447                        var isProcessingComplete, refreshOnceProcessingComplete;
     3448                        isProcessingComplete = function() {
     3449                            return 0 === api.state( 'processing' ).get();
     3450                        };
     3451                        if ( isProcessingComplete() ) {
     3452                            originalRefresh.call( previewer );
    32563453                        } else {
    3257                             return callback();
     3454                            refreshOnceProcessingComplete = function() {
     3455                                if ( isProcessingComplete() ) {
     3456                                    originalRefresh.call( previewer );
     3457                                    api.state( 'processing' ).unbind( refreshOnceProcessingComplete );
     3458                                }
     3459                            };
     3460                            api.state( 'processing' ).bind( refreshOnceProcessingComplete );
    32583461                        }
    3259                     }
    3260 
    3261                     clearTimeout( timeout );
    3262                     timeout = setTimeout( callback, self.refreshBuffer );
    3263                 };
    3264             })( this );
    3265 
    3266             this.container   = api.ensure( params.container );
    3267             this.allowedUrls = params.allowedUrls;
    3268             this.signature   = params.signature;
     3462                    };
     3463                }( previewer.refresh ) ),
     3464                previewer.refreshBuffer
     3465            );
     3466
     3467            previewer.container   = api.ensure( params.container );
     3468            previewer.allowedUrls = params.allowedUrls;
    32693469
    32703470            params.url = window.location.href;
    32713471
    3272             api.Messenger.prototype.initialize.call( this, params );
    3273 
    3274             this.add( 'scheme', this.origin() ).link( this.origin ).setter( function( to ) {
    3275                 var match = to.match( rscheme );
    3276                 return match ? match[0] : '';
    3277             });
     3472            api.Messenger.prototype.initialize.call( previewer, params );
     3473
     3474            urlParser.href = previewer.origin();
     3475            previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
    32783476
    32793477            // Limit the URL to internal, front-end links.
     
    32853483            // ssl certs.
    32863484
    3287             this.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
    3288                 var result, urlParser;
     3485            previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
     3486                var result, urlParser, newPreviewUrl, schemeMatchingPreviewUrl, queryParams;
    32893487                urlParser = document.createElement( 'a' );
    32903488                urlParser.href = to;
     
    32953493                }
    32963494
     3495                // Remove state query params.
     3496                if ( urlParser.search.length > 1 ) {
     3497                    queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     3498                    delete queryParams.customize_changeset_uuid;
     3499                    delete queryParams.customize_theme;
     3500                    delete queryParams.customize_messenger_channel;
     3501                    if ( _.isEmpty( queryParams ) ) {
     3502                        urlParser.search = '';
     3503                    } else {
     3504                        urlParser.search = $.param( queryParams );
     3505                    }
     3506                }
     3507
     3508                newPreviewUrl = urlParser.href;
     3509                urlParser.protocol = previewer.scheme.get() + ':';
     3510                schemeMatchingPreviewUrl = urlParser.href;
     3511
    32973512                // Attempt to match the URL to the control frame's scheme
    32983513                // 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 ) {
     3514                $.each( [ schemeMatchingPreviewUrl, newPreviewUrl ], function( i, url ) {
     3515                    $.each( previewer.allowedUrls, function( i, allowed ) {
    33013516                        var path;
    33023517
     
    33093524                        }
    33103525                    });
    3311                     if ( result )
     3526                    if ( result ) {
    33123527                        return false;
     3528                    }
    33133529                });
    33143530
     
    33173533            });
    33183534
     3535            previewer.bind( 'ready', previewer.ready );
     3536
     3537            // Start listening for keep-alive messages when iframe first loads.
     3538            previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) );
     3539
     3540            previewer.bind( 'synced', function() {
     3541                previewer.send( 'active' );
     3542            } );
     3543
    33193544            // Refresh the preview when the URL is changed (but not yet).
    3320             this.previewUrl.bind( this.refresh );
    3321 
    3322             this.scroll = 0;
    3323             this.bind( 'scroll', function( distance ) {
    3324                 this.scroll = distance;
    3325             });
    3326 
    3327             // Update the URL when the iframe sends a URL message.
    3328             this.bind( 'url', this.previewUrl );
     3545            previewer.previewUrl.bind( previewer.refresh );
     3546
     3547            previewer.scroll = 0;
     3548            previewer.bind( 'scroll', function( distance ) {
     3549                previewer.scroll = distance;
     3550            });
     3551
     3552            // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh.
     3553            previewer.bind( 'url', function( url ) {
     3554                var onUrlChange, urlChanged = false;
     3555                previewer.scroll = 0;
     3556                onUrlChange = function() {
     3557                    urlChanged = true;
     3558                };
     3559                previewer.previewUrl.bind( onUrlChange );
     3560                previewer.previewUrl.set( url );
     3561                previewer.previewUrl.unbind( onUrlChange );
     3562                if ( ! urlChanged ) {
     3563                    previewer.refresh();
     3564                }
     3565            } );
    33293566
    33303567            // Update the document title when the preview changes.
    3331             this.bind( 'documentTitle', function ( title ) {
     3568            previewer.bind( 'documentTitle', function ( title ) {
    33323569                api.setDocumentTitle( title );
    33333570            } );
     3571        },
     3572
     3573        /**
     3574         * Handle the preview receiving the ready message.
     3575         *
     3576         * @since 4.7.0
     3577         *
     3578         * @param {object} data - Data from preview.
     3579         * @param {string} data.currentUrl - Current URL.
     3580         * @param {object} data.activePanels - Active panels.
     3581         * @param {object} data.activeSections Active sections.
     3582         * @param {object} data.activeControls Active controls.
     3583         * @returns {void}
     3584         */
     3585        ready: function( data ) {
     3586            var previewer = this, synced = {}, constructs;
     3587
     3588            synced.settings = api.get();
     3589            if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) {
     3590                synced.scroll = previewer.scroll;
     3591            }
     3592            previewer.send( 'sync', synced );
     3593
     3594            // Set the previewUrl without causing the url to set the iframe.
     3595            if ( data.currentUrl ) {
     3596                previewer.previewUrl.unbind( previewer.refresh );
     3597                previewer.previewUrl.set( data.currentUrl );
     3598                previewer.previewUrl.bind( previewer.refresh );
     3599            }
     3600
     3601            /*
     3602             * Walk over all panels, sections, and controls and set their
     3603             * respective active states to true if the preview explicitly
     3604             * indicates as such.
     3605             */
     3606            constructs = {
     3607                panel: data.activePanels,
     3608                section: data.activeSections,
     3609                control: data.activeControls
     3610            };
     3611            _( constructs ).each( function ( activeConstructs, type ) {
     3612                api[ type ].each( function ( construct, id ) {
     3613                    var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
     3614
     3615                    /*
     3616                     * If the construct was created statically in PHP (not dynamically in JS)
     3617                     * then consider a missing (undefined) value in the activeConstructs to
     3618                     * mean it should be deactivated (since it is gone). But if it is
     3619                     * dynamically created then only toggle activation if the value is defined,
     3620                     * as this means that the construct was also then correspondingly
     3621                     * created statically in PHP and the active callback is available.
     3622                     * Otherwise, dynamically-created constructs should normally have
     3623                     * their active states toggled in JS rather than from PHP.
     3624                     */
     3625                    if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
     3626                        if ( activeConstructs[ id ] ) {
     3627                            construct.activate();
     3628                        } else {
     3629                            construct.deactivate();
     3630                        }
     3631                    }
     3632                } );
     3633            } );
     3634
     3635            if ( data.settingValidities ) {
     3636                api._handleSettingValidities( {
     3637                    settingValidities: data.settingValidities,
     3638                    focusInvalidControl: false
     3639                } );
     3640            }
     3641        },
     3642
     3643        /**
     3644         * Keep the preview alive by listening for ready and keep-alive messages.
     3645         *
     3646         * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL.
     3647         *
     3648         * @since 4.7.0
     3649         *
     3650         * @returns {void}
     3651         */
     3652        keepPreviewAlive: function keepPreviewAlive() {
     3653            var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck;
     3654
     3655            /**
     3656             * Schedule a preview keep-alive check.
     3657             *
     3658             * Note that if a page load takes longer than keepAliveCheck milliseconds,
     3659             * the keep-alive messages will still be getting sent from the previous
     3660             * URL.
     3661             */
     3662            scheduleKeepAliveCheck = function() {
     3663                timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck );
     3664            };
     3665
     3666            /**
     3667             * Set the previewerAlive state to true when receiving a message from the preview.
     3668             */
     3669            keepAliveTick = function() {
     3670                api.state( 'previewerAlive' ).set( true );
     3671                clearTimeout( timeoutId );
     3672                scheduleKeepAliveCheck();
     3673            };
     3674
     3675            /**
     3676             * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message.
     3677             *
     3678             * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser
     3679             * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage
     3680             * transport to use refresh instead, causing the preview frame also to be replaced with the current
     3681             * allowed preview URL.
     3682             */
     3683            handleMissingKeepAlive = function() {
     3684                api.state( 'previewerAlive' ).set( false );
     3685            };
     3686            scheduleKeepAliveCheck();
     3687
     3688            previewer.bind( 'ready', keepAliveTick );
     3689            previewer.bind( 'keep-alive', keepAliveTick );
    33343690        },
    33353691
     
    33493705
    33503706        /**
    3351          * Refresh the preview.
     3707         * Refresh the preview seamlessly.
    33523708         */
    33533709        refresh: function() {
    3354             var self = this;
     3710            var previewer = this;
    33553711
    33563712            // Display loading indicator
    3357             this.send( 'loading-initiated' );
    3358 
    3359             this.abort();
    3360 
    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;
    3376 
    3377                     self.targetWindow( this.targetWindow() );
    3378                     self.channel( this.channel() );
    3379 
    3380                     self.deferred.active.resolve();
    3381                     self.send( 'active' );
    3382                 });
    3383 
    3384                 this.send( 'sync', {
    3385                     scroll:   self.scroll,
    3386                     settings: api.get()
    3387                 });
    3388             });
    3389 
    3390             this.loading.fail( function( reason, location ) {
    3391                 self.send( 'loading-failed' );
    3392                 if ( 'redirect' === reason && location ) {
    3393                     self.previewUrl( location );
    3394                 }
     3713            previewer.send( 'loading-initiated' );
     3714
     3715            previewer.abort();
     3716
     3717            previewer.loading = new api.PreviewFrame({
     3718                url:        previewer.url(),
     3719                previewUrl: previewer.previewUrl(),
     3720                query:      previewer.query( { excludeCustomizedSaved: true } ) || {},
     3721                container:  previewer.container
     3722            });
     3723
     3724            previewer.loading.done( function( readyData ) {
     3725                var loadingFrame = this, previousPreview, onceSynced;
     3726
     3727                previousPreview = previewer.preview;
     3728                previewer.preview = loadingFrame;
     3729                previewer.targetWindow( loadingFrame.targetWindow() );
     3730                previewer.channel( loadingFrame.channel() );
     3731
     3732                onceSynced = function() {
     3733                    loadingFrame.unbind( 'synced', onceSynced );
     3734                    if ( previousPreview ) {
     3735                        previousPreview.destroy();
     3736                    }
     3737                    previewer.deferred.active.resolve();
     3738                    delete previewer.loading;
     3739                };
     3740                loadingFrame.bind( 'synced', onceSynced );
     3741
     3742                // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh.
     3743                previewer.trigger( 'ready', readyData );
     3744            });
     3745
     3746            previewer.loading.fail( function( reason ) {
     3747                previewer.send( 'loading-failed' );
    33953748
    33963749                if ( 'logged out' === reason ) {
    3397                     if ( self.preview ) {
    3398                         self.preview.destroy();
    3399                         delete self.preview;
     3750                    if ( previewer.preview ) {
     3751                        previewer.preview.destroy();
     3752                        delete previewer.preview;
    34003753                    }
    34013754
    3402                     self.login().done( self.refresh );
     3755                    previewer.login().done( previewer.refresh );
    34033756                }
    34043757
    34053758                if ( 'cheatin' === reason ) {
    3406                     self.cheatin();
     3759                    previewer.cheatin();
    34073760                }
    34083761            });
     
    34643817            request = wp.ajax.post( 'customize_refresh_nonces', {
    34653818                wp_customize: 'on',
    3466                 theme: api.settings.theme.stylesheet
     3819                customize_theme: api.settings.theme.stylesheet
    34673820            });
    34683821
     
    36804033        }
    36814034
     4035        if ( null === api.PreviewFrame.prototype.sensitivity ) {
     4036            api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity;
     4037        }
     4038        if ( null === api.Previewer.prototype.refreshBuffer ) {
     4039            api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh;
     4040        }
     4041
    36824042        var parent,
    36834043            body = $( document.body ),
     
    37234083            form:        '#customize-controls',
    37244084            previewUrl:  api.settings.url.preview,
    3725             allowedUrls: api.settings.url.allowed,
    3726             signature:   'WP_CUSTOMIZER_SIGNATURE'
     4085            allowedUrls: api.settings.url.allowed
    37274086        }, {
    37284087
     
    37324091             * Build the query to send along with the Preview request.
    37334092             *
    3734              * @return {object}
     4093             * @since 4.7.0 Added options param.
     4094             *
     4095             * @param {object}  [options] Options.
     4096             * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset).
     4097             * @return {object} Query vars.
    37354098             */
    3736             query: function() {
    3737                 var dirtyCustomized = {};
    3738                 api.each( function ( value, key ) {
    3739                     if ( value._dirty ) {
    3740                         dirtyCustomized[ key ] = value();
    3741                     }
    3742                 } );
    3743 
    3744                 return {
     4099            query: function( options ) {
     4100                var queryVars = {
    37454101                    wp_customize: 'on',
    3746                     theme:      api.settings.theme.stylesheet,
    3747                     customized: JSON.stringify( dirtyCustomized ),
    3748                     nonce:      this.nonce.preview
     4102                    customize_theme: api.settings.theme.stylesheet,
     4103                    nonce: this.nonce.preview,
     4104                    customize_changeset_uuid: api.settings.changeset.uuid
    37494105                };
     4106
     4107                /*
     4108                 * Exclude customized data if requested especially for calls to requestChangesetUpdate.
     4109                 * Changeset updates are differential and so it is a performance waste to send all of
     4110                 * the dirty settings with each update.
     4111                 */
     4112                queryVars.customized = JSON.stringify( api.dirtyValues( {
     4113                    unsaved: options && options.excludeCustomizedSaved
     4114                } ) );
     4115
     4116                return queryVars;
    37504117            },
    37514118
    3752             save: function() {
    3753                 var self = this,
     4119            /**
     4120             * Save (and publish) the customizer changeset.
     4121             *
     4122             * Updates to the changeset are transactional. If any of the settings
     4123             * are invalid then none of them will be written into the changeset.
     4124             * A revision will be made for the changeset post if revisions support
     4125             * has been added to the post type.
     4126             *
     4127             * @param {object} [args] Args.
     4128             * @param {string} [args.status=publish] Status.
     4129             * @param {string} [args.date] Date, in local time in MySQL format.
     4130             * @param {string} [args.title] Title
     4131             *
     4132             * @returns {jQuery.promise}
     4133             */
     4134            save: function( args ) {
     4135                var previewer = this,
     4136                    deferred = $.Deferred(),
     4137                    changesetStatus = 'publish',
    37544138                    processing = api.state( 'processing' ),
    37554139                    submitWhenDoneProcessing,
     
    37594143                    invalidControls;
    37604144
    3761                 body.addClass( 'saving' );
     4145                if ( args && args.status ) {
     4146                    changesetStatus = args.status;
     4147                }
     4148
     4149                if ( api.state( 'saving' ).get() ) {
     4150                    deferred.reject( 'already_saving' );
     4151                    deferred.promise();
     4152                }
     4153
     4154                api.state( 'saving' ).set( true );
    37624155
    37634156                function captureSettingModifiedDuringSave( setting ) {
     
    37674160
    37684161                submit = function () {
    3769                     var request, query;
     4162                    var request, query, settingInvalidities = {};
    37704163
    37714164                    /*
     
    37784171                            if ( 'error' === notification.type && ! notification.fromServer ) {
    37794172                                invalidSettings.push( setting.id );
     4173                                if ( ! settingInvalidities[ setting.id ] ) {
     4174                                    settingInvalidities[ setting.id ] = {};
     4175                                }
     4176                                settingInvalidities[ setting.id ][ notification.code ] = notification;
    37804177                            }
    37814178                        } );
     
    37844181                    if ( ! _.isEmpty( invalidControls ) ) {
    37854182                        _.values( invalidControls )[0][0].focus();
    3786                         body.removeClass( 'saving' );
    37874183                        api.unbind( 'change', captureSettingModifiedDuringSave );
    3788                         return;
     4184                        deferred.rejectWith( previewer, [
     4185                            { setting_invalidities: settingInvalidities }
     4186                        ] );
     4187                        api.state( 'saving' ).set( false );
     4188                        return deferred.promise();
    37894189                    }
    37904190
    3791                     query = $.extend( self.query(), {
    3792                         nonce:  self.nonce.save
     4191                    /*
     4192                     * Note that excludeCustomizedSaved is intentionally false so that the entire
     4193                     * set of customized data will be included if bypassed changeset update.
     4194                     */
     4195                    query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), {
     4196                        nonce: previewer.nonce.save,
     4197                        customize_changeset_status: changesetStatus
    37934198                    } );
     4199                    if ( args && args.date ) {
     4200                        query.customize_changeset_date = args.date;
     4201                    }
     4202                    if ( args && args.title ) {
     4203                        query.customize_changeset_title = args.title;
     4204                    }
     4205
     4206                    /*
     4207                     * Note that the dirty customized values will have already been set in the
     4208                     * changeset and so technically query.customized could be deleted. However,
     4209                     * it is remaining here to make sure that any settings that got updated
     4210                     * quietly which may have not triggered an update request will also get
     4211                     * included in the values that get saved to the changeset. This will ensure
     4212                     * that values that get injected via the saved event will be included in
     4213                     * the changeset. This also ensures that setting values that were invalid
     4214                     * will get re-validated, perhaps in the case of settings that are invalid
     4215                     * due to dependencies on other settings.
     4216                     */
    37944217                    request = wp.ajax.post( 'customize_save', query );
    37954218
     
    38004223
    38014224                    request.always( function () {
    3802                         body.removeClass( 'saving' );
     4225                        api.state( 'saving' ).set( false );
    38034226                        saveBtn.prop( 'disabled', false );
    38044227                        api.unbind( 'change', captureSettingModifiedDuringSave );
     
    38064229
    38074230                    request.fail( function ( response ) {
     4231
    38084232                        if ( '0' === response ) {
    38094233                            response = 'not_logged_in';
     
    38144238
    38154239                        if ( 'invalid_nonce' === response ) {
    3816                             self.cheatin();
     4240                            previewer.cheatin();
    38174241                        } else if ( 'not_logged_in' === response ) {
    3818                             self.preview.iframe.hide();
    3819                             self.login().done( function() {
    3820                                 self.save();
    3821                                 self.preview.iframe.show();
     4242                            previewer.preview.iframe.hide();
     4243                            previewer.login().done( function() {
     4244                                previewer.save();
     4245                                previewer.preview.iframe.show();
    38224246                            } );
    38234247                        }
     
    38304254                        }
    38314255
     4256                        deferred.rejectWith( previewer, [ response ] );
    38324257                        api.trigger( 'error', response );
    38334258                    } );
     
    38354260                    request.done( function( response ) {
    38364261
    3837                         // Clear setting dirty states, if setting wasn't modified while saving.
    3838                         api.each( function( setting ) {
    3839                             if ( ! modifiedWhileSaving[ setting.id ] ) {
    3840                                 setting._dirty = false;
    3841                             }
    3842                         } );
    3843 
    3844                         api.previewer.send( 'saved', response );
     4262                        previewer.send( 'saved', response );
     4263
     4264                        api.state( 'changesetStatus' ).set( response.changeset_status );
     4265                        if ( 'publish' === response.changeset_status ) {
     4266                            api.state( 'changesetStatus' ).set( '' );
     4267                            api.settings.changeset.uuid = response.next_changeset_uuid;
     4268                            parent.send( 'changeset-uuid', api.settings.changeset.uuid );
     4269                        }
    38454270
    38464271                        if ( response.setting_validities ) {
     
    38514276                        }
    38524277
     4278                        deferred.resolveWith( previewer, [ response ] );
    38534279                        api.trigger( 'saved', response );
    38544280
     
    38724298                }
    38734299
     4300                return deferred.promise();
    38744301            }
    38754302        });
     
    39584385        api.bind( 'ready', api.reflowPaneContents );
    39594386        $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) {
    3960             var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, 100 );
     4387            var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents );
    39614388            values.bind( 'add', debouncedReflowPaneContents );
    39624389            values.bind( 'change', debouncedReflowPaneContents );
    39634390            values.bind( 'remove', debouncedReflowPaneContents );
    39644391        } );
     4392
     4393        // Save and activated states
     4394        (function() {
     4395            var state = new api.Values(),
     4396                saved = state.create( 'saved' ),
     4397                saving = state.create( 'saving' ),
     4398                activated = state.create( 'activated' ),
     4399                processing = state.create( 'processing' ),
     4400                paneVisible = state.create( 'paneVisible' ),
     4401                changesetStatus = state.create( 'changesetStatus' ),
     4402                previewerAlive = state.create( 'previewerAlive' ),
     4403                populateChangesetUuidParam;
     4404
     4405            state.bind( 'change', function() {
     4406                var canSave;
     4407
     4408                if ( ! activated() ) {
     4409                    saveBtn.val( api.l10n.activate );
     4410                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
     4411
     4412                } else if ( '' === changesetStatus.get() && saved() ) {
     4413                    saveBtn.val( api.l10n.saved );
     4414                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
     4415
     4416                } else {
     4417                    saveBtn.val( api.l10n.save );
     4418                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
     4419                }
     4420
     4421                /*
     4422                 * Save (publish) button should be enabled if saving is not currently happening,
     4423                 * and if the theme is not active or the changeset exists but is not published.
     4424                 */
     4425                canSave = ! saving() && ( ! activated() || ! saved() || ( '' !== changesetStatus() && 'publish' !== changesetStatus() ) );
     4426
     4427                saveBtn.prop( 'disabled', ! canSave );
     4428            });
     4429
     4430            // Set default states.
     4431            saved( true );
     4432            saving( false );
     4433            activated( api.settings.theme.active );
     4434            processing( 0 );
     4435            paneVisible( true );
     4436            previewerAlive( true );
     4437            changesetStatus( api.settings.changeset.status );
     4438
     4439            api.bind( 'change', function() {
     4440                state('saved').set( false );
     4441            });
     4442
     4443            saving.bind( function( isSaving ) {
     4444                body.toggleClass( 'saving', isSaving );
     4445            } );
     4446
     4447            api.bind( 'saved', function( response ) {
     4448                state('saved').set( true );
     4449                if ( 'publish' === response.changeset_status ) {
     4450                    state( 'activated' ).set( true );
     4451                }
     4452            });
     4453
     4454            activated.bind( function( to ) {
     4455                if ( to ) {
     4456                    api.trigger( 'activated' );
     4457                }
     4458            });
     4459
     4460            populateChangesetUuidParam = function( isIncluded ) {
     4461                var urlParser, queryParams;
     4462                urlParser = document.createElement( 'a' );
     4463                urlParser.href = location.href;
     4464                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     4465                if ( isIncluded ) {
     4466                    if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) {
     4467                        return;
     4468                    }
     4469                    queryParams.changeset_uuid = api.settings.changeset.uuid;
     4470                } else {
     4471                    if ( ! queryParams.changeset_uuid ) {
     4472                        return;
     4473                    }
     4474                    delete queryParams.changeset_uuid;
     4475                }
     4476                urlParser.search = $.param( queryParams );
     4477                history.replaceState( {}, document.title, urlParser.href );
     4478            };
     4479
     4480            if ( history.replaceState ) {
     4481                saved.bind( function( isSaved ) {
     4482                    if ( ! isSaved ) {
     4483                        populateChangesetUuidParam( true );
     4484                    }
     4485                } );
     4486                changesetStatus.bind( function( newStatus ) {
     4487                    populateChangesetUuidParam( '' !== newStatus );
     4488                } );
     4489            }
     4490
     4491            // Expose states to the API.
     4492            api.state = state;
     4493        }());
    39654494
    39664495        // Check if preview url is valid and load the preview frame.
     
    39704499            api.previewer.previewUrl( api.settings.url.home );
    39714500        }
    3972 
    3973         // Save and activated states
    3974         (function() {
    3975             var state = new api.Values(),
    3976                 saved = state.create( 'saved' ),
    3977                 activated = state.create( 'activated' ),
    3978                 processing = state.create( 'processing' ),
    3979                 paneVisible = state.create( 'paneVisible' );
    3980 
    3981             state.bind( 'change', function() {
    3982                 if ( ! activated() ) {
    3983                     saveBtn.val( api.l10n.activate ).prop( 'disabled', false );
    3984                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    3985 
    3986                 } else if ( saved() ) {
    3987                     saveBtn.val( api.l10n.saved ).prop( 'disabled', true );
    3988                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
    3989 
    3990                 } else {
    3991                     saveBtn.val( api.l10n.save ).prop( 'disabled', false );
    3992                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    3993                 }
    3994             });
    3995 
    3996             // Set default states.
    3997             saved( true );
    3998             activated( api.settings.theme.active );
    3999             processing( 0 );
    4000             paneVisible( true );
    4001 
    4002             api.bind( 'change', function() {
    4003                 state('saved').set( false );
    4004             });
    4005 
    4006             api.bind( 'saved', function() {
    4007                 state('saved').set( true );
    4008                 state('activated').set( true );
    4009             });
    4010 
    4011             activated.bind( function( to ) {
    4012                 if ( to ) {
    4013                     api.trigger( 'activated' );
    4014                 }
    4015             });
    4016 
    4017             // Expose states to the API.
    4018             api.state = state;
    4019         }());
    40204501
    40214502        // Button bindings.
     
    41704651
    41714652        // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
    4172         $( window ).on( 'beforeunload', function () {
     4653        $( window ).on( 'beforeunload.customize-confirm', function () {
    41734654            if ( ! api.state( 'saved' )() ) {
    41744655                setTimeout( function() {
     
    41904671            parent.send( 'title', newTitle );
    41914672        });
     4673
     4674        parent.send( 'changeset-uuid', api.settings.changeset.uuid );
    41924675
    41934676        // Initialize the connection with the parent frame.
     
    42934776        });
    42944777
     4778        // Autosave changeset.
     4779        ( function() {
     4780            var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false;
     4781
     4782            /**
     4783             * Request changeset update and then re-schedule the next changeset update time.
     4784             *
     4785             * @private
     4786             */
     4787            updateChangesetWithReschedule = function() {
     4788                if ( ! updatePending ) {
     4789                    updatePending = true;
     4790                    api.requestChangesetUpdate().always( function() {
     4791                        updatePending = false;
     4792                    } );
     4793                }
     4794                scheduleChangesetUpdate();
     4795            };
     4796
     4797            /**
     4798             * Schedule changeset update.
     4799             *
     4800             * @private
     4801             */
     4802            scheduleChangesetUpdate = function() {
     4803                clearTimeout( timeoutId );
     4804                timeoutId = setTimeout( function() {
     4805                    updateChangesetWithReschedule();
     4806                }, api.settings.timeouts.changesetAutoSave );
     4807            };
     4808
     4809            // Start auto-save interval for updating changeset.
     4810            scheduleChangesetUpdate();
     4811
     4812            // Save changeset when focus removed from window.
     4813            $( window ).on( 'blur.wp-customize-changeset-update', function() {
     4814                updateChangesetWithReschedule();
     4815            } );
     4816
     4817            // Save changeset before unloading window.
     4818            $( window ).on( 'beforeunload.wp-customize-changeset-update', function() {
     4819                updateChangesetWithReschedule();
     4820            } );
     4821        } ());
     4822
    42954823        api.trigger( 'ready' );
    42964824    });
  • trunk/src/wp-admin/js/customize-widgets.js

    r38709 r38810  
    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
  • trunk/src/wp-includes/admin-bar.php

    r38708 r38810  
    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() ) {
     
    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(
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r38765 r38810  
    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     *
     
    194185
    195186    /**
     187     * Messenger channel.
     188     *
     189     * @since 4.7.0
     190     * @access protected
     191     * @var string
     192     */
     193    protected $messenger_channel;
     194
     195    /**
    196196     * Unsanitized values for Customize Settings parsed from $_POST['customized'].
    197197     *
     
    201201
    202202    /**
     203     * Changeset UUID.
     204     *
     205     * @since 4.7.0
     206     * @access private
     207     * @var string
     208     */
     209    private $_changeset_uuid;
     210
     211    /**
     212     * Changeset post ID.
     213     *
     214     * @since 4.7.0
     215     * @access private
     216     * @var int|false
     217     */
     218    private $_changeset_post_id;
     219
     220    /**
     221     * Changeset data loaded from a customize_changeset post.
     222     *
     223     * @since 4.7.0
     224     * @access private
     225     * @var array
     226     */
     227    private $_changeset_data;
     228
     229    /**
    203230     * Constructor.
    204231     *
    205232     * @since 3.4.0
    206      */
    207     public function __construct() {
     233     * @since 4.7.0 Added $args param.
     234     *
     235     * @param array $args {
     236     *     Args.
     237     *
     238     *     @type string $changeset_uuid    Changeset UUID, the post_name for the customize_changeset post containing the customized state. Defaults to new UUID.
     239     *     @type string $theme             Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params.
     240     *     @type string $messenger_channel Messenger channel. Defaults to customize_messenger_channel query param.
     241     * }
     242     */
     243    public function __construct( $args = array() ) {
     244
     245        $args = array_merge(
     246            array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel' ), null ),
     247            $args
     248        );
     249
     250        // Note that the UUID format will be validated in the setup_theme() method.
     251        if ( ! isset( $args['changeset_uuid'] ) ) {
     252            $args['changeset_uuid'] = wp_generate_uuid4();
     253        }
     254
     255        // The theme and messenger_channel should be supplied via $args, but they are also looked at in the $_REQUEST global here for back-compat.
     256        if ( ! isset( $args['theme'] ) ) {
     257            if ( isset( $_REQUEST['customize_theme'] ) ) {
     258                $args['theme'] = wp_unslash( $_REQUEST['customize_theme'] );
     259            } elseif ( isset( $_REQUEST['theme'] ) ) { // Deprecated.
     260                $args['theme'] = wp_unslash( $_REQUEST['theme'] );
     261            }
     262        }
     263        if ( ! isset( $args['messenger_channel'] ) && isset( $_REQUEST['customize_messenger_channel'] ) ) {
     264            $args['messenger_channel'] = sanitize_key( wp_unslash( $_REQUEST['customize_messenger_channel'] ) );
     265        }
     266
     267        $this->original_stylesheet = get_stylesheet();
     268        $this->theme = wp_get_theme( $args['theme'] );
     269        $this->messenger_channel = $args['messenger_channel'];
     270        $this->_changeset_uuid = $args['changeset_uuid'];
     271
    208272        require_once( ABSPATH . WPINC . '/class-wp-customize-setting.php' );
    209273        require_once( ABSPATH . WPINC . '/class-wp-customize-panel.php' );
     
    272336        }
    273337
    274         add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) );
    275 
    276338        add_action( 'setup_theme', array( $this, 'setup_theme' ) );
    277339        add_action( 'wp_loaded',   array( $this, 'wp_loaded' ) );
    278 
    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 );
    281340
    282341        // Do not spawn cron (especially the alternate cron) while running the Customizer.
     
    341400     */
    342401    protected function wp_die( $ajax_message, $message = null ) {
    343         if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
     402        if ( $this->doing_ajax() ) {
    344403            wp_die( $ajax_message );
    345404        }
     
    349408        }
    350409
     410        if ( $this->messenger_channel ) {
     411            ob_start();
     412            wp_enqueue_scripts();
     413            wp_print_scripts( array( 'customize-base' ) );
     414
     415            $settings = array(
     416                'messengerArgs' => array(
     417                    'channel' => $this->messenger_channel,
     418                    'url' => wp_customize_url(),
     419                ),
     420                'error' => $ajax_message,
     421            );
     422            ?>
     423            <script>
     424            ( function( api, settings ) {
     425                var preview = new api.Messenger( settings.messengerArgs );
     426                preview.send( 'iframe-loading-error', settings.error );
     427            } )( wp.customize, <?php echo wp_json_encode( $settings ) ?> );
     428            </script>
     429            <?php
     430            $message .= ob_get_clean();
     431        }
     432
    351433        wp_die( $message );
    352434    }
     
    356438     *
    357439     * @since 3.4.0
    358      *
    359      * @return string
     440     * @deprecated 4.7.0
     441     *
     442     * @return callable Die handler.
    360443     */
    361444    public function wp_die_handler() {
     445        _deprecated_function( __METHOD__, '4.7.0' );
     446
    362447        if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
    363448            return '_ajax_wp_die_handler';
     
    375460     */
    376461    public function setup_theme() {
    377         send_origin_headers();
    378 
    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.' ) );
    384         }
    385 
    386         show_admin_bar( false );
    387 
    388         if ( ! current_user_can( 'customize' ) ) {
    389             $this->wp_die( -1, __( 'Sorry, you are not allowed to customize this site.' ) );
    390         }
    391 
    392         $this->original_stylesheet = get_stylesheet();
    393 
    394         $this->theme = wp_get_theme( isset( $_REQUEST['theme'] ) ? $_REQUEST['theme'] : null );
     462        global $pagenow;
     463
     464        // Check permissions for customize.php access since this method is called before customize.php can run any code,
     465        if ( 'customize.php' === $pagenow && ! current_user_can( 'customize' ) ) {
     466            if ( ! is_user_logged_in() ) {
     467                auth_redirect();
     468            } else {
     469                wp_die(
     470                    '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     471                    '<p>' . __( 'Sorry, you are not allowed to customize this site.' ) . '</p>',
     472                    403
     473                );
     474            }
     475            return;
     476        }
     477
     478        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 ) ) {
     479            $this->wp_die( -1, __( 'Invalid changeset UUID' ) );
     480        }
     481
     482        /*
     483         * If unauthenticated then require a valid changeset UUID to load the preview.
     484         * In this way, the UUID serves as a secret key. If the messenger channel is present,
     485         * then send unauthenticated code to prompt re-auth.
     486         */
     487        if ( ! current_user_can( 'customize' ) && ! $this->changeset_post_id() ) {
     488            $this->wp_die( $this->messenger_channel ? 0 : -1, __( 'Non-existent changeset UUID.' ) );
     489        }
     490
     491        if ( ! headers_sent() ) {
     492            send_origin_headers();
     493        }
     494
     495        // Hide the admin bar if we're embedded in the customizer iframe.
     496        if ( $this->messenger_channel ) {
     497            show_admin_bar( false );
     498        }
    395499
    396500        if ( $this->is_theme_active() ) {
     
    508612
    509613    /**
     614     * Get the changeset UUID.
     615     *
     616     * @since 4.7.0
     617     * @access public
     618     *
     619     * @return string UUID.
     620     */
     621    public function changeset_uuid() {
     622        return $this->_changeset_uuid;
     623    }
     624
     625    /**
    510626     * Get the theme being customized.
    511627     *
     
    604720        do_action( 'customize_register', $this );
    605721
    606         if ( $this->is_preview() && ! is_admin() )
     722        /*
     723         * Note that settings must be previewed here even outside the customizer preview
     724         * and also in the customizer pane itself. This is to enable loading an existing
     725         * changeset into the customizer. Previewing the settings only has to be prevented
     726         * in the case of a customize_save action because then update_option()
     727         * may short-circuit because it will detect that there are no changes to
     728         * make.
     729         */
     730        if ( ! $this->doing_ajax( 'customize_save' ) ) {
     731            foreach ( $this->settings as $setting ) {
     732                $setting->preview();
     733            }
     734        }
     735
     736        if ( $this->is_preview() && ! is_admin() ) {
    607737            $this->customize_preview_init();
     738        }
    608739    }
    609740
     
    615746     *
    616747     * @since 3.4.0
    617      *
    618      * @param $status
     748     * @deprecated 4.7.0
     749     *
     750     * @param int $status Status.
    619751     * @return int
    620752     */
    621753    public function wp_redirect_status( $status ) {
    622         if ( $this->is_preview() && ! is_admin() )
     754        _deprecated_function( __FUNCTION__, '4.7.0' );
     755
     756        if ( $this->is_preview() && ! is_admin() ) {
    623757            return 200;
     758        }
    624759
    625760        return $status;
     
    627762
    628763    /**
    629      * Parse the incoming $_POST['customized'] JSON data and store the unsanitized
    630      * settings for subsequent post_value() lookups.
     764     * Find the changeset post ID for a given changeset UUID.
     765     *
     766     * @since 4.7.0
     767     * @access public
     768     *
     769     * @param string $uuid Changeset UUID.
     770     * @return int|null Returns post ID on success and null on failure.
     771     */
     772    public function find_changeset_post_id( $uuid ) {
     773        $cache_group = 'customize_changeset_post';
     774        $changeset_post_id = wp_cache_get( $uuid, $cache_group );
     775        if ( $changeset_post_id && 'customize_changeset' === get_post_type( $changeset_post_id ) ) {
     776            return $changeset_post_id;
     777        }
     778
     779        $changeset_post_query = new WP_Query( array(
     780            'post_type' => 'customize_changeset',
     781            'post_status' => get_post_stati(),
     782            'name' => $uuid,
     783            'number' => 1,
     784            'no_found_rows' => true,
     785            'cache_results' => true,
     786            'update_post_meta_cache' => false,
     787            'update_term_meta_cache' => false,
     788        ) );
     789        if ( ! empty( $changeset_post_query->posts ) ) {
     790            // Note: 'fields'=>'ids' is not being used in order to cache the post object as it will be needed.
     791            $changeset_post_id = $changeset_post_query->posts[0]->ID;
     792            wp_cache_set( $this->_changeset_uuid, $changeset_post_id, $cache_group );
     793            return $changeset_post_id;
     794        }
     795
     796        return null;
     797    }
     798
     799    /**
     800     * Get the changeset post id for the loaded changeset.
     801     *
     802     * @since 4.7.0
     803     * @access public
     804     *
     805     * @return int|null Post ID on success or null if there is no post yet saved.
     806     */
     807    public function changeset_post_id() {
     808        if ( ! isset( $this->_changeset_post_id ) ) {
     809            $post_id = $this->find_changeset_post_id( $this->_changeset_uuid );
     810            if ( ! $post_id ) {
     811                $post_id = false;
     812            }
     813            $this->_changeset_post_id = $post_id;
     814        }
     815        if ( false === $this->_changeset_post_id ) {
     816            return null;
     817        }
     818        return $this->_changeset_post_id;
     819    }
     820
     821    /**
     822     * Get the data stored in a changeset post.
     823     *
     824     * @since 4.7.0
     825     * @access protected
     826     *
     827     * @param int $post_id Changeset post ID.
     828     * @return array|WP_Error Changeset data or WP_Error on error.
     829     */
     830    protected function get_changeset_post_data( $post_id ) {
     831        if ( ! $post_id ) {
     832            return new WP_Error( 'empty_post_id' );
     833        }
     834        $changeset_post = get_post( $post_id );
     835        if ( ! $changeset_post ) {
     836            return new WP_Error( 'missing_post' );
     837        }
     838        if ( 'customize_changeset' !== $changeset_post->post_type ) {
     839            return new WP_Error( 'wrong_post_type' );
     840        }
     841        $changeset_data = json_decode( $changeset_post->post_content, true );
     842        if ( function_exists( 'json_last_error' ) && json_last_error() ) {
     843            return new WP_Error( 'json_parse_error', '', json_last_error() );
     844        }
     845        if ( ! is_array( $changeset_data ) ) {
     846            return new WP_Error( 'expected_array' );
     847        }
     848        return $changeset_data;
     849    }
     850
     851    /**
     852     * Get changeset data.
     853     *
     854     * @since 4.7.0
     855     * @access public
     856     *
     857     * @return array Changeset data.
     858     */
     859    public function changeset_data() {
     860        if ( isset( $this->_changeset_data ) ) {
     861            return $this->_changeset_data;
     862        }
     863        $changeset_post_id = $this->changeset_post_id();
     864        if ( ! $changeset_post_id ) {
     865            $this->_changeset_data = array();
     866        } else {
     867            $data = $this->get_changeset_post_data( $changeset_post_id );
     868            if ( ! is_wp_error( $data ) ) {
     869                $this->_changeset_data = $data;
     870            } else {
     871                $this->_changeset_data = array();
     872            }
     873        }
     874        return $this->_changeset_data;
     875    }
     876
     877    /**
     878     * Get dirty pre-sanitized setting values in the current customized state.
     879     *
     880     * The returned array consists of a merge of three sources:
     881     * 1. If the theme is not currently active, then the base array is any stashed
     882     *    theme mods that were modified previously but never published.
     883     * 2. The values from the current changeset, if it exists.
     884     * 3. If the user can customize, the values parsed from the incoming
     885     *    `$_POST['customized']` JSON data.
     886     * 4. Any programmatically-set post values via `WP_Customize_Manager::set_post_value()`.
     887     *
     888     * The name "unsanitized_post_values" is a carry-over from when the customized
     889     * state was exclusively sourced from `$_POST['customized']`. Nevertheless,
     890     * the value returned will come from the current changeset post and from the
     891     * incoming post data.
    631892     *
    632893     * @since 4.1.1
    633      *
     894     * @since 4.7.0 Added $args param and merging with changeset values and stashed theme mods.
     895     *
     896     * @param array $args {
     897     *     Args.
     898     *
     899     *     @type bool $exclude_changeset Whether the changeset values should also be excluded. Defaults to false.
     900     *     @type bool $exclude_post_data Whether the post input values should also be excluded. Defaults to false when lacking the customize capability.
     901     * }
    634902     * @return array
    635903     */
    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 );
    640             }
    641             if ( empty( $this->_post_values ) ) { // if not isset or if JSON error
    642                 $this->_post_values = array();
    643             }
    644         }
    645         if ( empty( $this->_post_values ) ) {
    646             return array();
    647         } else {
    648             return $this->_post_values;
    649         }
    650     }
    651 
    652     /**
    653      * Returns the sanitized value for a given setting from the request's POST data.
     904    public function unsanitized_post_values( $args = array() ) {
     905        $args = array_merge(
     906            array(
     907                'exclude_changeset' => false,
     908                'exclude_post_data' => ! current_user_can( 'customize' ),
     909            ),
     910            $args
     911        );
     912
     913        $values = array();
     914
     915        // Let default values be from the stashed theme mods if doing a theme switch and if no changeset is present.
     916        if ( ! $this->is_theme_active() ) {
     917            $stashed_theme_mods = get_option( 'customize_stashed_theme_mods' );
     918            $stylesheet = $this->get_stylesheet();
     919            if ( isset( $stashed_theme_mods[ $stylesheet ] ) ) {
     920                $values = array_merge( $values, wp_list_pluck( $stashed_theme_mods[ $stylesheet ], 'value' ) );
     921            }
     922        }
     923
     924        if ( ! $args['exclude_changeset'] ) {
     925            foreach ( $this->changeset_data() as $setting_id => $setting_params ) {
     926                if ( ! array_key_exists( 'value', $setting_params ) ) {
     927                    continue;
     928                }
     929                if ( isset( $setting_params['type'] ) && 'theme_mod' === $setting_params['type'] ) {
     930
     931                    // Ensure that theme mods values are only used if they were saved under the current theme.
     932                    $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     933                    if ( preg_match( $namespace_pattern, $setting_id, $matches ) && $this->get_stylesheet() === $matches['stylesheet'] ) {
     934                        $values[ $matches['setting_id'] ] = $setting_params['value'];
     935                    }
     936                } else {
     937                    $values[ $setting_id ] = $setting_params['value'];
     938                }
     939            }
     940        }
     941
     942        if ( ! $args['exclude_post_data'] ) {
     943            if ( ! isset( $this->_post_values ) ) {
     944                if ( isset( $_POST['customized'] ) ) {
     945                    $post_values = json_decode( wp_unslash( $_POST['customized'] ), true );
     946                } else {
     947                    $post_values = array();
     948                }
     949                if ( is_array( $post_values ) ) {
     950                    $this->_post_values = $post_values;
     951                } else {
     952                    $this->_post_values = array();
     953                }
     954            }
     955            $values = array_merge( $values, $this->_post_values );
     956        }
     957        return $values;
     958    }
     959
     960    /**
     961     * Returns the sanitized value for a given setting from the current customized state.
     962     *
     963     * The name "post_value" is a carry-over from when the customized state was exclusively
     964     * sourced from `$_POST['customized']`. Nevertheless, the value returned will come
     965     * from the current changeset post and from the incoming post data.
    654966     *
    655967     * @since 3.4.0
     
    685997
    686998    /**
    687      * Override a setting's (unsanitized) value as found in any incoming $_POST['customized'].
     999     * Override a setting's value in the current customized state.
     1000     *
     1001     * The name "post_value" is a carry-over from when the customized state was
     1002     * exclusively sourced from `$_POST['customized']`.
    6881003     *
    6891004     * @since 4.2.0
     
    6941009     */
    6951010    public function set_post_value( $setting_id, $value ) {
    696         $this->unsanitized_post_values();
     1011        $this->unsanitized_post_values(); // Populate _post_values from $_POST['customized'].
    6971012        $this->_post_values[ $setting_id ] = $value;
    6981013
     
    7341049     */
    7351050    public function customize_preview_init() {
    736         $this->nonce_tick = check_ajax_referer( 'preview-customize_' . $this->get_stylesheet(), 'nonce' );
     1051
     1052        /*
     1053         * Now that Customizer previews are loaded into iframes via GET requests
     1054         * and natural URLs with transaction UUIDs added, we need to ensure that
     1055         * the responses are never cached by proxies. In practice, this will not
     1056         * be needed if the user is logged-in anyway. But if anonymous access is
     1057         * allowed then the auth cookies would not be sent and WordPress would
     1058         * not send no-cache headers by default.
     1059         */
     1060        if ( ! headers_sent() ) {
     1061            nocache_headers();
     1062            header( 'X-Robots: noindex, nofollow, noarchive' );
     1063        }
     1064        add_action( 'wp_head', 'wp_no_robots' );
     1065        add_filter( 'wp_headers', array( $this, 'filter_iframe_security_headers' ) );
     1066
     1067        /*
     1068         * If preview is being served inside the customizer preview iframe, and
     1069         * if the user doesn't have customize capability, then it is assumed
     1070         * that the user's session has expired and they need to re-authenticate.
     1071         */
     1072        if ( $this->messenger_channel && ! current_user_can( 'customize' ) ) {
     1073            $this->wp_die( -1, __( 'Unauthorized. You may remove the customize_messenger_channel param to preview as frontend.' ) );
     1074            return;
     1075        }
    7371076
    7381077        $this->prepare_controls();
    7391078
     1079        add_filter( 'wp_redirect', array( $this, 'add_state_query_params' ) );
     1080
    7401081        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' ) );
    7431082        add_action( 'wp_head', array( $this, 'customize_preview_loading_style' ) );
    7441083        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         }
    7511084
    7521085        /**
     
    7621095
    7631096    /**
     1097     * Filter the X-Frame-Options and Content-Security-Policy headers to ensure frontend can load in customizer.
     1098     *
     1099     * @since 4.7.0
     1100     * @access public
     1101     *
     1102     * @param array $headers Headers.
     1103     * @return array Headers.
     1104     */
     1105    public function filter_iframe_security_headers( $headers ) {
     1106        $customize_url = admin_url( 'customize.php' );
     1107        $headers['X-Frame-Options'] = 'ALLOW-FROM ' . $customize_url;
     1108        $headers['Content-Security-Policy'] = 'frame-ancestors ' . preg_replace( '#^(\w+://[^/]+).+?$#', '$1', $customize_url );
     1109        return $headers;
     1110    }
     1111
     1112    /**
     1113     * Add customize state query params to a given URL if preview is allowed.
     1114     *
     1115     * @since 4.7.0
     1116     * @access public
     1117     * @see wp_redirect()
     1118     * @see WP_Customize_Manager::get_allowed_url()
     1119     *
     1120     * @param string $url URL.
     1121     * @return string URL.
     1122     */
     1123    public function add_state_query_params( $url ) {
     1124        $parsed_original_url = wp_parse_url( $url );
     1125        $is_allowed = false;
     1126        foreach ( $this->get_allowed_urls() as $allowed_url ) {
     1127            $parsed_allowed_url = wp_parse_url( $allowed_url );
     1128            $is_allowed = (
     1129                $parsed_allowed_url['scheme'] === $parsed_original_url['scheme']
     1130                &&
     1131                $parsed_allowed_url['host'] === $parsed_original_url['host']
     1132                &&
     1133                0 === strpos( $parsed_original_url['path'], $parsed_allowed_url['path'] )
     1134            );
     1135            if ( $is_allowed ) {
     1136                break;
     1137            }
     1138        }
     1139
     1140        if ( $is_allowed ) {
     1141            $query_params = array(
     1142                'customize_changeset_uuid' => $this->changeset_uuid(),
     1143            );
     1144            if ( ! $this->is_theme_active() ) {
     1145                $query_params['customize_theme'] = $this->get_stylesheet();
     1146            }
     1147            if ( $this->messenger_channel ) {
     1148                $query_params['customize_messenger_channel'] = $this->messenger_channel;
     1149            }
     1150            $url = add_query_arg( $query_params, $url );
     1151        }
     1152
     1153        return $url;
     1154    }
     1155
     1156    /**
    7641157     * Prevent sending a 404 status when returning the response for the customize
    7651158     * preview, since it causes the jQuery Ajax to fail. Send 200 instead.
    7661159     *
    7671160     * @since 4.0.0
     1161     * @deprecated 4.7.0
    7681162     * @access public
    7691163     */
    7701164    public function customize_preview_override_404_status() {
    771         if ( is_404() ) {
    772             status_header( 200 );
    773         }
     1165        _deprecated_function( __METHOD__, '4.7.0' );
    7741166    }
    7751167
     
    7781170     *
    7791171     * @since 3.4.0
     1172     * @deprecated 4.7.0
    7801173     */
    7811174    public function customize_preview_base() {
    782         ?><base href="<?php echo home_url( '/' ); ?>" /><?php
     1175        _deprecated_function( __METHOD__, '4.7.0' );
    7831176    }
    7841177
     
    8101203                pointer-events: none !important;
    8111204            }
     1205            form.customize-unpreviewable,
     1206            form.customize-unpreviewable input,
     1207            form.customize-unpreviewable select,
     1208            form.customize-unpreviewable button,
     1209            a.customize-unpreviewable,
     1210            area.customize-unpreviewable {
     1211                cursor: not-allowed !important;
     1212            }
    8121213        </style><?php
    8131214    }
     
    8191220     */
    8201221    public function customize_preview_settings() {
    821         $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1222        $post_values = $this->unsanitized_post_values( array( 'exclude_changeset' => true ) );
     1223        $setting_validities = $this->validate_setting_values( $post_values );
    8221224        $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
    8231225
     1226        // Note that the REQUEST_URI is not passed into home_url() since this breaks subdirectory installs.
     1227        $self_url = empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
     1228        $state_query_params = array(
     1229            'customize_theme',
     1230            'customize_changeset_uuid',
     1231            'customize_messenger_channel',
     1232        );
     1233        $self_url = remove_query_arg( $state_query_params, $self_url );
     1234
     1235        $allowed_urls = $this->get_allowed_urls();
     1236        $allowed_hosts = array();
     1237        foreach ( $allowed_urls as $allowed_url ) {
     1238            $parsed = wp_parse_url( $allowed_url );
     1239            if ( empty( $parsed['host'] ) ) {
     1240                continue;
     1241            }
     1242            $host = $parsed['host'];
     1243            if ( ! empty( $parsed['port'] ) ) {
     1244                $host .= ':' . $parsed['port'];
     1245            }
     1246            $allowed_hosts[] = $host;
     1247        }
    8241248        $settings = array(
     1249            'changeset' => array(
     1250                'uuid' => $this->_changeset_uuid,
     1251            ),
     1252            'timeouts' => array(
     1253                'selectiveRefresh' => 250,
     1254                'keepAliveSend' => 1000,
     1255            ),
    8251256            'theme' => array(
    8261257                'stylesheet' => $this->get_stylesheet(),
     
    8281259            ),
    8291260            'url' => array(
    830                 'self' => empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ),
     1261                'self' => $self_url,
     1262                'allowed' => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     1263                'allowedHosts' => array_unique( $allowed_hosts ),
     1264                'isCrossDomain' => $this->is_cross_domain(),
    8311265            ),
    832             'channel' => wp_unslash( $_POST['customize_messenger_channel'] ),
     1266            'channel' => $this->messenger_channel,
    8331267            'activePanels' => array(),
    8341268            'activeSections' => array(),
    8351269            'activeControls' => array(),
    8361270            'settingValidities' => $exported_setting_validities,
    837             'nonce' => $this->get_nonces(),
     1271            'nonce' => current_user_can( 'customize' ) ? $this->get_nonces() : array(),
    8381272            'l10n' => array(
    8391273                'shiftClickToEdit' => __( 'Shift-click to edit this element.' ),
     1274                'linkUnpreviewable' => __( 'This link is not live-previewable.' ),
     1275                'formUnpreviewable' => __( 'This form is not live-previewable.' ),
    8401276            ),
    841             '_dirty' => array_keys( $this->unsanitized_post_values() ),
     1277            '_dirty' => array_keys( $post_values ),
    8421278        );
    8431279
     
    8931329     *
    8941330     * @since 3.4.0
     1331     * @deprecated 4.7.0
    8951332     */
    8961333    public function customize_preview_signature() {
    897         echo 'WP_CUSTOMIZER_SIGNATURE';
     1334        _deprecated_function( __METHOD__, '4.7.0' );
    8981335    }
    8991336
     
    9021339     *
    9031340     * @since 3.4.0
     1341     * @deprecated 4.7.0
    9041342     *
    9051343     * @param mixed $return Value passed through for {@see 'wp_die_handler'} filter.
     
    9071345     */
    9081346    public function remove_preview_signature( $return = null ) {
    909         remove_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 );
     1347        _deprecated_function( __METHOD__, '4.7.0' );
    9101348
    9111349        return $return;
     
    9941432     *
    9951433     * @param array $setting_values Mapping of setting IDs to values to validate and sanitize.
     1434     * @param array $options {
     1435     *     Options.
     1436     *
     1437     *     @type bool $validate_existence  Whether a setting's existence will be checked.
     1438     *     @type bool $validate_capability Whether the setting capability will be checked.
     1439     * }
    9961440     * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`.
    9971441     */
    998     public function validate_setting_values( $setting_values ) {
     1442    public function validate_setting_values( $setting_values, $options = array() ) {
     1443        $options = wp_parse_args( $options, array(
     1444            'validate_capability' => false,
     1445            'validate_existence' => false,
     1446        ) );
     1447
    9991448        $validities = array();
    10001449        foreach ( $setting_values as $setting_id => $unsanitized_value ) {
    10011450            $setting = $this->get_setting( $setting_id );
    1002             if ( ! $setting || is_null( $unsanitized_value ) ) {
     1451            if ( ! $setting ) {
     1452                if ( $options['validate_existence'] ) {
     1453                    $validities[ $setting_id ] = new WP_Error( 'unrecognized', __( 'Setting does not exist or is unrecognized.' ) );
     1454                }
    10031455                continue;
    10041456            }
    1005             $validity = $setting->validate( $unsanitized_value );
     1457            if ( is_null( $unsanitized_value ) ) {
     1458                continue;
     1459            }
     1460            if ( $options['validate_capability'] && ! current_user_can( $setting->capability ) ) {
     1461                $validity = new WP_Error( 'unauthorized', __( 'Unauthorized to modify setting due to capability.' ) );
     1462            } else {
     1463                $validity = $setting->validate( $unsanitized_value );
     1464            }
    10061465            if ( ! is_wp_error( $validity ) ) {
    10071466                /** This filter is documented in wp-includes/class-wp-customize-setting.php */
     
    10571516
    10581517    /**
    1059      * Switch the theme and trigger the save() method on each setting.
    1060      *
    1061      * @since 3.4.0
     1518     * Handle customize_save WP Ajax request to save/update a changeset.
     1519     *
     1520     * @since 3.4.0
     1521     * @since 4.7.0 The semantics of this method have changed to update a changeset, optionally to also change the status and other attributes.
    10621522     */
    10631523    public function save() {
     1524        if ( ! is_user_logged_in() ) {
     1525            wp_send_json_error( 'unauthenticated' );
     1526        }
     1527
    10641528        if ( ! $this->is_preview() ) {
    10651529            wp_send_json_error( 'not_preview' );
     
    10711535        }
    10721536
     1537        $changeset_post_id = $this->changeset_post_id();
     1538        if ( $changeset_post_id && in_array( get_post_status( $changeset_post_id ), array( 'publish', 'trash' ) ) ) {
     1539            wp_send_json_error( 'changeset_already_published' );
     1540        }
     1541
     1542        if ( empty( $changeset_post_id ) ) {
     1543            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->create_posts ) ) {
     1544                wp_send_json_error( 'cannot_create_changeset_post' );
     1545            }
     1546        } else {
     1547            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
     1548                wp_send_json_error( 'cannot_edit_changeset_post' );
     1549            }
     1550        }
     1551
     1552        if ( ! empty( $_POST['customize_changeset_data'] ) ) {
     1553            $input_changeset_data = json_decode( wp_unslash( $_POST['customize_changeset_data'] ), true );
     1554            if ( ! is_array( $input_changeset_data ) ) {
     1555                wp_send_json_error( 'invalid_customize_changeset_data' );
     1556            }
     1557        } else {
     1558            $input_changeset_data = array();
     1559        }
     1560
     1561        // Validate title.
     1562        $changeset_title = null;
     1563        if ( isset( $_POST['customize_changeset_title'] ) ) {
     1564            $changeset_title = sanitize_text_field( wp_unslash( $_POST['customize_changeset_title'] ) );
     1565        }
     1566
     1567        // Validate changeset status param.
     1568        $is_publish = null;
     1569        $changeset_status = null;
     1570        if ( isset( $_POST['customize_changeset_status'] ) ) {
     1571            $changeset_status = wp_unslash( $_POST['customize_changeset_status'] );
     1572            if ( ! get_post_status_object( $changeset_status ) || ! in_array( $changeset_status, array( 'draft', 'pending', 'publish', 'future' ), true ) ) {
     1573                wp_send_json_error( 'bad_customize_changeset_status', 400 );
     1574            }
     1575            $is_publish = ( 'publish' === $changeset_status || 'future' === $changeset_status );
     1576            if ( $is_publish ) {
     1577                if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     1578                    wp_send_json_error( 'changeset_publish_unauthorized', 403 );
     1579                }
     1580                if ( false === has_action( 'transition_post_status', '_wp_customize_publish_changeset' ) ) {
     1581                    wp_send_json_error( 'missing_publish_callback', 500 );
     1582                }
     1583            }
     1584        }
     1585
     1586        /*
     1587         * Validate changeset date param. Date is assumed to be in local time for
     1588         * the WP if in MySQL format (YYYY-MM-DD HH:MM:SS). Otherwise, the date
     1589         * is parsed with strtotime() so that ISO date format may be supplied
     1590         * or a string like "+10 minutes".
     1591         */
     1592        $changeset_date_gmt = null;
     1593        if ( isset( $_POST['customize_changeset_date'] ) ) {
     1594            $changeset_date = wp_unslash( $_POST['customize_changeset_date'] );
     1595            if ( preg_match( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $changeset_date ) ) {
     1596                $mm = substr( $changeset_date, 5, 2 );
     1597                $jj = substr( $changeset_date, 8, 2 );
     1598                $aa = substr( $changeset_date, 0, 4 );
     1599                $valid_date = wp_checkdate( $mm, $jj, $aa, $changeset_date );
     1600                if ( ! $valid_date ) {
     1601                    wp_send_json_error( 'bad_customize_changeset_date', 400 );
     1602                }
     1603                $changeset_date_gmt = get_gmt_from_date( $changeset_date );
     1604            } else {
     1605                $timestamp = strtotime( $changeset_date );
     1606                if ( ! $timestamp ) {
     1607                    wp_send_json_error( 'bad_customize_changeset_date', 400 );
     1608                }
     1609                $changeset_date_gmt = gmdate( 'Y-m-d H:i:s', $timestamp );
     1610            }
     1611            $now = gmdate( 'Y-m-d H:i:59' );
     1612
     1613            $is_future_dated = ( mysql2date( 'U', $changeset_date_gmt, false ) > mysql2date( 'U', $now, false ) );
     1614            if ( ! $is_future_dated ) {
     1615                wp_send_json_error( 'not_future_date', 400 ); // Only future dates are allowed.
     1616            }
     1617
     1618            if ( ! $this->is_theme_active() && ( 'future' === $changeset_status || $is_future_dated ) ) {
     1619                wp_send_json_error( 'cannot_schedule_theme_switches', 400 ); // This should be allowed in the future, when theme is a regular setting.
     1620            }
     1621            $will_remain_auto_draft = ( ! $changeset_status && ( ! $changeset_post_id || 'auto-draft' === get_post_status( $changeset_post_id ) ) );
     1622            if ( $changeset_date && $will_remain_auto_draft ) {
     1623                wp_send_json_error( 'cannot_supply_date_for_auto_draft_changeset', 400 );
     1624            }
     1625        }
     1626
     1627        $r = $this->save_changeset_post( array(
     1628            'status' => $changeset_status,
     1629            'title' => $changeset_title,
     1630            'date_gmt' => $changeset_date_gmt,
     1631            'data' => $input_changeset_data,
     1632        ) );
     1633        if ( is_wp_error( $r ) ) {
     1634            $response = $r->get_error_data();
     1635        } else {
     1636            $response = $r;
     1637
     1638            // Note that if the changeset status was publish, then it will get set to trash if revisions are not supported.
     1639            $response['changeset_status'] = get_post_status( $this->changeset_post_id() );
     1640            if ( $is_publish && 'trash' === $response['changeset_status'] ) {
     1641                $response['changeset_status'] = 'publish';
     1642            }
     1643
     1644            if ( 'publish' === $response['changeset_status'] ) {
     1645                $response['next_changeset_uuid'] = wp_generate_uuid4();
     1646            }
     1647        }
     1648
     1649        if ( isset( $response['setting_validities'] ) ) {
     1650            $response['setting_validities'] = array_map( array( $this, 'prepare_setting_validity_for_js' ), $response['setting_validities'] );
     1651        }
     1652
     1653        /**
     1654         * Filters response data for a successful customize_save Ajax request.
     1655         *
     1656         * This filter does not apply if there was a nonce or authentication failure.
     1657         *
     1658         * @since 4.2.0
     1659         *
     1660         * @param array                $response Additional information passed back to the 'saved'
     1661         *                                       event on `wp.customize`.
     1662         * @param WP_Customize_Manager $this     WP_Customize_Manager instance.
     1663         */
     1664        $response = apply_filters( 'customize_save_response', $response, $this );
     1665
     1666        if ( is_wp_error( $r ) ) {
     1667            wp_send_json_error( $response );
     1668        } else {
     1669            wp_send_json_success( $response );
     1670        }
     1671    }
     1672
     1673    /**
     1674     * Save the post for the loaded changeset.
     1675     *
     1676     * @since 4.7.0
     1677     * @access public
     1678     *
     1679     * @param array $args {
     1680     *     Args for changeset post.
     1681     *
     1682     *     @type array  $data     Optional additional changeset data. Values will be merged on top of any existing post values.
     1683     *     @type string $status   Post status. Optional. If supplied, the save will be transactional and a post revision will be allowed.
     1684     *     @type string $title    Post title. Optional.
     1685     *     @type string $date_gmt Date in GMT. Optional.
     1686     * }
     1687     *
     1688     * @return array|WP_Error Returns array on success and WP_Error with array data on error.
     1689     */
     1690    function save_changeset_post( $args = array() ) {
     1691
     1692        $args = array_merge(
     1693            array(
     1694                'status' => null,
     1695                'title' => null,
     1696                'data' => array(),
     1697                'date_gmt' => null,
     1698            ),
     1699            $args
     1700        );
     1701
     1702        $changeset_post_id = $this->changeset_post_id();
     1703
     1704        // The request was made via wp.customize.previewer.save().
     1705        $update_transactionally = (bool) $args['status'];
     1706        $allow_revision = (bool) $args['status'];
     1707
     1708        // Amend post values with any supplied data.
     1709        foreach ( $args['data'] as $setting_id => $setting_params ) {
     1710            if ( array_key_exists( 'value', $setting_params ) ) {
     1711                $this->set_post_value( $setting_id, $setting_params['value'] ); // Add to post values so that they can be validated and sanitized.
     1712            }
     1713        }
     1714
     1715        // Note that in addition to post data, this will include any stashed theme mods.
     1716        $post_values = $this->unsanitized_post_values( array(
     1717            'exclude_changeset' => true,
     1718            'exclude_post_data' => false,
     1719        ) );
     1720        $this->add_dynamic_settings( array_keys( $post_values ) ); // Ensure settings get created even if they lack an input value.
     1721
    10731722        /**
    10741723         * Fires before save validation happens.
     
    10851734
    10861735        // Validate settings.
    1087         $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1736        $setting_validities = $this->validate_setting_values( $post_values, array(
     1737            'validate_capability' => true,
     1738            'validate_existence' => true,
     1739        ) );
    10881740        $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) );
    1089         $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
    1090         if ( $invalid_setting_count > 0 ) {
     1741
     1742        /*
     1743         * Short-circuit if there are invalid settings the update is transactional.
     1744         * A changeset update is transactional when a status is supplied in the request.
     1745         */
     1746        if ( $update_transactionally && $invalid_setting_count > 0 ) {
    10911747            $response = array(
    1092                 'setting_validities' => $exported_setting_validities,
     1748                'setting_validities' => $setting_validities,
    10931749                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
    10941750            );
    1095 
    1096             /** This filter is documented in wp-includes/class-wp-customize-manager.php */
    1097             $response = apply_filters( 'customize_save_response', $response, $this );
    1098             wp_send_json_error( $response );
    1099         }
    1100 
    1101         // Do we have to switch themes?
    1102         if ( ! $this->is_theme_active() ) {
    1103             // Temporarily stop previewing the theme to allow switch_themes()
    1104             // to operate properly.
     1751            return new WP_Error( 'transaction_fail', '', $response );
     1752        }
     1753
     1754        $response = array(
     1755            'setting_validities' => $setting_validities,
     1756        );
     1757
     1758        // Obtain/merge data for changeset.
     1759        $original_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
     1760        $data = $original_changeset_data;
     1761        if ( is_wp_error( $data ) ) {
     1762            $data = array();
     1763        }
     1764
     1765        // Ensure that all post values are included in the changeset data.
     1766        foreach ( $post_values as $setting_id => $post_value ) {
     1767            if ( ! isset( $args['data'][ $setting_id ] ) ) {
     1768                $args['data'][ $setting_id ] = array();
     1769            }
     1770            if ( ! isset( $args['data'][ $setting_id ]['value'] ) ) {
     1771                $args['data'][ $setting_id ]['value'] = $post_value;
     1772            }
     1773        }
     1774
     1775        foreach ( $args['data'] as $setting_id => $setting_params ) {
     1776            $setting = $this->get_setting( $setting_id );
     1777            if ( ! $setting || ! $setting->check_capabilities() ) {
     1778                continue;
     1779            }
     1780
     1781            // Skip updating changeset for invalid setting values.
     1782            if ( isset( $setting_validities[ $setting_id ] ) && is_wp_error( $setting_validities[ $setting_id ] ) ) {
     1783                continue;
     1784            }
     1785
     1786            $changeset_setting_id = $setting_id;
     1787            if ( 'theme_mod' === $setting->type ) {
     1788                $changeset_setting_id = sprintf( '%s::%s', $this->get_stylesheet(), $setting_id );
     1789            }
     1790
     1791            if ( null === $setting_params ) {
     1792                // Remove setting from changeset entirely.
     1793                unset( $data[ $changeset_setting_id ] );
     1794            } else {
     1795                // Merge any additional setting params that have been supplied with the existing params.
     1796                if ( ! isset( $data[ $changeset_setting_id ] ) ) {
     1797                    $data[ $changeset_setting_id ] = array();
     1798                }
     1799                $data[ $changeset_setting_id ] = array_merge(
     1800                    $data[ $changeset_setting_id ],
     1801                    $setting_params,
     1802                    array( 'type' => $setting->type )
     1803                );
     1804            }
     1805        }
     1806
     1807        $filter_context = array(
     1808            'uuid' => $this->changeset_uuid(),
     1809            'title' => $args['title'],
     1810            'status' => $args['status'],
     1811            'date_gmt' => $args['date_gmt'],
     1812            'post_id' => $changeset_post_id,
     1813            'previous_data' => is_wp_error( $original_changeset_data ) ? array() : $original_changeset_data,
     1814            'manager' => $this,
     1815        );
     1816
     1817        /**
     1818         * Filters the settings' data that will be persisted into the changeset.
     1819         *
     1820         * Plugins may amend additional data (such as additional meta for settings) into the changeset with this filter.
     1821         *
     1822         * @since 4.7.0
     1823         *
     1824         * @param array $data Updated changeset data, mapping setting IDs to arrays containing a $value item and optionally other metadata.
     1825         * @param array $context {
     1826         *     Filter context.
     1827         *
     1828         *     @type string               $uuid          Changeset UUID.
     1829         *     @type string               $title         Requested title for the changeset post.
     1830         *     @type string               $status        Requested status for the changeset post.
     1831         *     @type string               $date_gmt      Requested date for the changeset post in MySQL format and GMT timezone.
     1832         *     @type int|false            $post_id       Post ID for the changeset, or false if it doesn't exist yet.
     1833         *     @type array                $previous_data Previous data contained in the changeset.
     1834         *     @type WP_Customize_Manager $manager       Manager instance.
     1835         * }
     1836         */
     1837        $data = apply_filters( 'customize_changeset_save_data', $data, $filter_context );
     1838
     1839        // Switch theme if publishing changes now.
     1840        if ( 'publish' === $args['status'] && ! $this->is_theme_active() ) {
     1841            // Temporarily stop previewing the theme to allow switch_themes() to operate properly.
    11051842            $this->stop_previewing_theme();
    11061843            switch_theme( $this->get_stylesheet() );
     
    11091846        }
    11101847
     1848        // Gather the data for wp_insert_post()/wp_update_post().
     1849        $json_options = 0;
     1850        if ( defined( 'JSON_UNESCAPED_SLASHES' ) ) {
     1851            $json_options |= JSON_UNESCAPED_SLASHES; // Introduced in PHP 5.4. This is only to improve readability as slashes needn't be escaped in storage.
     1852        }
     1853        $json_options |= JSON_PRETTY_PRINT; // Also introduced in PHP 5.4, but WP defines constant for back compat. See WP Trac #30139.
     1854        $post_array = array(
     1855            'post_content' => wp_json_encode( $data, $json_options ),
     1856        );
     1857        if ( $args['title'] ) {
     1858            $post_array['post_title'] = $args['title'];
     1859        }
     1860        if ( $changeset_post_id ) {
     1861            $post_array['ID'] = $changeset_post_id;
     1862        } else {
     1863            $post_array['post_type'] = 'customize_changeset';
     1864            $post_array['post_name'] = $this->changeset_uuid();
     1865            $post_array['post_status'] = 'auto-draft';
     1866        }
     1867        if ( $args['status'] ) {
     1868            $post_array['post_status'] = $args['status'];
     1869        }
     1870        if ( $args['date_gmt'] ) {
     1871            $post_array['post_date_gmt'] = $args['date_gmt'];
     1872            $post_array['post_date'] = get_date_from_gmt( $args['date_gmt'] );
     1873        }
     1874
     1875        $this->store_changeset_revision = $allow_revision;
     1876        add_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ), 5, 3 );
     1877
     1878        // Update the changeset post. The publish_customize_changeset action will cause the settings in the changeset to be saved via WP_Customize_Setting::save().
     1879        $has_kses = ( false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) );
     1880        if ( $has_kses ) {
     1881            kses_remove_filters(); // Prevent KSES from corrupting JSON in post_content.
     1882        }
     1883
     1884        // Note that updating a post with publish status will trigger WP_Customize_Manager::publish_changeset_values().
     1885        if ( $changeset_post_id ) {
     1886            $post_array['edit_date'] = true; // Prevent date clearing.
     1887            $r = wp_update_post( wp_slash( $post_array ), true );
     1888        } else {
     1889            $r = wp_insert_post( wp_slash( $post_array ), true );
     1890            if ( ! is_wp_error( $r ) ) {
     1891                $this->_changeset_post_id = $r; // Update cached post ID for the loaded changeset.
     1892            }
     1893        }
     1894        if ( $has_kses ) {
     1895            kses_init_filters();
     1896        }
     1897        $this->_changeset_data = null; // Reset so WP_Customize_Manager::changeset_data() will re-populate with updated contents.
     1898
     1899        remove_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ) );
     1900
     1901        if ( is_wp_error( $r ) ) {
     1902            $response['changeset_post_save_failure'] = $r->get_error_code();
     1903            return new WP_Error( 'changeset_post_save_failure', '', $response );
     1904        }
     1905
     1906        return $response;
     1907    }
     1908
     1909    /**
     1910     * Whether a changeset revision should be made.
     1911     *
     1912     * @since 4.7.0
     1913     * @access private
     1914     * @var bool
     1915     */
     1916    protected $store_changeset_revision;
     1917
     1918    /**
     1919     * Filters whether a changeset has changed to create a new revision.
     1920     *
     1921     * Note that this will not be called while a changeset post remains in auto-draft status.
     1922     *
     1923     * @since 4.7.0
     1924     * @access private
     1925     *
     1926     * @param bool    $post_has_changed Whether the post has changed.
     1927     * @param WP_Post $last_revision    The last revision post object.
     1928     * @param WP_Post $post             The post object.
     1929     *
     1930     * @return bool Whether a revision should be made.
     1931     */
     1932    public function _filter_revision_post_has_changed( $post_has_changed, $last_revision, $post ) {
     1933        unset( $last_revision );
     1934        if ( 'customize_changeset' === $post->post_type ) {
     1935            $post_has_changed = $this->store_changeset_revision;
     1936        }
     1937        return $post_has_changed;
     1938    }
     1939
     1940    /**
     1941     * Publish changeset values.
     1942     *
     1943     * This will the values contained in a changeset, even changesets that do not
     1944     * correspond to current manager instance. This is called by
     1945     * `_wp_customize_publish_changeset()` when a customize_changeset post is
     1946     * transitioned to the `publish` status. As such, this method should not be
     1947     * called directly and instead `wp_publish_post()` should be used.
     1948     *
     1949     * Please note that if the settings in the changeset are for a non-activated
     1950     * theme, the theme must first be switched to (via `switch_theme()`) before
     1951     * invoking this method.
     1952     *
     1953     * @since 4.7.0
     1954     * @access private
     1955     * @see _wp_customize_publish_changeset()
     1956     *
     1957     * @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
     1958     * @return true|WP_Error True or error info.
     1959     */
     1960    public function _publish_changeset_values( $changeset_post_id ) {
     1961        $publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
     1962        if ( is_wp_error( $publishing_changeset_data ) ) {
     1963            return $publishing_changeset_data;
     1964        }
     1965
     1966        $changeset_post = get_post( $changeset_post_id );
     1967
     1968        /*
     1969         * Temporarily override the changeset context so that it will be read
     1970         * in calls to unsanitized_post_values() and so that it will be available
     1971         * on the $wp_customize object passed to hooks during the save logic.
     1972         */
     1973        $previous_changeset_post_id = $this->_changeset_post_id;
     1974        $this->_changeset_post_id   = $changeset_post_id;
     1975        $previous_changeset_uuid    = $this->_changeset_uuid;
     1976        $this->_changeset_uuid      = $changeset_post->post_name;
     1977        $previous_changeset_data    = $this->_changeset_data;
     1978        $this->_changeset_data      = $publishing_changeset_data;
     1979
     1980        // Ensure that other theme mods are stashed.
     1981        $other_theme_mod_settings = array();
     1982        if ( did_action( 'switch_theme' ) ) {
     1983            $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     1984            $matches = array();
     1985            foreach ( $this->_changeset_data as $raw_setting_id => $setting_params ) {
     1986                $is_other_theme_mod = (
     1987                    isset( $setting_params['value'] )
     1988                    &&
     1989                    isset( $setting_params['type'] )
     1990                    &&
     1991                    'theme_mod' === $setting_params['type']
     1992                    &&
     1993                    preg_match( $namespace_pattern, $raw_setting_id, $matches )
     1994                    &&
     1995                    $this->get_stylesheet() !== $matches['stylesheet']
     1996                );
     1997                if ( $is_other_theme_mod ) {
     1998                    if ( ! isset( $other_theme_mod_settings[ $matches['stylesheet'] ] ) ) {
     1999                        $other_theme_mod_settings[ $matches['stylesheet'] ] = array();
     2000                    }
     2001                    $other_theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params;
     2002                }
     2003            }
     2004        }
     2005
     2006        $changeset_setting_values = $this->unsanitized_post_values( array(
     2007            'exclude_post_data' => true,
     2008            'exclude_changeset' => false,
     2009        ) );
     2010        $changeset_setting_ids = array_keys( $changeset_setting_values );
     2011        $this->add_dynamic_settings( $changeset_setting_ids );
     2012
    11112013        /**
    11122014         * Fires once the theme has switched in the Customizer, but before settings
     
    11152017         * @since 3.4.0
    11162018         *
    1117          * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     2019         * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11182020         */
    11192021        do_action( 'customize_save', $this );
    11202022
    1121         foreach ( $this->settings as $setting ) {
    1122             $setting->save();
     2023        /*
     2024         * Ensure that all settings will allow themselves to be saved. Note that
     2025         * this is safe because the setting would have checked the capability
     2026         * when the setting value was written into the changeset. So this is why
     2027         * an additional capability check is not required here.
     2028         */
     2029        $original_setting_capabilities = array();
     2030        foreach ( $changeset_setting_ids as $setting_id ) {
     2031            $setting = $this->get_setting( $setting_id );
     2032            if ( $setting ) {
     2033                $original_setting_capabilities[ $setting->id ] = $setting->capability;
     2034                $setting->capability = 'exist';
     2035            }
     2036        }
     2037
     2038        foreach ( $changeset_setting_ids as $setting_id ) {
     2039            $setting = $this->get_setting( $setting_id );
     2040            if ( $setting ) {
     2041                $setting->save();
     2042            }
     2043        }
     2044
     2045        // Update the stashed theme mod settings, removing the active theme's stashed settings, if activated.
     2046        if ( did_action( 'switch_theme' ) ) {
     2047            $this->update_stashed_theme_mod_settings( $other_theme_mod_settings );
    11232048        }
    11242049
     
    11282053         * @since 3.6.0
    11292054         *
    1130          * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     2055         * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11312056         */
    11322057        do_action( 'customize_save_after', $this );
    11332058
    1134         $data = array(
    1135             'setting_validities' => $exported_setting_validities,
    1136         );
    1137 
    1138         /**
    1139          * Filters response data for a successful customize_save Ajax request.
    1140          *
    1141          * This filter does not apply if there was a nonce or authentication failure.
    1142          *
    1143          * @since 4.2.0
    1144          *
    1145          * @param array                $data Additional information passed back to the 'saved'
    1146          *                                   event on `wp.customize`.
    1147          * @param WP_Customize_Manager $this WP_Customize_Manager instance.
    1148          */
    1149         $response = apply_filters( 'customize_save_response', $data, $this );
    1150         wp_send_json_success( $response );
     2059        // Restore original capabilities.
     2060        foreach ( $original_setting_capabilities as $setting_id => $capability ) {
     2061            $setting = $this->get_setting( $setting_id );
     2062            if ( $setting ) {
     2063                $setting->capability = $capability;
     2064            }
     2065        }
     2066
     2067        // Restore original changeset data.
     2068        $this->_changeset_data    = $previous_changeset_data;
     2069        $this->_changeset_post_id = $previous_changeset_post_id;
     2070        $this->_changeset_uuid    = $previous_changeset_uuid;
     2071
     2072        return true;
     2073    }
     2074
     2075    /**
     2076     * Update stashed theme mod settings.
     2077     *
     2078     * @since 4.7.0
     2079     * @access private
     2080     *
     2081     * @param array $inactive_theme_mod_settings Mapping of stylesheet to arrays of theme mod settings.
     2082     * @return array|false Returns array of updated stashed theme mods or false if the update failed or there were no changes.
     2083     */
     2084    protected function update_stashed_theme_mod_settings( $inactive_theme_mod_settings ) {
     2085        $stashed_theme_mod_settings = get_option( 'customize_stashed_theme_mods' );
     2086        if ( empty( $stashed_theme_mod_settings ) ) {
     2087            $stashed_theme_mod_settings = array();
     2088        }
     2089
     2090        // Delete any stashed theme mods for the active theme since since they would have been loaded and saved upon activation.
     2091        unset( $stashed_theme_mod_settings[ $this->get_stylesheet() ] );
     2092
     2093        // Merge inactive theme mods with the stashed theme mod settings.
     2094        foreach ( $inactive_theme_mod_settings as $stylesheet => $theme_mod_settings ) {
     2095            if ( ! isset( $stashed_theme_mod_settings[ $stylesheet ] ) ) {
     2096                $stashed_theme_mod_settings[ $stylesheet ] = array();
     2097            }
     2098
     2099            $stashed_theme_mod_settings[ $stylesheet ] = array_merge(
     2100                $stashed_theme_mod_settings[ $stylesheet ],
     2101                $theme_mod_settings
     2102            );
     2103        }
     2104
     2105        $autoload = false;
     2106        $result = update_option( 'customize_stashed_theme_mods', $stashed_theme_mod_settings, $autoload );
     2107        if ( ! $result ) {
     2108            return false;
     2109        }
     2110        return $stashed_theme_mod_settings;
    11512111    }
    11522112
     
    16922652
    16932653    /**
     2654     * Determines whether the admin and the frontend are on different domains.
     2655     *
     2656     * @since 4.7.0
     2657     * @access public
     2658     *
     2659     * @return bool Whether cross-domain.
     2660     */
     2661    public function is_cross_domain() {
     2662        $admin_origin = wp_parse_url( admin_url() );
     2663        $home_origin = wp_parse_url( home_url() );
     2664        $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
     2665        return $cross_domain;
     2666    }
     2667
     2668    /**
     2669     * Get URLs allowed to be previewed.
     2670     *
     2671     * If the front end and the admin are served from the same domain, load the
     2672     * preview over ssl if the Customizer is being loaded over ssl. This avoids
     2673     * insecure content warnings. This is not attempted if the admin and front end
     2674     * are on different domains to avoid the case where the front end doesn't have
     2675     * ssl certs. Domain mapping plugins can allow other urls in these conditions
     2676     * using the customize_allowed_urls filter.
     2677     *
     2678     * @since 4.7.0
     2679     * @access public
     2680     *
     2681     * @returns array Allowed URLs.
     2682     */
     2683    public function get_allowed_urls() {
     2684        $allowed_urls = array( home_url( '/' ) );
     2685
     2686        if ( is_ssl() && ! $this->is_cross_domain() ) {
     2687            $allowed_urls[] = home_url( '/', 'https' );
     2688        }
     2689
     2690        /**
     2691         * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
     2692         *
     2693         * @since 3.4.0
     2694         *
     2695         * @param array $allowed_urls An array of allowed URLs.
     2696         */
     2697        $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
     2698
     2699        return $allowed_urls;
     2700    }
     2701
     2702    /**
     2703     * Get messenger channel.
     2704     *
     2705     * @since 4.7.0
     2706     * @access public
     2707     *
     2708     * @return string Messenger channel.
     2709     */
     2710    public function get_messenger_channel() {
     2711        return $this->messenger_channel;
     2712    }
     2713
     2714    /**
    16942715     * Set URL to link the user to when closing the Customizer.
    16952716     *
     
    18002821     */
    18012822    public function customize_pane_settings() {
    1802         /*
    1803          * If the front end and the admin are served from the same domain, load the
    1804          * preview over ssl if the Customizer is being loaded over ssl. This avoids
    1805          * insecure content warnings. This is not attempted if the admin and front end
    1806          * are on different domains to avoid the case where the front end doesn't have
    1807          * ssl certs. Domain mapping plugins can allow other urls in these conditions
    1808          * using the customize_allowed_urls filter.
    1809          */
    1810 
    1811         $allowed_urls = array( home_url( '/' ) );
    1812         $admin_origin = parse_url( admin_url() );
    1813         $home_origin  = parse_url( home_url() );
    1814         $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
    1815 
    1816         if ( is_ssl() && ! $cross_domain ) {
    1817             $allowed_urls[] = home_url( '/', 'https' );
    1818         }
    1819 
    1820         /**
    1821          * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
    1822          *
    1823          * @since 3.4.0
    1824          *
    1825          * @param array $allowed_urls An array of allowed URLs.
    1826          */
    1827         $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
    18282823
    18292824        $login_url = add_query_arg( array(
     
    18322827        ), wp_login_url() );
    18332828
     2829        // Ensure dirty flags are set for modified settings.
     2830        foreach ( array_keys( $this->unsanitized_post_values() ) as $setting_id ) {
     2831            $setting = $this->get_setting( $setting_id );
     2832            if ( $setting ) {
     2833                $setting->dirty = true;
     2834            }
     2835        }
     2836
    18342837        // Prepare Customizer settings to pass to JavaScript.
    18352838        $settings = array(
     2839            'changeset' => array(
     2840                'uuid' => $this->changeset_uuid(),
     2841                'status' => $this->changeset_post_id() ? get_post_status( $this->changeset_post_id() ) : '',
     2842            ),
     2843            'timeouts' => array(
     2844                'windowRefresh' => 250,
     2845                'changesetAutoSave' => AUTOSAVE_INTERVAL * 1000,
     2846                'keepAliveCheck' => 2500,
     2847                'reflowPaneContents' => 100,
     2848                'previewFrameSensitivity' => 2000,
     2849            ),
    18362850            'theme'    => array(
    18372851                'stylesheet' => $this->get_stylesheet(),
     
    18432857                'activated'     => esc_url_raw( home_url( '/' ) ),
    18442858                'ajax'          => esc_url_raw( admin_url( 'admin-ajax.php', 'relative' ) ),
    1845                 'allowed'       => array_map( 'esc_url_raw', $allowed_urls ),
    1846                 'isCrossDomain' => $cross_domain,
     2859                'allowed'       => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     2860                'isCrossDomain' => $this->is_cross_domain(),
    18472861                'home'          => esc_url_raw( home_url( '/' ) ),
    18482862                'login'         => esc_url_raw( $login_url ),
     
    23383352     */
    23393353    public function register_dynamic_settings() {
    2340         $this->add_dynamic_settings( array_keys( $this->unsanitized_post_values() ) );
     3354        $setting_ids = array_keys( $this->unsanitized_post_values() );
     3355        $this->add_dynamic_settings( $setting_ids );
    23413356    }
    23423357
  • trunk/src/wp-includes/class-wp-customize-nav-menus.php

    r38794 r38810  
    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;
     
    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' ) );
     
    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' );
  • trunk/src/wp-includes/class-wp-customize-widgets.php

    r38766 r38810  
    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' ) );
     
    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
  • trunk/src/wp-includes/customize/class-wp-customize-selective-refresh.php

    r38478 r38810  
    307307            return;
    308308        }
    309 
    310         $this->manager->remove_preview_signature();
    311309
    312310        /*
  • trunk/src/wp-includes/customize/class-wp-customize-theme-control.php

    r35389 r38810  
    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        ?>
  • trunk/src/wp-includes/default-filters.php

    r38778 r38810  
    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
     
    383384add_action( 'wp_head', '_custom_logo_header_styles' );
    384385add_action( 'plugins_loaded', '_wp_customize_include' );
     386add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 );
    385387add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' );
    386388add_action( 'delete_attachment', '_delete_attachment_theme_mod' );
  • trunk/src/wp-includes/functions.php

    r38809 r38810  
    55245524    return false;
    55255525}
     5526
     5527/**
     5528 * Generate a random UUID (version 4).
     5529 *
     5530 * @since 4.7.0
     5531 *
     5532 * @return string UUID.
     5533 */
     5534function wp_generate_uuid4() {
     5535    return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
     5536        mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ),
     5537        mt_rand( 0, 0xffff ),
     5538        mt_rand( 0, 0x0fff ) | 0x4000,
     5539        mt_rand( 0, 0x3fff ) | 0x8000,
     5540        mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff )
     5541    );
     5542}
  • trunk/src/wp-includes/functions.wp-scripts.php

    r38519 r38810  
    3535 */
    3636function _wp_scripts_maybe_doing_it_wrong( $function ) {
    37     if ( did_action( 'init' ) ) {
     37    if ( did_action( 'init' ) || did_action( 'admin_enqueue_scripts' ) || did_action( 'wp_enqueue_scripts' ) || did_action( 'login_enqueue_scripts' ) ) {
    3838        return;
    3939    }
  • trunk/src/wp-includes/js/customize-base.js

    r38513 r38810  
    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 ) {
    647647            // Target the parent frame by default, but only if a parent frame exists.
    648             var defaultTarget = window.parent == window ? null : window.parent;
     648            var defaultTarget = window.parent === window ? null : window.parent;
    649649
    650650            $.extend( this, options || {} );
     
    653653            this.add( 'url', params.url || '' );
    654654            this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) {
    655                 return to.replace( /([^:]+:\/\/[^\/]+).*/, '$1' );
     655                var urlParser = document.createElement( 'a' );
     656                urlParser.href = to;
     657                return urlParser.protocol + '//' + urlParser.hostname;
    656658            });
    657659
     
    808810    };
    809811
     812    /**
     813     * Utility function namespace
     814     */
     815    api.utils = {};
     816
     817    /**
     818     * Parse query string.
     819     *
     820     * @since 4.7.0
     821     * @access public
     822     *
     823     * @param {string} queryString Query string.
     824     * @returns {object} Parsed query string.
     825     */
     826    api.utils.parseQueryString = function parseQueryString( queryString ) {
     827        var queryParams = {};
     828        _.each( queryString.split( '&' ), function( pair ) {
     829            var parts, key, value;
     830            parts = pair.split( '=', 2 );
     831            if ( ! parts[0] ) {
     832                return;
     833            }
     834            key = decodeURIComponent( parts[0].replace( /\+/g, ' ' ) );
     835            key = key.replace( / /g, '_' ); // What PHP does.
     836            if ( _.isUndefined( parts[1] ) ) {
     837                value = null;
     838            } else {
     839                value = decodeURIComponent( parts[1].replace( /\+/g, ' ' ) );
     840            }
     841            queryParams[ key ] = value;
     842        } );
     843        return queryParams;
     844    };
     845
    810846    // Expose the API publicly on window.wp.customize
    811847    exports.customize = api;
  • trunk/src/wp-includes/js/customize-loader.js

    r38520 r38810  
    132132                targetWindow: this.iframe[0].contentWindow
    133133            });
     134
     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 = document.createElement( 'a' );
     139                    urlParser.href = location.href;
     140                    urlParser.search = $.param( _.extend(
     141                        api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     142                        { changeset_uuid: changesetUuid }
     143                    ) );
     144                    history.replaceState( { customize: urlParser.href }, '', urlParser.href );
     145                } );
     146            }
    134147
    135148            // Wait for the connection from the iframe before sending any postMessage events.
  • trunk/src/wp-includes/js/customize-preview-nav-menus.js

    r36889 r38810  
    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 );
     
    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                    }
     
    366380        var selector = '.menu-item';
    367381
     382        // Skip adding highlights if not in the customizer preview iframe.
     383        if ( ! api.settings.channel ) {
     384            return;
     385        }
     386
    368387        // Focus on the menu item control when shift+clicking the menu item.
    369388        $( document ).on( 'click', selector, function( e ) {
  • trunk/src/wp-includes/js/customize-preview-widgets.js

    r38577 r38810  
    573573            selector = this.widgetSelectors.join( ',' );
    574574
     575        // Skip adding highlights if not in the customizer preview iframe.
     576        if ( ! api.settings.channel ) {
     577            return;
     578        }
     579
    575580        $( selector ).attr( 'title', this.l10n.widgetTooltip );
    576581
  • trunk/src/wp-includes/js/customize-preview.js

    r38588 r38810  
    44(function( exports, $ ){
    55    var api = wp.customize,
    6         debounce;
     6        debounce,
     7        currentHistoryState = {};
     8
     9    /*
     10     * Capture the state that is passed into history.replaceState() and history.pushState()
     11     * and also which is returned in the popstate event so that when the changeset_uuid
     12     * gets updated when transitioning to a new changeset there the current state will
     13     * be supplied in the call to history.replaceState().
     14     */
     15    ( function( history ) {
     16        var injectUrlWithState;
     17
     18        if ( ! history.replaceState ) {
     19            return;
     20        }
     21
     22        /**
     23         * Amend the supplied URL with the customized state.
     24         *
     25         * @since 4.7.0
     26         * @access private
     27         *
     28         * @param {string} url URL.
     29         * @returns {string} URL with customized state.
     30         */
     31        injectUrlWithState = function( url ) {
     32            var urlParser, queryParams;
     33            urlParser = document.createElement( 'a' );
     34            urlParser.href = url;
     35            queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     36
     37            queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     38            if ( ! api.settings.theme.active ) {
     39                queryParams.customize_theme = api.settings.theme.stylesheet;
     40            }
     41            if ( api.settings.theme.channel ) {
     42                queryParams.customize_messenger_channel = api.settings.channel;
     43            }
     44            urlParser.search = $.param( queryParams );
     45            return url;
     46        };
     47
     48        history.replaceState = ( function( nativeReplaceState ) {
     49            return function historyReplaceState( data, title, url ) {
     50                currentHistoryState = data;
     51                return nativeReplaceState.call( history, data, title, injectUrlWithState( url ) );
     52            };
     53        } )( history.replaceState );
     54
     55        history.pushState = ( function( nativePushState ) {
     56            return function historyPushState( data, title, url ) {
     57                currentHistoryState = data;
     58                return nativePushState.call( history, data, title, injectUrlWithState( url ) );
     59            };
     60        } )( history.pushState );
     61
     62        window.addEventListener( 'popstate', function( event ) {
     63            currentHistoryState = event.state;
     64        } );
     65
     66    }( history ) );
    767
    868    /**
     
    3898         */
    3999        initialize: function( params, options ) {
    40             var self = this;
    41 
    42             api.Messenger.prototype.initialize.call( this, params, options );
    43 
    44             this.body = $( document.body );
    45             this.body.on( 'click.preview', 'a', function( event ) {
     100            var preview = this, urlParser = document.createElement( 'a' );
     101
     102            api.Messenger.prototype.initialize.call( preview, params, options );
     103
     104            urlParser.href = preview.origin();
     105            preview.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
     106
     107            preview.body = $( document.body );
     108            preview.body.on( 'click.preview', 'a', function( event ) {
    46109                var link, isInternalJumpLink;
    47110                link = $( this );
     111
     112                // No-op if the anchor is not a link.
     113                if ( _.isUndefined( link.attr( 'href' ) ) ) {
     114                    return;
     115                }
     116
    48117                isInternalJumpLink = ( '#' === link.attr( 'href' ).substr( 0, 1 ) );
     118
     119                // Allow internal jump links to behave normally without preventing default.
     120                if ( isInternalJumpLink ) {
     121                    return;
     122                }
     123
     124                // If the link is not previewable, prevent the browser from navigating to it.
     125                if ( ! api.isLinkPreviewable( link[0] ) ) {
     126                    wp.a11y.speak( api.settings.l10n.linkUnpreviewable );
     127                    event.preventDefault();
     128                    return;
     129                }
     130
     131                // If not in an iframe, then allow the link click to proceed normally since the state query params are added.
     132                if ( ! api.settings.channel ) {
     133                    return;
     134                }
     135
     136                // Prevent initiating navigating from click and instead rely on sending url message to pane.
    49137                event.preventDefault();
    50 
    51                 if ( isInternalJumpLink && '#' !== link.attr( 'href' ) ) {
    52                     $( link.attr( 'href' ) ).each( function() {
    53                         this.scrollIntoView();
    54                     } );
    55                 }
    56138
    57139                /*
     
    60142                 * control instead of also navigating to the URL linked to.
    61143                 */
    62                 if ( event.shiftKey || isInternalJumpLink ) {
     144                if ( event.shiftKey ) {
    63145                    return;
    64146                }
    65                 self.send( 'scroll', 0 );
    66                 self.send( 'url', link.prop( 'href' ) );
    67             });
    68 
    69             // You cannot submit forms.
    70             this.body.on( 'submit.preview', 'form', function( event ) {
    71                 var urlParser;
     147
     148                // Note: It's not relevant to send scroll because sending url message will have the same effect.
     149                preview.send( 'url', link.prop( 'href' ) );
     150            } );
     151
     152            preview.body.on( 'submit.preview', 'form', function( event ) {
     153                var urlParser = document.createElement( 'a' );
     154                urlParser.href = this.action;
     155
     156                // If the link is not previewable, prevent the browser from navigating to it.
     157                if ( 'GET' !== this.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) {
     158                    wp.a11y.speak( api.settings.l10n.formUnpreviewable );
     159                    event.preventDefault();
     160                    return;
     161                }
     162
     163                // If not in an iframe, then allow the form submission to proceed normally with the state inputs injected.
     164                if ( ! api.settings.channel ) {
     165                    return;
     166                }
    72167
    73168                /*
     
    82177                 * external site in the preview.
    83178                 */
    84                 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 ) {
     179                if ( ! event.isDefaultPrevented() ) {
     180                    if ( urlParser.search.length > 1 ) {
    88181                        urlParser.search += '&';
    89182                    }
     
    92185                }
    93186
     187                // Prevent default since navigation should be done via sending url message or via JS submit handler.
    94188                event.preventDefault();
    95189            });
    96190
    97             this.window = $( window );
    98             this.window.on( 'scroll.preview', debounce( function() {
    99                 self.send( 'scroll', self.window.scrollTop() );
    100             }, 200 ));
    101 
    102             this.bind( 'scroll', function( distance ) {
    103                 self.window.scrollTop( distance );
    104             });
     191            preview.window = $( window );
     192
     193            if ( api.settings.channel ) {
     194                preview.window.on( 'scroll.preview', debounce( function() {
     195                    preview.send( 'scroll', preview.window.scrollTop() );
     196                }, 200 ) );
     197
     198                preview.bind( 'scroll', function( distance ) {
     199                    preview.window.scrollTop( distance );
     200                });
     201            }
    105202        }
    106203    });
     204
     205    /**
     206     * Inject the changeset UUID into links in the document.
     207     *
     208     * @since 4.7.0
     209     * @access protected
     210     *
     211     * @access private
     212     * @returns {void}
     213     */
     214    api.addLinkPreviewing = function addLinkPreviewing() {
     215        var linkSelectors = 'a[href], area';
     216
     217        // Inject links into initial document.
     218        $( document.body ).find( linkSelectors ).each( function() {
     219            api.prepareLinkPreview( this );
     220        } );
     221
     222        // Inject links for new elements added to the page.
     223        if ( 'undefined' !== typeof MutationObserver ) {
     224            api.mutationObserver = new MutationObserver( function( mutations ) {
     225                _.each( mutations, function( mutation ) {
     226                    $( mutation.target ).find( linkSelectors ).each( function() {
     227                        api.prepareLinkPreview( this );
     228                    } );
     229                } );
     230            } );
     231            api.mutationObserver.observe( document.documentElement, {
     232                childList: true,
     233                subtree: true
     234            } );
     235        } else {
     236
     237            // If mutation observers aren't available, fallback to just-in-time injection.
     238            $( document.documentElement ).on( 'click focus mouseover', linkSelectors, function() {
     239                api.prepareLinkPreview( this );
     240            } );
     241        }
     242    };
     243
     244    /**
     245     * Should the supplied link is previewable.
     246     *
     247     * @since 4.7.0
     248     * @access public
     249     *
     250     * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     251     * @param {string} element.search Query string.
     252     * @param {string} element.pathname Path.
     253     * @param {string} element.hostname Hostname.
     254     * @param {object} [options]
     255     * @param {object} [options.allowAdminAjax=false] Allow admin-ajax.php requests.
     256     * @returns {boolean} Is appropriate for changeset link.
     257     */
     258    api.isLinkPreviewable = function isLinkPreviewable( element, options ) {
     259        var hasMatchingHost, urlParser, args;
     260
     261        args = _.extend( {}, { allowAdminAjax: false }, options || {} );
     262
     263        if ( 'javascript:' === element.protocol ) { // jshint ignore:line
     264            return true;
     265        }
     266
     267        // Only web URLs can be previewed.
     268        if ( 'https:' !== element.protocol && 'http:' !== element.protocol ) {
     269            return false;
     270        }
     271
     272        urlParser = document.createElement( 'a' );
     273        hasMatchingHost = ! _.isUndefined( _.find( api.settings.url.allowed, function( allowedUrl ) {
     274            urlParser.href = allowedUrl;
     275            if ( urlParser.hostname === element.hostname && urlParser.protocol === element.protocol ) {
     276                return true;
     277            }
     278            return false;
     279        } ) );
     280        if ( ! hasMatchingHost ) {
     281            return false;
     282        }
     283
     284        // Skip wp login and signup pages.
     285        if ( /\/wp-(login|signup)\.php$/.test( element.pathname ) ) {
     286            return false;
     287        }
     288
     289        // Allow links to admin ajax as faux frontend URLs.
     290        if ( /\/wp-admin\/admin-ajax\.php$/.test( element.pathname ) ) {
     291            return args.allowAdminAjax;
     292        }
     293
     294        // Disallow links to admin, includes, and content.
     295        if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) {
     296            return false;
     297        }
     298
     299        return true;
     300    };
     301
     302    /**
     303     * Inject the customize_changeset_uuid query param into links on the frontend.
     304     *
     305     * @since 4.7.0
     306     * @access protected
     307     *
     308     * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     309     * @param {object} element.search Query string.
     310     * @returns {void}
     311     */
     312    api.prepareLinkPreview = function prepareLinkPreview( element ) {
     313        var queryParams;
     314
     315        // Skip links in admin bar.
     316        if ( $( element ).closest( '#wpadminbar' ).length ) {
     317            return;
     318        }
     319
     320        // Ignore links with href="#" or href="#id".
     321        if ( '#' === $( element ).attr( 'href' ).substr( 0, 1 ) ) {
     322            return;
     323        }
     324
     325        // Make sure links in preview use HTTPS if parent frame uses HTTPS.
     326        if ( 'https' === api.preview.scheme.get() && 'http:' === element.protocol && -1 !== api.settings.url.allowedHosts.indexOf( element.hostname ) ) {
     327            element.protocol = 'https:';
     328        }
     329
     330        if ( ! api.isLinkPreviewable( element ) ) {
     331            $( element ).addClass( 'customize-unpreviewable' );
     332            return;
     333        }
     334        $( element ).removeClass( 'customize-unpreviewable' );
     335
     336        queryParams = api.utils.parseQueryString( element.search.substring( 1 ) );
     337        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     338        if ( ! api.settings.theme.active ) {
     339            queryParams.customize_theme = api.settings.theme.stylesheet;
     340        }
     341        if ( api.settings.channel ) {
     342            queryParams.customize_messenger_channel = api.settings.channel;
     343        }
     344        element.search = $.param( queryParams );
     345
     346        // Prevent links from breaking out of preview iframe.
     347        if ( api.settings.channel ) {
     348            element.target = '_self';
     349        }
     350    };
     351
     352    /**
     353     * Inject the changeset UUID into Ajax requests.
     354     *
     355     * @since 4.7.0
     356     * @access protected
     357     *
     358     * @return {void}
     359     */
     360    api.addRequestPreviewing = function addRequestPreviewing() {
     361
     362        /**
     363         * Rewrite Ajax requests to inject customizer state.
     364         *
     365         * @param {object} options Options.
     366         * @param {string} options.type Type.
     367         * @param {string} options.url URL.
     368         * @param {object} originalOptions Original options.
     369         * @param {XMLHttpRequest} xhr XHR.
     370         * @returns {void}
     371         */
     372        var prefilterAjax = function( options, originalOptions, xhr ) {
     373            var urlParser, queryParams, requestMethod, dirtyValues = {};
     374            urlParser = document.createElement( 'a' );
     375            urlParser.href = options.url;
     376
     377            // Abort if the request is not for this site.
     378            if ( ! api.isLinkPreviewable( urlParser, { allowAdminAjax: true } ) ) {
     379                return;
     380            }
     381            queryParams = api.utils.parseQueryString( urlParser.search.substring( 1 ) );
     382
     383            // Note that _dirty flag will be cleared with changeset updates.
     384            api.each( function( setting ) {
     385                if ( setting._dirty ) {
     386                    dirtyValues[ setting.id ] = setting.get();
     387                }
     388            } );
     389
     390            if ( ! _.isEmpty( dirtyValues ) ) {
     391                requestMethod = options.type.toUpperCase();
     392
     393                // Override underlying request method to ensure unsaved changes to changeset can be included (force Backbone.emulateHTTP).
     394                if ( 'POST' !== requestMethod ) {
     395                    xhr.setRequestHeader( 'X-HTTP-Method-Override', requestMethod );
     396                    queryParams._method = requestMethod;
     397                    options.type = 'POST';
     398                }
     399
     400                // Amend the post data with the customized values.
     401                if ( options.data ) {
     402                    options.data += '&';
     403                } else {
     404                    options.data = '';
     405                }
     406                options.data += $.param( {
     407                    customized: JSON.stringify( dirtyValues )
     408                } );
     409            }
     410
     411            // Include customized state query params in URL.
     412            queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     413            if ( ! api.settings.theme.active ) {
     414                queryParams.customize_theme = api.settings.theme.stylesheet;
     415            }
     416            urlParser.search = $.param( queryParams );
     417            options.url = urlParser.href;
     418        };
     419
     420        $.ajaxPrefilter( prefilterAjax );
     421    };
     422
     423    /**
     424     * Inject changeset UUID into forms, allowing preview to persist through submissions.
     425     *
     426     * @since 4.7.0
     427     * @access protected
     428     *
     429     * @returns {void}
     430     */
     431    api.addFormPreviewing = function addFormPreviewing() {
     432
     433        // Inject inputs for forms in initial document.
     434        $( document.body ).find( 'form' ).each( function() {
     435            api.prepareFormPreview( this );
     436        } );
     437
     438        // Inject inputs for new forms added to the page.
     439        if ( 'undefined' !== typeof MutationObserver ) {
     440            api.mutationObserver = new MutationObserver( function( mutations ) {
     441                _.each( mutations, function( mutation ) {
     442                    $( mutation.target ).find( 'form' ).each( function() {
     443                        api.prepareFormPreview( this );
     444                    } );
     445                } );
     446            } );
     447            api.mutationObserver.observe( document.documentElement, {
     448                childList: true,
     449                subtree: true
     450            } );
     451        }
     452    };
     453
     454    /**
     455     * Inject changeset into form inputs.
     456     *
     457     * @since 4.7.0
     458     * @access protected
     459     *
     460     * @param {HTMLFormElement} form Form.
     461     * @returns {void}
     462     */
     463    api.prepareFormPreview = function prepareFormPreview( form ) {
     464        var urlParser, stateParams = {};
     465
     466        if ( ! form.action ) {
     467            form.action = location.href;
     468        }
     469
     470        urlParser = document.createElement( 'a' );
     471        urlParser.href = form.action;
     472
     473        // Make sure forms in preview use HTTPS if parent frame uses HTTPS.
     474        if ( 'https' === api.preview.scheme.get() && 'http:' === urlParser.protocol && -1 !== api.settings.url.allowedHosts.indexOf( urlParser.hostname ) ) {
     475            urlParser.protocol = 'https:';
     476            form.action = urlParser.href;
     477        }
     478
     479        if ( 'GET' !== form.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) {
     480            $( form ).addClass( 'customize-unpreviewable' );
     481            return;
     482        }
     483        $( form ).removeClass( 'customize-unpreviewable' );
     484
     485        stateParams.customize_changeset_uuid = api.settings.changeset.uuid;
     486        if ( ! api.settings.theme.active ) {
     487            stateParams.customize_theme = api.settings.theme.stylesheet;
     488        }
     489        if ( api.settings.channel ) {
     490            stateParams.customize_messenger_channel = api.settings.channel;
     491        }
     492
     493        _.each( stateParams, function( value, name ) {
     494            var input = $( form ).find( 'input[name="' + name + '"]' );
     495            if ( input.length ) {
     496                input.val( value );
     497            } else {
     498                $( form ).prepend( $( '<input>', {
     499                    type: 'hidden',
     500                    name: name,
     501                    value: value
     502                } ) );
     503            }
     504        } );
     505
     506        // Prevent links from breaking out of preview iframe.
     507        if ( api.settings.channel ) {
     508            form.target = '_self';
     509        }
     510    };
     511
     512    /**
     513     * Watch current URL and send keep-alive (heartbeat) messages to the parent.
     514     *
     515     * Keep the customizer pane notified that the preview is still alive
     516     * and that the user hasn't navigated to a non-customized URL.
     517     *
     518     * @since 4.7.0
     519     * @access protected
     520     */
     521    api.keepAliveCurrentUrl = ( function() {
     522        var previousPathName = location.pathname,
     523            previousQueryString = location.search.substr( 1 ),
     524            previousQueryParams = null,
     525            stateQueryParams = [ 'customize_theme', 'customize_changeset_uuid', 'customize_messenger_channel' ];
     526
     527        return function keepAliveCurrentUrl() {
     528            var urlParser, currentQueryParams;
     529
     530            // Short-circuit with keep-alive if previous URL is identical (as is normal case).
     531            if ( previousQueryString === location.search.substr( 1 ) && previousPathName === location.pathname ) {
     532                api.preview.send( 'keep-alive' );
     533                return;
     534            }
     535
     536            urlParser = document.createElement( 'a' );
     537            if ( null === previousQueryParams ) {
     538                urlParser.search = previousQueryString;
     539                previousQueryParams = api.utils.parseQueryString( previousQueryString );
     540                _.each( stateQueryParams, function( name ) {
     541                    delete previousQueryParams[ name ];
     542                } );
     543            }
     544
     545            // Determine if current URL minus customized state params and URL hash.
     546            urlParser.href = location.href;
     547            currentQueryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     548            _.each( stateQueryParams, function( name ) {
     549                delete currentQueryParams[ name ];
     550            } );
     551
     552            if ( previousPathName !== location.pathname || ! _.isEqual( previousQueryParams, currentQueryParams ) ) {
     553                urlParser.search = $.param( currentQueryParams );
     554                urlParser.hash = '';
     555                api.settings.url.self = urlParser.href;
     556                api.preview.send( 'ready', {
     557                    currentUrl: api.settings.url.self,
     558                    activePanels: api.settings.activePanels,
     559                    activeSections: api.settings.activeSections,
     560                    activeControls: api.settings.activeControls,
     561                    settingValidities: api.settings.settingValidities
     562                } );
     563            } else {
     564                api.preview.send( 'keep-alive' );
     565            }
     566            previousQueryParams = currentQueryParams;
     567            previousQueryString = location.search.substr( 1 );
     568            previousPathName = location.pathname;
     569        };
     570    } )();
    107571
    108572    $( function() {
     
    118582            channel: api.settings.channel
    119583        });
     584
     585        api.addLinkPreviewing();
     586        api.addRequestPreviewing();
     587        api.addFormPreviewing();
    120588
    121589        /**
     
    172640
    173641            api.preview.send( 'documentTitle', document.title );
     642
     643            // Send scroll in case of loading via non-refresh.
     644            api.preview.send( 'scroll', $( window ).scrollTop() );
    174645        });
    175646
    176647        api.preview.bind( 'saved', function( response ) {
     648
     649            if ( response.next_changeset_uuid ) {
     650                api.settings.changeset.uuid = response.next_changeset_uuid;
     651
     652                // Update UUIDs in links and forms.
     653                $( document.body ).find( 'a[href], area' ).each( function() {
     654                    api.prepareLinkPreview( this );
     655                } );
     656                $( document.body ).find( 'form' ).each( function() {
     657                    api.prepareFormPreview( this );
     658                } );
     659
     660                /*
     661                 * Replace the UUID in the URL. Note that the wrapped history.replaceState()
     662                 * will handle injecting the current api.settings.changeset.uuid into the URL,
     663                 * so this is merely to trigger that logic.
     664                 */
     665                if ( history.replaceState ) {
     666                    history.replaceState( currentHistoryState, '', location.href );
     667                }
     668            }
     669
    177670            api.trigger( 'saved', response );
    178671        } );
    179672
    180         api.bind( 'saved', function() {
    181             api.each( function( setting ) {
    182                 setting._dirty = false;
     673        /*
     674         * Clear dirty flag for settings when saved to changeset so that they
     675         * won't be needlessly included in selective refresh or ajax requests.
     676         */
     677        api.preview.bind( 'changeset-saved', function( data ) {
     678            _.each( data.saved_changeset_values, function( value, settingId ) {
     679                var setting = api( settingId );
     680                if ( setting && _.isEqual( setting.get(), value ) ) {
     681                    setting._dirty = false;
     682                }
    183683            } );
    184684        } );
     
    193693         */
    194694        api.preview.send( 'ready', {
     695            currentUrl: api.settings.url.self,
    195696            activePanels: api.settings.activePanels,
    196697            activeSections: api.settings.activeSections,
     
    198699            settingValidities: api.settings.settingValidities
    199700        } );
     701
     702        // Send ready when URL changes via JS.
     703        setInterval( api.keepAliveCurrentUrl, api.settings.timeouts.keepAliveSend );
    200704
    201705        // Display a loading indicator when preview is reloading, and remove on failure.
  • trunk/src/wp-includes/js/customize-selective-refresh.js

    r38649 r38810  
    1212            l10n: {
    1313                shiftClickToEdit: ''
    14             },
    15             refreshBuffer: 250
     14            }
    1615        },
    1716        currentRequest: null
     
    486485            wp_customize: 'on',
    487486            nonce: api.settings.nonce.preview,
    488             theme: api.settings.theme.stylesheet,
    489             customized: JSON.stringify( dirtyCustomized )
     487            customize_theme: api.settings.theme.stylesheet,
     488            customized: JSON.stringify( dirtyCustomized ),
     489            customize_changeset_uuid: api.settings.changeset.uuid
    490490        };
    491491    };
     
    669669                } );
    670670            },
    671             self.data.refreshBuffer
     671            api.settings.timeouts.selectiveRefresh
    672672        );
    673673
  • trunk/src/wp-includes/post.php

    r38798 r38810  
    110110        'delete_with_user' => false,
    111111        'query_var' => false,
     112    ) );
     113
     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        ),
    112157    ) );
    113158
  • trunk/src/wp-includes/script-loader.php

    r38797 r38810  
    451451    $scripts->add( 'customize-base',     "/wp-includes/js/customize-base$suffix.js",     array( 'jquery', 'json2', 'underscore' ), false, 1 );
    452452    $scripts->add( 'customize-loader',   "/wp-includes/js/customize-loader$suffix.js",   array( 'customize-base' ), false, 1 );
    453     $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'customize-base' ), false, 1 );
     453    $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'wp-a11y', 'customize-base' ), false, 1 );
    454454    $scripts->add( 'customize-models',   "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 );
    455455    $scripts->add( 'customize-views',    "/wp-includes/js/customize-views.js",  array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 );
  • trunk/src/wp-includes/theme.php

    r38649 r38810  
    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 *
     
    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( $_GET['customize_changeset_uuid'] ) || ! empty( $_POST['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    /*
     2094     * Note that wp_unslash() is not being used on the input vars because it is
     2095     * called before wp_magic_quotes() gets called. Besides this fact, none of
     2096     * the values should contain any characters needing slashes anyway.
     2097     */
     2098    $keys = array( 'changeset_uuid', 'customize_changeset_uuid', 'customize_theme', 'theme', 'customize_messenger_channel' );
     2099    $input_vars = array_merge(
     2100        wp_array_slice_assoc( $_GET, $keys ),
     2101        wp_array_slice_assoc( $_POST, $keys )
     2102    );
     2103
     2104    $theme = null;
     2105    $changeset_uuid = null;
     2106    $messenger_channel = null;
     2107
     2108    if ( $is_customize_admin_page && isset( $input_vars['changeset_uuid'] ) ) {
     2109        $changeset_uuid = sanitize_key( $input_vars['changeset_uuid'] );
     2110    } elseif ( ! empty( $input_vars['customize_changeset_uuid'] ) ) {
     2111        $changeset_uuid = sanitize_key( $input_vars['customize_changeset_uuid'] );
     2112    }
     2113
     2114    // Note that theme will be sanitized via WP_Theme.
     2115    if ( $is_customize_admin_page && isset( $input_vars['theme'] ) ) {
     2116        $theme = $input_vars['theme'];
     2117    } elseif ( isset( $input_vars['customize_theme'] ) ) {
     2118        $theme = $input_vars['customize_theme'];
     2119    }
     2120    if ( isset( $input_vars['customize_messenger_channel'] ) ) {
     2121        $messenger_channel = sanitize_key( $input_vars['customize_messenger_channel'] );
     2122    }
     2123
     2124    require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2125    $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel' ) );
     2126}
     2127
     2128/**
     2129 * Publish a snapshot's changes.
     2130 *
     2131 * @param string  $new_status     New post status.
     2132 * @param string  $old_status     Old post status.
     2133 * @param WP_Post $changeset_post Changeset post object.
     2134 */
     2135function _wp_customize_publish_changeset( $new_status, $old_status, $changeset_post ) {
     2136    global $wp_customize;
     2137
     2138    $is_publishing_changeset = (
     2139        'customize_changeset' === $changeset_post->post_type
     2140        &&
     2141        'publish' === $new_status
     2142        &&
     2143        'publish' !== $old_status
     2144    );
     2145    if ( ! $is_publishing_changeset ) {
     2146        return;
     2147    }
     2148
     2149    if ( empty( $wp_customize ) ) {
     2150        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2151        $wp_customize = new WP_Customize_Manager( $changeset_post->post_name );
     2152    }
     2153
     2154    if ( ! did_action( 'customize_register' ) ) {
     2155        /*
     2156         * When running from CLI or Cron, the customize_register action will need
     2157         * to be triggered in order for core, themes, and plugins to register their
     2158         * settings. Normally core will add_action( 'customize_register' ) at
     2159         * priority 10 to register the core settings, and if any themes/plugins
     2160         * also add_action( 'customize_register' ) at the same priority, they
     2161         * will have a $wp_customize with those settings registered since they
     2162         * call add_action() afterward, normally. However, when manually doing
     2163         * the customize_register action after the setup_theme, then the order
     2164         * will be reversed for two actions added at priority 10, resulting in
     2165         * the core settings no longer being available as expected to themes/plugins.
     2166         * So the following manually calls the method that registers the core
     2167         * settings up front before doing the action.
     2168         */
     2169        remove_action( 'customize_register', array( $wp_customize, 'register_controls' ) );
     2170        $wp_customize->register_controls();
     2171
     2172        /** This filter is documented in /wp-includes/class-wp-customize-manager.php */
     2173        do_action( 'customize_register', $wp_customize );
     2174    }
     2175    $wp_customize->_publish_changeset_values( $changeset_post->ID ) ;
     2176
     2177    /*
     2178     * Trash the changeset post if revisions are not enabled. Unpublished
     2179     * changesets by default get garbage collected due to the auto-draft status.
     2180     * When a changeset post is published, however, it would no longer get cleaned
     2181     * out. Ths is a problem when the changeset posts are never displayed anywhere,
     2182     * since they would just be endlessly piling up. So here we use the revisions
     2183     * feature to indicate whether or not a published changeset should get trashed
     2184     * and thus garbage collected.
     2185     */
     2186    if ( ! wp_revisions_enabled( $changeset_post ) ) {
     2187        wp_trash_post( $changeset_post->ID );
     2188    }
     2189}
     2190
     2191/**
     2192 * Filters changeset post data upon insert to ensure post_name is intact.
     2193 *
     2194 * This is needed to prevent the post_name from being dropped when the post is
     2195 * transitioned into pending status by a contributor.
     2196 *
     2197 * @since 4.7.0
     2198 * @see wp_insert_post()
     2199 *
     2200 * @param array $post_data          An array of slashed post data.
     2201 * @param array $supplied_post_data An array of sanitized, but otherwise unmodified post data.
     2202 * @returns array Filtered data.
     2203 */
     2204function _wp_customize_changeset_filter_insert_post_data( $post_data, $supplied_post_data ) {
     2205    if ( isset( $post_data['post_type'] ) && 'customize_changeset' === $post_data['post_type'] ) {
     2206
     2207        // Prevent post_name from being dropped, such as when contributor saves a changeset post as pending.
     2208        if ( empty( $post_data['post_name'] ) && ! empty( $supplied_post_data['post_name'] ) ) {
     2209            $post_data['post_name'] = $supplied_post_data['post_name'];
     2210        }
     2211    }
     2212    return $post_data;
    20872213}
    20882214
  • trunk/tests/phpunit/tests/adminbar.php

    r38708 r38810  
    551551        $this->assertNull( $node );
    552552    }
     553
     554    /**
     555     * @ticket 30937
     556     * @covers wp_admin_bar_customize_menu()
     557     */
     558    public function test_customize_link() {
     559        global $wp_customize;
     560        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     561        $uuid = wp_generate_uuid4();
     562        $this->go_to( home_url( "/?customize_changeset_uuid=$uuid" ) );
     563        wp_set_current_user( self::$admin_id );
     564
     565        $this->factory()->post->create( array(
     566            'post_type' => 'customize_changeset',
     567            'post_status' => 'auto-draft',
     568            'post_name' => $uuid,
     569        ) );
     570        $wp_customize = new WP_Customize_Manager( array(
     571            'changeset_uuid' => $uuid,
     572        ) );
     573        $wp_customize->start_previewing_theme();
     574
     575        set_current_screen( 'front' );
     576        $wp_admin_bar = $this->get_standard_admin_bar();
     577        $node = $wp_admin_bar->get_node( 'customize' );
     578        $this->assertNotEmpty( $node );
     579
     580        $parsed_url = wp_parse_url( $node->href );
     581        $query_params = array();
     582        wp_parse_str( $parsed_url['query'], $query_params );
     583        $this->assertEquals( $uuid, $query_params['changeset_uuid'] );
     584        $this->assertNotContains( 'changeset_uuid', $query_params['url'] );
     585    }
    553586}
  • trunk/tests/phpunit/tests/customize/manager.php

    r38765 r38810  
    2828
    2929    /**
     30     * Admin user ID.
     31     *
     32     * @var int
     33     */
     34    protected static $admin_user_id;
     35
     36    /**
     37     * Subscriber user ID.
     38     *
     39     * @var int
     40     */
     41    protected static $subscriber_user_id;
     42
     43    /**
     44     * Set up before class.
     45     *
     46     * @param WP_UnitTest_Factory $factory Factory.
     47     */
     48    public static function wpSetUpBeforeClass( $factory ) {
     49        self::$subscriber_user_id = $factory->user->create( array( 'role' => 'subscriber' ) );
     50        self::$admin_user_id = $factory->user->create( array( 'role' => 'administrator' ) );
     51    }
     52
     53    /**
    3054     * Set up test.
    3155     */
     
    4367        $this->manager = null;
    4468        unset( $GLOBALS['wp_customize'] );
     69        $_REQUEST = array();
    4570        parent::tearDown();
    4671    }
     
    5479        $GLOBALS['wp_customize'] = new WP_Customize_Manager();
    5580        return $GLOBALS['wp_customize'];
     81    }
     82
     83    /**
     84     * Test WP_Customize_Manager::__construct().
     85     *
     86     * @covers WP_Customize_Manager::__construct()
     87     */
     88    function test_constructor() {
     89        $uuid = wp_generate_uuid4();
     90        $theme = 'twentyfifteen';
     91        $messenger_channel = 'preview-123';
     92        $wp_customize = new WP_Customize_Manager( array(
     93            'changeset_uuid' => $uuid,
     94            'theme' => $theme,
     95            'messenger_channel' => $messenger_channel,
     96        ) );
     97        $this->assertEquals( $uuid, $wp_customize->changeset_uuid() );
     98        $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     99        $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() );
     100
     101        $theme = 'twentyfourteen';
     102        $messenger_channel = 'preview-456';
     103        $_REQUEST['theme'] = $theme;
     104        $_REQUEST['customize_messenger_channel'] = $messenger_channel;
     105        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     106        $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     107        $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() );
     108
     109        $theme = 'twentyfourteen';
     110        $_REQUEST['customize_theme'] = $theme;
     111        $wp_customize = new WP_Customize_Manager();
     112        $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     113        $this->assertNotEmpty( $wp_customize->changeset_uuid() );
     114    }
     115
     116    /**
     117     * Test WP_Customize_Manager::setup_theme() for admin screen.
     118     *
     119     * @covers WP_Customize_Manager::setup_theme()
     120     */
     121    function test_setup_theme_in_customize_admin() {
     122        global $pagenow, $wp_customize;
     123        $pagenow = 'customize.php';
     124        set_current_screen( 'customize' );
     125
     126        // Unauthorized.
     127        $exception = null;
     128        $wp_customize = new WP_Customize_Manager();
     129        wp_set_current_user( self::$subscriber_user_id );
     130        try {
     131            $wp_customize->setup_theme();
     132        } catch ( Exception $e ) {
     133            $exception = $e;
     134        }
     135        $this->assertInstanceOf( 'WPDieException', $exception );
     136        $this->assertContains( 'you are not allowed to customize this site', $exception->getMessage() );
     137
     138        // Bad changeset.
     139        $exception = null;
     140        wp_set_current_user( self::$admin_user_id );
     141        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => 'bad' ) );
     142        try {
     143            $wp_customize->setup_theme();
     144        } catch ( Exception $e ) {
     145            $exception = $e;
     146        }
     147        $this->assertInstanceOf( 'WPDieException', $exception );
     148        $this->assertContains( 'Invalid changeset UUID', $exception->getMessage() );
     149
     150        $wp_customize = new WP_Customize_Manager();
     151        $wp_customize->setup_theme();
     152    }
     153
     154    /**
     155     * Test WP_Customize_Manager::setup_theme() for frontend.
     156     *
     157     * @covers WP_Customize_Manager::setup_theme()
     158     */
     159    function test_setup_theme_in_frontend() {
     160        global $wp_customize, $pagenow, $show_admin_bar;
     161        $pagenow = 'front';
     162        set_current_screen( 'front' );
     163
     164        wp_set_current_user( 0 );
     165        $exception = null;
     166        $wp_customize = new WP_Customize_Manager();
     167        wp_set_current_user( self::$subscriber_user_id );
     168        try {
     169            $wp_customize->setup_theme();
     170        } catch ( Exception $e ) {
     171            $exception = $e;
     172        }
     173        $this->assertInstanceOf( 'WPDieException', $exception );
     174        $this->assertContains( 'Non-existent changeset UUID', $exception->getMessage() );
     175
     176        wp_set_current_user( self::$admin_user_id );
     177        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-1' ) );
     178        $wp_customize->setup_theme();
     179        $this->assertFalse( $show_admin_bar );
     180
     181        show_admin_bar( true );
     182        wp_set_current_user( self::$admin_user_id );
     183        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => null ) );
     184        $wp_customize->setup_theme();
     185        $this->assertTrue( $show_admin_bar );
     186    }
     187
     188    /**
     189     * Test WP_Customize_Manager::changeset_uuid().
     190     *
     191     * @ticket 30937
     192     * @covers WP_Customize_Manager::changeset_uuid()
     193     */
     194    function test_changeset_uuid() {
     195        $uuid = wp_generate_uuid4();
     196        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     197        $this->assertEquals( $uuid, $wp_customize->changeset_uuid() );
     198    }
     199
     200    /**
     201     * Test WP_Customize_Manager::wp_loaded().
     202     *
     203     * Ensure that post values are previewed even without being in preview.
     204     *
     205     * @ticket 30937
     206     * @covers WP_Customize_Manager::wp_loaded()
     207     */
     208    function test_wp_loaded() {
     209        wp_set_current_user( self::$admin_user_id );
     210        $wp_customize = new WP_Customize_Manager();
     211        $title = 'Hello World';
     212        $wp_customize->set_post_value( 'blogname', $title );
     213        $this->assertNotEquals( $title, get_option( 'blogname' ) );
     214        $wp_customize->wp_loaded();
     215        $this->assertFalse( $wp_customize->is_preview() );
     216        $this->assertEquals( $title, $wp_customize->get_setting( 'blogname' )->value() );
     217        $this->assertEquals( $title, get_option( 'blogname' ) );
     218    }
     219
     220    /**
     221     * Test WP_Customize_Manager::find_changeset_post_id().
     222     *
     223     * @ticket 30937
     224     * @covers WP_Customize_Manager::find_changeset_post_id()
     225     */
     226    function test_find_changeset_post_id() {
     227        $uuid = wp_generate_uuid4();
     228        $post_id = $this->factory()->post->create( array(
     229            'post_name' => $uuid,
     230            'post_type' => 'customize_changeset',
     231            'post_status' => 'auto-draft',
     232            'post_content' => '{}',
     233        ) );
     234
     235        $wp_customize = new WP_Customize_Manager();
     236        $this->assertNull( $wp_customize->find_changeset_post_id( wp_generate_uuid4() ) );
     237        $this->assertEquals( $post_id, $wp_customize->find_changeset_post_id( $uuid ) );
     238    }
     239
     240    /**
     241     * Test WP_Customize_Manager::changeset_post_id().
     242     *
     243     * @ticket 30937
     244     * @covers WP_Customize_Manager::changeset_post_id()
     245     */
     246    function test_changeset_post_id() {
     247        $uuid = wp_generate_uuid4();
     248        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     249        $this->assertNull( $wp_customize->changeset_post_id() );
     250
     251        $uuid = wp_generate_uuid4();
     252        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     253        $post_id = $this->factory()->post->create( array(
     254            'post_name' => $uuid,
     255            'post_type' => 'customize_changeset',
     256            'post_status' => 'auto-draft',
     257            'post_content' => '{}',
     258        ) );
     259        $this->assertEquals( $post_id, $wp_customize->changeset_post_id() );
     260    }
     261
     262    /**
     263     * Test WP_Customize_Manager::changeset_data().
     264     *
     265     * @ticket 30937
     266     * @covers WP_Customize_Manager::changeset_data()
     267     */
     268    function test_changeset_data() {
     269        $uuid = wp_generate_uuid4();
     270        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     271        $this->assertEquals( array(), $wp_customize->changeset_data() );
     272
     273        $uuid = wp_generate_uuid4();
     274        $data = array( 'blogname' => array( 'value' => 'Hello World' ) );
     275        $this->factory()->post->create( array(
     276            'post_name' => $uuid,
     277            'post_type' => 'customize_changeset',
     278            'post_status' => 'auto-draft',
     279            'post_content' => wp_json_encode( $data ),
     280        ) );
     281        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     282        $this->assertEquals( $data, $wp_customize->changeset_data() );
     283    }
     284
     285    /**
     286     * Test WP_Customize_Manager::customize_preview_init().
     287     *
     288     * @ticket 30937
     289     * @covers WP_Customize_Manager::customize_preview_init()
     290     */
     291    function test_customize_preview_init() {
     292
     293        // Test authorized admin user.
     294        wp_set_current_user( self::$admin_user_id );
     295        $did_action_customize_preview_init = did_action( 'customize_preview_init' );
     296        $wp_customize = new WP_Customize_Manager();
     297        $wp_customize->customize_preview_init();
     298        $this->assertEquals( $did_action_customize_preview_init + 1, did_action( 'customize_preview_init' ) );
     299
     300        $this->assertEquals( 10, has_action( 'wp_head', 'wp_no_robots' ) );
     301        $this->assertEquals( 10, has_filter( 'wp_headers', array( $wp_customize, 'filter_iframe_security_headers' ) ) );
     302        $this->assertEquals( 10, has_filter( 'wp_redirect', array( $wp_customize, 'add_state_query_params' ) ) );
     303        $this->assertTrue( wp_script_is( 'customize-preview', 'enqueued' ) );
     304        $this->assertEquals( 10, has_action( 'wp_head', array( $wp_customize, 'customize_preview_loading_style' ) ) );
     305        $this->assertEquals( 20, has_action( 'wp_footer', array( $wp_customize, 'customize_preview_settings' ) ) );
     306
     307        // Test unauthorized user outside preview (no messenger_channel).
     308        wp_set_current_user( self::$subscriber_user_id );
     309        $wp_customize = new WP_Customize_Manager();
     310        $wp_customize->register_controls();
     311        $this->assertNotEmpty( $wp_customize->controls() );
     312        $wp_customize->customize_preview_init();
     313        $this->assertEmpty( $wp_customize->controls() );
     314
     315        // Test unauthorized user inside preview (with messenger_channel).
     316        wp_set_current_user( self::$subscriber_user_id );
     317        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-0' ) );
     318        $exception = null;
     319        try {
     320            $wp_customize->customize_preview_init();
     321        } catch ( WPDieException $e ) {
     322            $exception = $e;
     323        }
     324        $this->assertNotNull( $exception );
     325        $this->assertContains( 'Unauthorized', $exception->getMessage() );
     326    }
     327
     328    /**
     329     * Test WP_Customize_Manager::filter_iframe_security_headers().
     330     *
     331     * @ticket 30937
     332     * @covers WP_Customize_Manager::filter_iframe_security_headers()
     333     */
     334    function test_filter_iframe_security_headers() {
     335        $customize_url = admin_url( 'customize.php' );
     336        $wp_customize = new WP_Customize_Manager();
     337        $headers = $wp_customize->filter_iframe_security_headers( array() );
     338        $this->assertArrayHasKey( 'X-Frame-Options', $headers );
     339        $this->assertArrayHasKey( 'Content-Security-Policy', $headers );
     340        $this->assertEquals( "ALLOW-FROM $customize_url", $headers['X-Frame-Options'] );
     341    }
     342
     343    /**
     344     * Test WP_Customize_Manager::add_state_query_params().
     345     *
     346     * @ticket 30937
     347     * @covers WP_Customize_Manager::add_state_query_params()
     348     */
     349    function test_add_state_query_params() {
     350        $uuid = wp_generate_uuid4();
     351        $messenger_channel = 'preview-0';
     352        $wp_customize = new WP_Customize_Manager( array(
     353            'changeset_uuid' => $uuid,
     354            'messenger_channel' => $messenger_channel,
     355        ) );
     356        $url = $wp_customize->add_state_query_params( home_url( '/' ) );
     357        $parsed_url = wp_parse_url( $url );
     358        parse_str( $parsed_url['query'], $query_params );
     359        $this->assertArrayHasKey( 'customize_messenger_channel', $query_params );
     360        $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params );
     361        $this->assertArrayNotHasKey( 'customize_theme', $query_params );
     362        $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] );
     363        $this->assertEquals( $messenger_channel, $query_params['customize_messenger_channel'] );
     364
     365        $uuid = wp_generate_uuid4();
     366        $wp_customize = new WP_Customize_Manager( array(
     367            'changeset_uuid' => $uuid,
     368            'messenger_channel' => null,
     369            'theme' => 'twentyfifteen',
     370        ) );
     371        $url = $wp_customize->add_state_query_params( home_url( '/' ) );
     372        $parsed_url = wp_parse_url( $url );
     373        parse_str( $parsed_url['query'], $query_params );
     374        $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params );
     375        $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params );
     376        $this->assertArrayHasKey( 'customize_theme', $query_params );
     377        $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] );
     378        $this->assertEquals( 'twentyfifteen', $query_params['customize_theme'] );
     379
     380        $uuid = wp_generate_uuid4();
     381        $wp_customize = new WP_Customize_Manager( array(
     382            'changeset_uuid' => $uuid,
     383            'messenger_channel' => null,
     384            'theme' => 'twentyfifteen',
     385        ) );
     386        $url = $wp_customize->add_state_query_params( 'http://not-allowed.example.com/?q=1' );
     387        $parsed_url = wp_parse_url( $url );
     388        parse_str( $parsed_url['query'], $query_params );
     389        $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params );
     390        $this->assertArrayNotHasKey( 'customize_changeset_uuid', $query_params );
     391        $this->assertArrayNotHasKey( 'customize_theme', $query_params );
     392    }
     393
     394    /**
     395     * Test WP_Customize_Manager::save_changeset_post().
     396     *
     397     * @ticket 30937
     398     * @covers WP_Customize_Manager::save_changeset_post()
     399     */
     400    function test_save_changeset_post_without_theme_activation() {
     401        wp_set_current_user( self::$admin_user_id );
     402
     403        $did_action = array(
     404            'customize_save_validation_before' => did_action( 'customize_save_validation_before' ),
     405            'customize_save' => did_action( 'customize_save' ),
     406            'customize_save_after' => did_action( 'customize_save_after' ),
     407        );
     408        $uuid = wp_generate_uuid4();
     409
     410        $manager = new WP_Customize_Manager( array(
     411            'changeset_uuid' => $uuid,
     412        ) );
     413        $manager->register_controls();
     414        $manager->set_post_value( 'blogname', 'Changeset Title' );
     415        $manager->set_post_value( 'blogdescription', 'Changeset Tagline' );
     416
     417        $r = $manager->save_changeset_post( array(
     418            'status' => 'auto-draft',
     419            'title' => 'Auto Draft',
     420            'date_gmt' => '2010-01-01 00:00:00',
     421            'data' => array(
     422                'blogname' => array(
     423                    'value' => 'Overridden Changeset Title',
     424                ),
     425                'blogdescription' => array(
     426                    'custom' => 'something',
     427                ),
     428            ),
     429        ) );
     430        $this->assertInternalType( 'array', $r );
     431
     432        $this->assertEquals( $did_action['customize_save_validation_before'] + 1, did_action( 'customize_save_validation_before' ) );
     433
     434        $post_id = $manager->find_changeset_post_id( $uuid );
     435        $this->assertNotNull( $post_id );
     436        $saved_data = json_decode( get_post( $post_id )->post_content, true );
     437        $this->assertEquals( $manager->unsanitized_post_values(), wp_list_pluck( $saved_data, 'value' ) );
     438        $this->assertEquals( 'Overridden Changeset Title', $saved_data['blogname']['value'] );
     439        $this->assertEquals( 'something', $saved_data['blogdescription']['custom'] );
     440        $this->assertEquals( 'Auto Draft', get_post( $post_id )->post_title );
     441        $this->assertEquals( 'auto-draft', get_post( $post_id )->post_status );
     442        $this->assertEquals( '2010-01-01 00:00:00', get_post( $post_id )->post_date_gmt );
     443        $this->assertNotEquals( 'Changeset Title', get_option( 'blogname' ) );
     444        $this->assertArrayHasKey( 'setting_validities', $r );
     445
     446        // Test saving with invalid settings, ensuring transaction blocked.
     447        $previous_saved_data = $saved_data;
     448        $manager->add_setting( 'foo_unauthorized', array(
     449            'capability' => 'do_not_allow',
     450        ) );
     451        $manager->add_setting( 'baz_illegal', array(
     452            'validate_callback' => array( $this, 'return_illegal_error' ),
     453        ) );
     454        $r = $manager->save_changeset_post( array(
     455            'status' => 'auto-draft',
     456            'data' => array(
     457                'blogname' => array(
     458                    'value' => 'OK',
     459                ),
     460                'foo_unauthorized' => array(
     461                    'value' => 'No',
     462                ),
     463                'bar_unknown' => array(
     464                    'value' => 'No',
     465                ),
     466                'baz_illegal' => array(
     467                    'value' => 'No',
     468                ),
     469            ),
     470        ) );
     471        $this->assertInstanceOf( 'WP_Error', $r );
     472        $this->assertEquals( 'transaction_fail', $r->get_error_code() );
     473        $this->assertInternalType( 'array', $r->get_error_data() );
     474        $this->assertArrayHasKey( 'setting_validities', $r->get_error_data() );
     475        $error_data = $r->get_error_data();
     476        $this->assertArrayHasKey( 'blogname', $error_data['setting_validities'] );
     477        $this->assertTrue( $error_data['setting_validities']['blogname'] );
     478        $this->assertArrayHasKey( 'foo_unauthorized', $error_data['setting_validities'] );
     479        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['foo_unauthorized'] );
     480        $this->assertEquals( 'unauthorized', $error_data['setting_validities']['foo_unauthorized']->get_error_code() );
     481        $this->assertArrayHasKey( 'bar_unknown', $error_data['setting_validities'] );
     482        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['bar_unknown'] );
     483        $this->assertEquals( 'unrecognized', $error_data['setting_validities']['bar_unknown']->get_error_code() );
     484        $this->assertArrayHasKey( 'baz_illegal', $error_data['setting_validities'] );
     485        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['baz_illegal'] );
     486        $this->assertEquals( 'illegal', $error_data['setting_validities']['baz_illegal']->get_error_code() );
     487
     488        // Since transactional, ensure no changes have been made.
     489        $this->assertEquals( $previous_saved_data, json_decode( get_post( $post_id )->post_content, true ) );
     490
     491        // Attempt a non-transactional/incremental update.
     492        $manager = new WP_Customize_Manager( array(
     493            'changeset_uuid' => $uuid,
     494        ) );
     495        $manager->register_controls(); // That is, register settings.
     496        $r = $manager->save_changeset_post( array(
     497            'status' => null,
     498            'data' => array(
     499                'blogname' => array(
     500                    'value' => 'Non-Transactional \o/ <script>unsanitized</script>',
     501                ),
     502                'bar_unknown' => array(
     503                    'value' => 'No',
     504                ),
     505            ),
     506        ) );
     507        $this->assertInternalType( 'array', $r );
     508        $this->assertArrayHasKey( 'setting_validities', $r );
     509        $this->assertTrue( $r['setting_validities']['blogname'] );
     510        $this->assertInstanceOf( 'WP_Error', $r['setting_validities']['bar_unknown'] );
     511        $saved_data = json_decode( get_post( $post_id )->post_content, true );
     512        $this->assertNotEquals( $previous_saved_data, $saved_data );
     513        $this->assertEquals( 'Non-Transactional \o/ <script>unsanitized</script>', $saved_data['blogname']['value'] );
     514
     515        // Ensure the filter applies.
     516        $customize_changeset_save_data_call_count = $this->customize_changeset_save_data_call_count;
     517        add_filter( 'customize_changeset_save_data', array( $this, 'filter_customize_changeset_save_data' ), 10, 2 );
     518        $manager->save_changeset_post( array(
     519            'status' => null,
     520            'data' => array(
     521                'blogname' => array(
     522                    'value' => 'Filtered',
     523                ),
     524            ),
     525        ) );
     526        $this->assertEquals( $customize_changeset_save_data_call_count + 1, $this->customize_changeset_save_data_call_count );
     527
     528        // Publish the changeset.
     529        $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     530        $manager->register_controls();
     531        $GLOBALS['wp_customize'] = $manager;
     532        $r = $manager->save_changeset_post( array(
     533            'status' => 'publish',
     534            'data' => array(
     535                'blogname' => array(
     536                    'value' => 'Do it live \o/',
     537                ),
     538            ),
     539        ) );
     540        $this->assertInternalType( 'array', $r );
     541        $this->assertEquals( 'Do it live \o/', get_option( 'blogname' ) );
     542        $this->assertEquals( 'trash', get_post_status( $post_id ) ); // Auto-trashed.
     543
     544        // Test revisions.
     545        add_post_type_support( 'customize_changeset', 'revisions' );
     546        $uuid = wp_generate_uuid4();
     547        $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     548        $manager->register_controls();
     549        $GLOBALS['wp_customize'] = $manager;
     550
     551        $manager->set_post_value( 'blogname', 'Hello Surface' );
     552        $manager->save_changeset_post( array( 'status' => 'auto-draft' ) );
     553
     554        $manager->set_post_value( 'blogname', 'Hello World' );
     555        $manager->save_changeset_post( array( 'status' => 'draft' ) );
     556        $this->assertTrue( wp_revisions_enabled( get_post( $manager->changeset_post_id() ) ) );
     557
     558        $manager->set_post_value( 'blogname', 'Hello Solar System' );
     559        $manager->save_changeset_post( array( 'status' => 'draft' ) );
     560
     561        $manager->set_post_value( 'blogname', 'Hello Galaxy' );
     562        $manager->save_changeset_post( array( 'status' => 'draft' ) );
     563        $this->assertCount( 3, wp_get_post_revisions( $manager->changeset_post_id() ) );
     564    }
     565
     566    /**
     567     * Call count for customize_changeset_save_data filter.
     568     *
     569     * @var int
     570     */
     571    protected $customize_changeset_save_data_call_count = 0;
     572
     573    /**
     574     * Filter customize_changeset_save_data.
     575     *
     576     * @param array $data    Data.
     577     * @param array $context Context.
     578     * @returns array Data.
     579     */
     580    function filter_customize_changeset_save_data( $data, $context ) {
     581        $this->customize_changeset_save_data_call_count += 1;
     582        $this->assertInternalType( 'array', $data );
     583        $this->assertInternalType( 'array', $context );
     584        $this->assertArrayHasKey( 'uuid', $context );
     585        $this->assertArrayHasKey( 'title', $context );
     586        $this->assertArrayHasKey( 'status', $context );
     587        $this->assertArrayHasKey( 'date_gmt', $context );
     588        $this->assertArrayHasKey( 'post_id', $context );
     589        $this->assertArrayHasKey( 'previous_data', $context );
     590        $this->assertArrayHasKey( 'manager', $context );
     591        return $data;
     592    }
     593
     594    /**
     595     * Return illegal error.
     596     *
     597     * @return WP_Error Error.
     598     */
     599    function return_illegal_error() {
     600        return new WP_Error( 'illegal' );
     601    }
     602
     603    /**
     604     * Test WP_Customize_Manager::save_changeset_post().
     605     *
     606     * @ticket 30937
     607     * @covers WP_Customize_Manager::save_changeset_post()
     608     * @covers WP_Customize_Manager::update_stashed_theme_mod_settings()
     609     */
     610    function test_save_changeset_post_with_theme_activation() {
     611        wp_set_current_user( self::$admin_user_id );
     612
     613        $stashed_theme_mods = array(
     614            'twentyfifteen' => array(
     615                'background_color' => array(
     616                    'value' => '#123456',
     617                ),
     618            ),
     619        );
     620        update_option( 'customize_stashed_theme_mods', $stashed_theme_mods );
     621        $uuid = wp_generate_uuid4();
     622        $manager = new WP_Customize_Manager( array(
     623            'changeset_uuid' => $uuid,
     624            'theme' => 'twentyfifteen',
     625        ) );
     626        $manager->register_controls();
     627        $GLOBALS['wp_customize'] = $manager;
     628
     629        $manager->set_post_value( 'blogname', 'Hello 2015' );
     630        $post_values = $manager->unsanitized_post_values();
     631        $manager->save_changeset_post( array( 'status' => 'publish' ) ); // Activate.
     632
     633        $this->assertEquals( '#123456', $post_values['background_color'] );
     634        $this->assertEquals( 'twentyfifteen', get_stylesheet() );
     635        $this->assertEquals( 'Hello 2015', get_option( 'blogname' ) );
     636    }
     637
     638    /**
     639     * Test WP_Customize_Manager::is_cross_domain().
     640     *
     641     * @ticket 30937
     642     * @covers WP_Customize_Manager::is_cross_domain()
     643     */
     644    function test_is_cross_domain() {
     645        $wp_customize = new WP_Customize_Manager();
     646
     647        update_option( 'home', 'http://example.com' );
     648        update_option( 'siteurl', 'http://example.com' );
     649        $this->assertFalse( $wp_customize->is_cross_domain() );
     650
     651        update_option( 'home', 'http://example.com' );
     652        update_option( 'siteurl', 'https://admin.example.com' );
     653        $this->assertTrue( $wp_customize->is_cross_domain() );
     654    }
     655
     656    /**
     657     * Test WP_Customize_Manager::get_allowed_urls().
     658     *
     659     * @ticket 30937
     660     * @covers WP_Customize_Manager::get_allowed_urls()
     661     */
     662    function test_get_allowed_urls() {
     663        $wp_customize = new WP_Customize_Manager();
     664        $this->assertFalse( is_ssl() );
     665        $this->assertFalse( $wp_customize->is_cross_domain() );
     666        $allowed = $wp_customize->get_allowed_urls();
     667        $this->assertEquals( $allowed, array( home_url( '/', 'http' ) ) );
     668
     669        add_filter( 'customize_allowed_urls', array( $this, 'filter_customize_allowed_urls' ) );
     670        $allowed = $wp_customize->get_allowed_urls();
     671        $this->assertEqualSets( $allowed, array( 'http://headless.example.com/', home_url( '/', 'http' ) ) );
     672    }
     673
     674    /**
     675     * Callback for customize_allowed_urls filter.
     676     *
     677     * @param array $urls URLs.
     678     * @return array URLs.
     679     */
     680    function filter_customize_allowed_urls( $urls ) {
     681        $urls[] = 'http://headless.example.com/';
     682        return $urls;
    56683    }
    57684
     
    91718     * @ticket 30988
    92719     */
    93     function test_unsanitized_post_values() {
     720    function test_unsanitized_post_values_from_input() {
     721        wp_set_current_user( self::$admin_user_id );
    94722        $manager = $this->manager;
    95723
     
    101729        $post_values = $manager->unsanitized_post_values();
    102730        $this->assertEquals( $customized, $post_values );
     731        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) );
     732
     733        $manager->set_post_value( 'foo', 'BAR' );
     734        $post_values = $manager->unsanitized_post_values();
     735        $this->assertEquals( 'BAR', $post_values['foo'] );
     736        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) );
     737
     738        // If user is unprivileged, the post data is ignored.
     739        wp_set_current_user( 0 );
     740        $this->assertEmpty( $manager->unsanitized_post_values() );
     741    }
     742
     743    /**
     744     * Test WP_Customize_Manager::unsanitized_post_values().
     745     *
     746     * @ticket 30937
     747     * @covers WP_Customize_Manager::unsanitized_post_values()
     748     */
     749    function test_unsanitized_post_values_with_changeset_and_stashed_theme_mods() {
     750        wp_set_current_user( self::$admin_user_id );
     751
     752        $stashed_theme_mods = array(
     753            'twentyfifteen' => array(
     754                'background_color' => array(
     755                    'value' => '#000000',
     756                ),
     757            ),
     758        );
     759        $stashed_theme_mods[ get_stylesheet() ] = array(
     760            'background_color' => array(
     761                'value' => '#FFFFFF',
     762            ),
     763        );
     764        update_option( 'customize_stashed_theme_mods', $stashed_theme_mods );
     765
     766        $post_values = array(
     767            'blogdescription' => 'Post Input Tagline',
     768        );
     769        $_POST['customized'] = wp_slash( wp_json_encode( $post_values ) );
     770
     771        $uuid = wp_generate_uuid4();
     772        $changeset_data = array(
     773            'blogname' => array(
     774                'value' => 'Changeset Title',
     775            ),
     776            'blogdescription' => array(
     777                'value' => 'Changeset Tagline',
     778            ),
     779        );
     780        $this->factory()->post->create( array(
     781            'post_type' => 'customize_changeset',
     782            'post_status' => 'auto-draft',
     783            'post_name' => $uuid,
     784            'post_content' => wp_json_encode( $changeset_data ),
     785        ) );
     786
     787        $manager = new WP_Customize_Manager( array(
     788            'changeset_uuid' => $uuid,
     789        ) );
     790        $this->assertTrue( $manager->is_theme_active() );
     791
     792        $this->assertArrayNotHasKey( 'background_color', $manager->unsanitized_post_values() );
     793
     794        $this->assertEquals(
     795            array(
     796                'blogname' => 'Changeset Title',
     797                'blogdescription' => 'Post Input Tagline',
     798            ),
     799            $manager->unsanitized_post_values()
     800        );
     801        $this->assertEquals(
     802            array(
     803                'blogdescription' => 'Post Input Tagline',
     804            ),
     805            $manager->unsanitized_post_values( array( 'exclude_changeset' => true ) )
     806        );
     807
     808        $manager->set_post_value( 'blogdescription', 'Post Override Tagline' );
     809        $this->assertEquals(
     810            array(
     811                'blogname' => 'Changeset Title',
     812                'blogdescription' => 'Post Override Tagline',
     813            ),
     814            $manager->unsanitized_post_values()
     815        );
     816
     817        $this->assertEquals(
     818            array(
     819                'blogname' => 'Changeset Title',
     820                'blogdescription' => 'Changeset Tagline',
     821            ),
     822            $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) )
     823        );
     824
     825        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) ) );
     826
     827        // Test unstashing theme mods.
     828        $manager = new WP_Customize_Manager( array(
     829            'changeset_uuid' => $uuid,
     830            'theme' => 'twentyfifteen',
     831        ) );
     832        $this->assertFalse( $manager->is_theme_active() );
     833        $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) );
     834        $this->assertNotEmpty( $values );
     835        $this->assertArrayHasKey( 'background_color', $values );
     836        $this->assertEquals( '#000000', $values['background_color'] );
     837
     838        $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => false, 'exclude_changeset' => false ) );
     839        $this->assertArrayHasKey( 'background_color', $values );
     840        $this->assertArrayHasKey( 'blogname', $values );
     841        $this->assertArrayHasKey( 'blogdescription', $values );
    103842    }
    104843
     
    109848     */
    110849    function test_post_value() {
     850        wp_set_current_user( self::$admin_user_id );
    111851        $posted_settings = array(
    112852            'foo' => 'OOF',
     
    132872     */
    133873    function test_invalid_post_value() {
     874        wp_set_current_user( self::$admin_user_id );
    134875        $default_value = 'foo_default';
    135876        $setting = $this->manager->add_setting( 'foo', array(
     
    197938     */
    198939    function test_post_value_validation_sanitization_order() {
     940        wp_set_current_user( self::$admin_user_id );
    199941        $default_value = '0';
    200942        $setting = $this->manager->add_setting( 'numeric', array(
     
    241983     */
    242984    function test_validate_setting_values() {
     985        wp_set_current_user( self::$admin_user_id );
    243986        $setting = $this->manager->add_setting( 'foo', array(
    244987            'validate_callback' => array( $this, 'filter_customize_validate_foo' ),
     
    3061049
    3071050    /**
     1051     * Test WP_Customize_Manager::validate_setting_values().
     1052     *
     1053     * @ticket 30937
     1054     * @covers WP_Customize_Manager::validate_setting_values()
     1055     */
     1056    function test_validate_setting_values_args() {
     1057        wp_set_current_user( self::$admin_user_id );
     1058        $this->manager->register_controls();
     1059
     1060        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ) );
     1061        $this->assertEmpty( $validities );
     1062
     1063        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => false ) );
     1064        $this->assertEmpty( $validities );
     1065
     1066        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => true ) );
     1067        $this->assertNotEmpty( $validities );
     1068        $this->assertArrayHasKey( 'unknown', $validities );
     1069        $error = $validities['unknown'];
     1070        $this->assertInstanceOf( 'WP_Error', $error );
     1071        $this->assertEquals( 'unrecognized', $error->get_error_code() );
     1072
     1073        $this->manager->get_setting( 'blogname' )->capability = 'do_not_allow';
     1074        $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => false ) );
     1075        $this->assertArrayHasKey( 'blogname', $validities );
     1076        $this->assertTrue( $validities['blogname'] );
     1077        $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => true ) );
     1078        $this->assertArrayHasKey( 'blogname', $validities );
     1079        $error = $validities['blogname'];
     1080        $this->assertInstanceOf( 'WP_Error', $error );
     1081        $this->assertEquals( 'unauthorized', $error->get_error_code() );
     1082    }
     1083
     1084    /**
    3081085     * Add a length constraint to a setting.
    3091086     *
     
    3291106     */
    3301107    function test_validate_setting_values_validation_sanitization_order() {
     1108        wp_set_current_user( self::$admin_user_id );
    3311109        $setting = $this->manager->add_setting( 'numeric', array(
    3321110            'validate_callback' => array( $this, 'filter_customize_validate_numeric' ),
     
    3701148     */
    3711149    function test_set_post_value() {
     1150        wp_set_current_user( self::$admin_user_id );
    3721151        $this->manager->add_setting( 'foo', array(
    3731152            'sanitize_callback' => array( $this, 'sanitize_foo_for_test_set_post_value' ),
     
    4741253        $this->assertFalse( $this->manager->has_published_pages() );
    4751254
    476         wp_set_current_user( $this->factory()->user->create( array( 'role' => 'editor' ) ) );
     1255        wp_set_current_user( self::$admin_user_id );
    4771256        $this->manager->nav_menus->customize_register();
    4781257        $setting_id = 'nav_menus_created_posts';
     
    4931272     */
    4941273    function test_register_dynamic_settings() {
     1274        wp_set_current_user( self::$admin_user_id );
    4951275        $posted_settings = array(
    4961276            'foo' => 'OOF',
     
    5921372        $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
    5931373
    594         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1374        wp_set_current_user( self::$admin_user_id );
    5951375        $this->assertTrue( current_user_can( 'edit_theme_options' ) );
    5961376        $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
     
    6851465     */
    6861466    function test_customize_pane_settings() {
    687         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1467        wp_set_current_user( self::$admin_user_id );
    6881468        $this->manager->register_controls();
    6891469        $this->manager->prepare_controls();
     
    7071487        $this->assertNotEmpty( $data );
    7081488
    709         $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices' ), array_keys( $data ) );
     1489        $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset', 'timeouts' ), array_keys( $data ) );
    7101490        $this->assertEquals( $autofocus, $data['autofocus'] );
    7111491        $this->assertArrayHasKey( 'save', $data['nonce'] );
     
    7191499     */
    7201500    function test_customize_preview_settings() {
    721         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1501        wp_set_current_user( self::$admin_user_id );
    7221502        $this->manager->register_controls();
    7231503        $this->manager->prepare_controls();
     
    7411521        $this->assertArrayHasKey( 'nonce', $settings );
    7421522        $this->assertArrayHasKey( '_dirty', $settings );
     1523        $this->assertArrayHasKey( 'timeouts', $settings );
     1524        $this->assertArrayHasKey( 'changeset', $settings );
    7431525
    7441526        $this->assertArrayHasKey( 'preview', $settings['nonce'] );
    745         $this->assertEquals( array( 'foo' ), $settings['_dirty'] );
    7461527    }
    7471528
     
    8151596        $manager->register_controls();
    8161597        $section_id = 'foo-section';
    817         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1598        wp_set_current_user( self::$admin_user_id );
    8181599        $manager->add_section( $section_id, array(
    8191600            'title'      => 'Section',
     
    8461627    function test_add_section_return_instance() {
    8471628        $manager = new WP_Customize_Manager();
    848         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1629        wp_set_current_user( self::$admin_user_id );
    8491630
    8501631        $section_id = 'foo-section';
     
    8731654    function test_add_setting_return_instance() {
    8741655        $manager = new WP_Customize_Manager();
    875         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1656        wp_set_current_user( self::$admin_user_id );
    8761657
    8771658        $setting_id = 'foo-setting';
     
    9441725    function test_add_panel_return_instance() {
    9451726        $manager = new WP_Customize_Manager();
    946         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1727        wp_set_current_user( self::$admin_user_id );
    9471728
    9481729        $panel_id = 'foo-panel';
     
    9711752        $manager = new WP_Customize_Manager();
    9721753        $section_id = 'foo-section';
    973         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1754        wp_set_current_user( self::$admin_user_id );
    9741755        $manager->add_section( $section_id, array(
    9751756            'title'    => 'Section',
  • trunk/tests/phpunit/tests/customize/selective-refresh-ajax.php

    r37700 r38810  
    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() {