Jump to content

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

From The Petit Planet Wiki
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
Line 3: Line 3:


     const PreloadEditor = {
     const PreloadEditor = {
         version: '0.5.0',
         version: '0.6.0',


         state: {
         state: {
Line 24: Line 24:
         status: null,
         status: null,
         editorTools: null,
         editorTools: null,
        /*
        * Browser-local draft storage.
        *
        * This is deliberately separate from the wiki.
        * Nothing is saved to MediaWiki until the user
        * explicitly clicks one of the Save buttons.
        */
        draftStorageKey:
            'PetitPlanet.PreloadEditor.drafts.v1',


         defaults: {
         defaults: {
Line 30: Line 40:
         },
         },


         // API
         // Browser-local draft storage


         async getPage(title) {
         getDraftStorage() {
             const response = await new mw.Api().get({
             try {
                action: 'query',
                const raw =
                prop: 'revisions',
                    window.localStorage.getItem(
                titles: title,
                        this.draftStorageKey
                rvprop: 'content',
                    );
                rvslots: 'main',
                formatversion: 2
            });


            const page =
                if (!raw) {
                response &&
                    return {
                response.query &&
                        active: null,
                response.query.pages &&
                        drafts: {}
                 response.query.pages[0];
                    };
                 }


            if (!page || page.missing) return null;
                const parsed =
                    JSON.parse(raw);


            const content =
                if (
                page.revisions &&
                    !parsed ||
                 page.revisions[0] &&
                    typeof parsed !== 'object' ||
                page.revisions[0].slots &&
                    Array.isArray(parsed)
                page.revisions[0].slots.main &&
                 ) {
                 page.revisions[0].slots.main.content;
                    return {
                        active: null,
                        drafts: {}
                    };
                 }


            return {
                if (
                title: page.title || title,
                    !parsed.drafts ||
                content:
                     typeof parsed.drafts !== 'object' ||
                     typeof content === 'string'
                    Array.isArray(parsed.drafts)
                        ? content
                ) {
                        : ''
                    parsed.drafts = {};
            };
                }
        },


        async pageExists(title) {
                return parsed;
            return !!await this.getPage(title);
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not read local drafts.',
                    error
                );
 
                return {
                    active: null,
                    drafts: {}
                };
            }
         },
         },


         async searchPages(query) {
         setDraftStorage(storage) {
             const response = await new mw.Api().get({
             try {
                 action: 'query',
                window.localStorage.setItem(
                 list: 'search',
                    this.draftStorageKey,
                 srsearch: query,
                    JSON.stringify(storage)
                srlimit: 10,
                 );
                srnamespace: 10,
 
                 formatversion: 2
                 return true;
            });
            } catch (error) {
                 console.warn(
                    'Preload Editor: Could not save local draft.',
                    error
                 );


            return (
                return false;
                response &&
             }
                response.query &&
                response.query.search
             ) || [];
         },
         },


         async savePage(
         getDraftId(title) {
            title,
             title =
            content,
                 String(title || '')
            summary,
                    .trim();
            createOnly
        ) {
             const params = {
                action: 'edit',
                 title,
                text: content,
                summary:
                    summary ||
                    'Updated with Preload Editor',
                formatversion: 2
            };


             if (createOnly) {
            /*
                 params.createonly = true;
            * Untitled drafts are still useful for a new preload
             }
            * before the user has entered/saved a title.
            */
             if (!title) {
                 return '__untitled__';
             }


             return new mw.Api().postWithToken(
             return title.toLowerCase();
                'csrf',
                params
            );
         },
         },


         // Preload parsing
         getDraftSnapshot() {
            let content =
                this.getEditorContent();
 
            /*
            * If CodeMirror is not mounted yet, use the state
            * value instead.
            */
            if (
                !this.editorView &&
                !content
            ) {
                content =
                    this.state.preloadContent ||
                    '';
            }
 
            let config = null;


        extractIncludeonly(source) {
            try {
             const match =
                config =
                 String(source || '').match(
                    this.state.config
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                        ? JSON.parse(
                            JSON.stringify(
                                this.state.config
                            )
                        )
                        : this.emptyConfig();
             } catch (error) {
                 console.warn(
                    'Preload Editor: Could not clone config for local draft.',
                     error
                 );
                 );


            return match
                 config =
                 ? match[1].trim()
                    this.emptyConfig();
                : String(source || '').trim();
            }
        },


        findVariables(content) {
            return {
            const variables = [];
                 version: 1,
            const pattern =
                 /%%([A-Za-z0-9_-]+)%%/g;


            let match;
                preloadTitle:
                    String(
                        this.state.preloadTitle ||
                        ''
                    ),


            while (
                preloadSource:
                (match = pattern.exec(content)) !==
                    String(
                null
                        this.state.preloadSource ||
            ) {
                        ''
                 const name =
                    ),
                     match[1].toUpperCase();
 
                 preloadContent:
                     String(
                        content ||
                        ''
                    ),
 
                config,


                 if (!variables.includes(name)) {
                 variables:
                    variables.push(name);
                    Array.isArray(
                }
                        this.state.variables
            }
                    )
                        ? this.state.variables.slice()
                        : [],


            return variables;
                conditions:
        },
                    Array.isArray(
                        this.state.conditions
                    )
                        ? this.state.conditions.slice()
                        : [],


        findConditions(content) {
                selectedVariable:
            const conditions = [];
                    String(
            const pattern =
                        this.state.selectedVariable ||
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                        'PAGE'
                    ),


            let match;
                mode:
                    this.state.mode ||
                    'edit',


            while (
                 dirty: true,
                 (match = pattern.exec(content)) !==
                null
            ) {
                conditions.push({
                    type:
                        match[1].toLowerCase(),
                    expression:
                        match[2].trim(),
                    index: match.index
                });
            }


             return conditions;
                savedAt:
                    Date.now()
             };
         },
         },


         findConditionVariables(content) {
         saveDraft() {
             const variables = [];
             /*
 
            * Do not save while the editor is merely showing
             this.findConditions(content).forEach(
            * a loaded/saved page with no local changes.
                condition => {
            */
                    if (
            if (!this.state.dirty) {
                        condition.type !==
                return;
                            'if' &&
            }
                        condition.type !==
 
                            'elseif'
             const snapshot =
                    ) {
                this.getDraftSnapshot();
                        return;
 
                    }
            const id =
                this.getDraftId(
                    snapshot.preloadTitle
                );
 
            const storage =
                this.getDraftStorage();


                    const matches =
            storage.drafts[id] =
                        condition.expression.match(
                snapshot;
                            /[A-Za-z0-9_-]+/g
                        ) || [];


                    matches.forEach(name => {
            /*
                        name =
            * Remember which draft was being worked on most
                            name.toUpperCase();
            * recently. This is what lets the editor restore
            * the right thing after a crash/reload.
            */
            storage.active = id;


                        if (
            this.setDraftStorage(
                            !variables.includes(
                 storage
                                name
                            )
                        ) {
                            variables.push(
                                name
                            );
                        }
                    });
                 }
             );
             );
            return variables;
         },
         },


         analyzePreload(content) {
         clearDraft(title) {
             return {
             const storage =
                 variables:
                 this.getDraftStorage();
                    this.findVariables(
                        content
                    ),


                 conditions:
            const id =
                    this.findConditions(
                 this.getDraftId(
                        content
                    title
                    ),
                );


                 conditionVariables:
            if (
                    this.findConditionVariables(
                 storage.drafts &&
                        content
                storage.drafts[id]
                    )
            ) {
            };
                delete storage.drafts[id];
        },
            }


        // Config
            if (
                storage.active === id
            ) {
                storage.active = null;
            }


        getAutomaticPageField() {
             this.setDraftStorage(
             return {
                 storage
                label: 'Page Name',
             );
                description: 'Name of the page.',
                 type: 'page',
                required: true,
                namespace: 'Main',
                'allow-new': true
             };
         },
         },


         emptyConfig() {
         getMostRecentDraft() {
             return {
             const storage =
                 settings: {
                 this.getDraftStorage();
                    incomplete: 'add-stub'
                },


                fields: {
                    PAGE:
                        this.getAutomaticPageField()
                }
            };
        },
        normalizeConfig(config) {
             if (
             if (
                 !config ||
                 !storage.drafts ||
                 typeof config !== 'object' ||
                 !Object.keys(
                 Array.isArray(config)
                    storage.drafts
                 ).length
             ) {
             ) {
                 config =
                 return null;
                    this.emptyConfig();
             }
             }


            /*
            * Prefer the explicitly active draft.
            */
             if (
             if (
                 !config.settings ||
                 storage.active &&
                 typeof config.settings !==
                 storage.drafts[
                     'object' ||
                     storage.active
                Array.isArray(
                 ]
                    config.settings
                 )
             ) {
             ) {
                 config.settings = {};
                 return {
            }
                    id: storage.active,
 
                     draft:
            if (
                        storage.drafts[
                !config.fields ||
                            storage.active
                typeof config.fields !==
                        ]
                     'object' ||
                 };
                Array.isArray(
                    config.fields
                )
            ) {
                 config.fields = {};
             }
             }


             /*
             /*
             * PAGE is always a special,
             * Fallback: find the most recently saved draft.
            * automatic field.
            *
            * Its configuration is precompiled
            * and cannot be edited.
             */
             */
             config.fields.PAGE =
             let latestId = null;
                this.getAutomaticPageField();
            let latestDraft = null;


             return config;
             Object.entries(
        },
                storage.drafts
            ).forEach(
                ([id, draft]) => {
                    if (
                        !draft ||
                        typeof draft !==
                            'object'
                    ) {
                        return;
                    }


        createDefaultField(
                    if (
            name,
                        !latestDraft ||
            analysis
                        (
        ) {
                            Number(
            return {
                                draft.savedAt
                label:
                            ) || 0
                     this.humanizeName(
                        ) >
                         name
                        (
                     ),
                            Number(
                                latestDraft.savedAt
                            ) || 0
                        )
                     ) {
                        latestId = id;
                         latestDraft = draft;
                     }
                }
            );


                 description: '',
            if (
                 !latestDraft
            ) {
                return null;
            }


                 type:
            return {
                    analysis.conditionVariables.includes(
                 id: latestId,
                        name
                draft: latestDraft
                    )
                        ? 'boolean'
                        : 'text'
             };
             };
         },
         },


         humanizeName(name) {
         restoreDraft() {
             return String(name || '')
             const result =
                 .replace(
                 this.getMostRecentDraft();
                    /[_-]+/g,
                    ' '
                )
                .replace(
                    /([a-z])([A-Z])/g,
                    '$1 $2'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim()
                .replace(
                    /\b\w/g,
                    char =>
                        char.toUpperCase()
                );
        },


        prepareConfigForVariables(
            if (
            config,
                !result ||
             analysis
                !result.draft
        ) {
             ) {
            config =
                 return false;
                 this.normalizeConfig(
            }
                    config
                );


             const existing =
             const draft =
                 config.fields || {};
                 result.draft;


             const fields = {};
             const title =
                String(
                    draft.preloadTitle ||
                    ''
                ).trim();


             /*
             const displayTitle =
            * PAGE is always automatic.
                title ||
            */
                 'an untitled preload';
            fields.PAGE =
                 this.getAutomaticPageField();


             /*
             const savedAt =
            * Add every variable currently
                draft.savedAt
            * detected in the preload.
                    ? new Date(
            *
                        draft.savedAt
            * Existing configuration is
                    ).toLocaleString()
            * preserved.
                     : '';
            *
            * New variables receive a
            * default configuration.
            */
            analysis.variables.forEach(
                name => {
                     if (name === 'PAGE') {
                        return;
                    }


                    fields[name] =
            const message =
                        existing[name] ||
                'Preload Editor found unsaved local work for ' +
                        this.createDefaultField(
                displayTitle +
                            name,
                (savedAt
                            analysis
                    ? '\n\nLast saved locally: ' +
                        );
                      savedAt
                 }
                    : '') +
            );
                 '\n\nRestore it?';


             /*
             const restore =
            * Preserve existing fields even
                window.confirm(
            * if they are currently unused.
                     message
            *
                );
            * This allows users to keep
            * configuration around while
            * editing the preload.
            */
            Object.keys(existing).forEach(
                name => {
                     const upper =
                        name.toUpperCase();


                    if (
            if (!restore) {
                        upper !== 'PAGE' &&
                /*
                        !fields[upper]
                * The user explicitly rejected recovery,
                    ) {
                * so remove that draft instead of asking
                        fields[upper] =
                * about the same stale work on every reload.
                            existing[name];
                */
                     }
                this.clearDraft(
                 }
                     title
            );
                 );


             config.fields = fields;
                return false;
             }


             return config;
             let config =
        },
                this.emptyConfig();
 
        validateConfig(
            config,
            analysis
        ) {
            const errors = [];
            const warnings = [];


             if (
             if (
                 !config ||
                 draft.config &&
                 typeof config !== 'object' ||
                 typeof draft.config ===
                 Array.isArray(config)
                    'object' &&
                 !Array.isArray(
                    draft.config
                )
             ) {
             ) {
                 return {
                 try {
                     valid: false,
                     config =
                     errors: [
                        this.normalizeConfig(
                         'Config must be an object.'
                            JSON.parse(
                    ],
                                JSON.stringify(
                     warnings: []
                                    draft.config
                 };
                                )
                            )
                        );
                } catch (
                    error
                ) {
                     console.warn(
                         'Preload Editor: Could not restore local config.',
                        error
                     );
                 }
             }
             }


             if (
             this.state = {
                !config.fields ||
                 preloadTitle:
                typeof config.fields !==
                     title,
                    'object' ||
                Array.isArray(
                    config.fields
                )
            ) {
                 return {
                    valid: false,
                     errors: [
                        '"fields" must be an object.'
                    ],
                    warnings
                };
            }


            const configured =
                 preloadSource:
                 Object.keys(
                     String(
                     config.fields
                        draft.preloadSource ||
                ).map(
                         ''
                    name =>
                    ),
                         name.toUpperCase()
                );


            analysis.variables.forEach(
                preloadContent:
                 name => {
                    String(
                     if (
                        draft.preloadContent ||
                         !configured.includes(
                        ''
                            name
                    ),
                         )
 
                     ) {
                config,
                        errors.push(
 
                            'Variable "' +
                variables:
                            name +
                    Array.isArray(
                            '" is not configured.'
                        draft.variables
                        );
                    )
                     }
                        ? draft.variables.slice()
                        : [],
 
                 conditions:
                     Array.isArray(
                         draft.conditions
                    )
                        ? draft.conditions.slice()
                        : [],
 
                selectedVariable:
                    String(
                         draft.selectedVariable ||
                        'PAGE'
                    ),
 
                mode:
                    draft.mode ||
                     'edit',
 
                dirty: true
            };
 
            /*
            * Reanalyze from the actual restored preload.
            * This also recreates any variable configuration
            * that may have been introduced before the crash.
            */
            this.reanalyze();
 
            this.renderEditor();
 
            this.setStatus(
                'Restored unsaved local work from this browser.',
                'warning'
            );
 
            mw.notify(
                'Unsaved Preload Editor work restored.',
                {
                     type: 'success'
                 }
                 }
             );
             );


             configured.forEach(
             return true;
                name => {
        },
                    if (
                        !analysis.variables.includes(
                            name
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is not used by the preload.'
                        );
                    }
                }
            );


            if (
        // API
                !configured.includes(
                    'PAGE'
                )
            ) {
                errors.push(
                    'PAGE must always be configured.'
                );
            }


             Object.entries(
        async getPage(title) {
                config.fields
             const response = await new mw.Api().get({
            ).forEach(
                 action: 'query',
                 ([name, field]) => {
                prop: 'revisions',
                    if (
                titles: title,
                        !field ||
                rvprop: 'content',
                        typeof field !==
                rvslots: 'main',
                            'object' ||
                formatversion: 2
                        Array.isArray(field)
            });
                    ) {
                        errors.push(
                            'Field "' +
                            name +
                            '" must be an object.'
                        );


                        return;
            const page =
                    }
                response &&
                response.query &&
                response.query.pages &&
                response.query.pages[0];


                    if (!field.type) {
            if (!page || page.missing) return null;
                        warnings.push(
                            'Field "' +
                            name +
                            '" has no type.'
                        );
                    }


                    if (
            const content =
                        (
                page.revisions &&
                            field.type ===
                page.revisions[0] &&
                                'select' ||
                page.revisions[0].slots &&
                            field.type ===
                page.revisions[0].slots.main &&
                                'multiselect'
                 page.revisions[0].slots.main.content;
                        ) &&
                        !field.source &&
                        !Array.isArray(
                            field.options
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is a ' +
                            field.type +
                            ' without a source or manual options.'
                        );
                    }
                 }
            );


             return {
             return {
                 valid:
                 title: page.title || title,
                    !errors.length,
                 content:
                 errors,
                    typeof content === 'string'
                warnings
                        ? content
                        : ''
             };
             };
         },
         },


         // Transformation helpers
         async pageExists(title) {
            return !!await this.getPage(title);
        },


         transformationObject(
         async searchPages(query) {
             transform,
             const response = await new mw.Api().get({
            prefix,
                action: 'query',
            suffix,
                list: 'search',
            from,
                srsearch: query,
            to
                srlimit: 10,
        ) {
                srnamespace: 10,
             if (!transform) {
                formatversion: 2
                return null;
             });
            }


             switch (transform) {
             return (
                 case 'remove-prefix':
                 response &&
                    return prefix
                response.query &&
                        ? {
                response.query.search
                            transform,
            ) || [];
                            prefix
        },
                        }
                        : null;


                 case 'remove-suffix':
        async savePage(
                     return suffix
            title,
                        ? {
            content,
                            transform,
            summary,
                            suffix
            createOnly
                        }
        ) {
                        : null;
            const params = {
                 action: 'edit',
                title,
                text: content,
                summary:
                     summary ||
                    'Updated with Preload Editor',
                formatversion: 2
            };


                 case 'replace':
            if (createOnly) {
                    return from === ''
                 params.createonly = true;
                        ? null
            }
                        : {
                            transform,
                            from,
                            to: to || ''
                        };


                 default:
            return new mw.Api().postWithToken(
                    return null;
                 'csrf',
             }
                params
             );
         },
         },


         // UI helpers
         // Preload parsing


         createSection(
         extractIncludeonly(source) {
             title,
             const match =
            className
                String(source || '').match(
         ) {
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
             const section =
                );
                 $('<section>')
 
                    .addClass(
            return match
                        'preload-editor-section'
                ? match[1].trim()
                     );
                : String(source || '').trim();
        },
 
         findVariables(content) {
             const variables = [];
            const pattern =
                 /%%([A-Za-z0-9_-]+)%%/g;
 
            let match;
 
            while (
                (match = pattern.exec(content)) !==
                null
            ) {
                const name =
                     match[1].toUpperCase();


            if (className) {
                if (!variables.includes(name)) {
                section.addClass(
                    variables.push(name);
                    className
                 }
                 );
             }
             }


            $('<h2>')
             return variables;
                .addClass(
                    'preload-editor-section-title'
                )
                .text(title)
                .appendTo(section);
 
             return section;
         },
         },


         createButton(
         findConditions(content) {
             label,
             const conditions = [];
            className,
             const pattern =
            callback
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
        ) {
             const button =
                 $('<button>')
                    .attr(
                        'type',
                        'button'
                    )
                    .addClass(
                        'preload-editor-button'
                    )
                    .text(label);


             if (className) {
             let match;
                button.addClass(
                    className
                );
            }


             if (callback) {
             while (
                 button.on(
                (match = pattern.exec(content)) !==
                     'click.preloadEditor',
                null
                     callback
            ) {
                 );
                 conditions.push({
                    type:
                        match[1].toLowerCase(),
                     expression:
                        match[2].trim(),
                     index: match.index
                 });
             }
             }


             return button;
             return conditions;
         },
         },


         fieldRow(
         findConditionVariables(content) {
             label,
             const variables = [];
            input,
 
             description
             this.findConditions(content).forEach(
        ) {
                condition => {
            const row =
                    if (
                $('<div>')
                        condition.type !==
                    .addClass(
                            'if' &&
                        'preload-editor-field'
                        condition.type !==
                     );
                            'elseif'
                     ) {
                        return;
                    }


            $('<label>')
                    const matches =
                .addClass(
                        condition.expression.match(
                    'preload-editor-label'
                            /[A-Za-z0-9_-]+/g
                )
                        ) || [];
                .text(label)
                .appendTo(row);


            if (description) {
                     matches.forEach(name => {
                $('<div>')
                         name =
                     .addClass(
                            name.toUpperCase();
                         'preload-editor-help'
                    )
                    .text(description)
                    .appendTo(row);
            }


            row.append(input);
                        if (
 
                            !variables.includes(
            return row;
                                name
        },
                            )
 
                        ) {
        textInput(
                            variables.push(
            value,
                                name
            placeholder
                            );
        ) {
                         }
            return $('<input>')
                    });
                .attr({
                }
                    type: 'text',
            );
                    value:
                         value == null
                            ? ''
                            : value,


                    placeholder:
            return variables;
                        placeholder || ''
                })
                .addClass(
                    'preload-editor-input'
                );
         },
         },


         textarea(value) {
         analyzePreload(content) {
             return $('<textarea>')
             return {
                 .val(
                 variables:
                    value == null
                    this.findVariables(
                         ? ''
                         content
                        : value
                     ),
                )
                .addClass(
                     'preload-editor-textarea'
                );
        },


        checkbox(checked) {
                conditions:
            return $('<input>')
                    this.findConditions(
                .attr(
                        content
                     'type',
                     ),
                    'checkbox'
 
                 )
                 conditionVariables:
                .prop(
                    this.findConditionVariables(
                    'checked',
                        content
                     !!checked
                     )
                )
            };
                .addClass(
                    'preload-editor-checkbox'
                );
         },
         },


         select(
         // Config
            options,
            value
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-select'
                    );


            options.forEach(
        getAutomaticPageField() {
                option => {
            return {
                    $('<option>')
                label: 'Page Name',
                        .attr(
                description: 'Name of the page.',
                            'value',
                type: 'page',
                            option.value
                required: true,
                        )
                namespace: 'Main',
                        .text(
                'allow-new': true
                            option.label
            };
                        )
        },
                        .prop(
 
                            'selected',
        emptyConfig() {
                            String(
            return {
                                option.value
                settings: {
                            ) ===
                    incomplete: 'add-stub'
                            String(
                },
                                value ===
 
                                    undefined
                fields: {
                                    ? ''
                    PAGE:
                                    : value
                         this.getAutomaticPageField()
                            )
                        )
                         .appendTo(
                            select
                        );
                 }
                 }
             );
             };
 
            return select;
         },
         },


         // CodeMirror
         normalizeConfig(config) {
 
             if (
        destroyEditor() {
                !config ||
             if (!this.editor) {
                typeof config !== 'object' ||
                 this.editorView = null;
                Array.isArray(config)
                this.editorTools = null;
            ) {
                return;
                 config =
                    this.emptyConfig();
             }
             }


             const {
             if (
                 view,
                !config.settings ||
                 inputHandler
                typeof config.settings !==
             } = this.editor;
                    'object' ||
                 Array.isArray(
                    config.settings
                 )
             ) {
                config.settings = {};
            }


             if (
             if (
                 view &&
                 !config.fields ||
                 inputHandler
                 typeof config.fields !==
                    'object' ||
                Array.isArray(
                    config.fields
                )
             ) {
             ) {
                 view.dom.removeEventListener(
                 config.fields = {};
                    'input',
                    inputHandler
                );
             }
             }


             if (view) {
             /*
                 view.destroy();
            * PAGE is always a special,
            }
            * automatic field.
            *
            * Its configuration is precompiled
            * and cannot be edited.
            */
            config.fields.PAGE =
                 this.getAutomaticPageField();


             this.editor = null;
             return config;
            this.editorView = null;
            this.editorTools = null;
         },
         },


         getEditorView() {
         createDefaultField(
             return this.editorView;
            name,
        },
            analysis
        ) {
             return {
                label:
                    this.humanizeName(
                        name
                    ),


        getEditorContent() {
                 description: '',
            /*
            * Always read directly from the
            * CodeMirror state when available.
            *
            * This is important because the
            * native textarea may not contain
            * the actual current document.
            */
            if (
                 this.editorView &&
                this.editorView.state
            ) {
                return this.editorView.state.doc.toString();
            }


            return (
                type:
                this.state.preloadContent ||
                    analysis.conditionVariables.includes(
                ''
                        name
             );
                    )
                        ? 'boolean'
                        : 'text'
             };
         },
         },


         setEditorContent(content) {
         humanizeName(name) {
             content =
             return String(name || '')
                String(content || '');
                .replace(
 
                    /[_-]+/g,
             if (!this.editorView) {
                    ' '
                 this.state.preloadContent =
                )
                     content;
                .replace(
                    /([a-z])([A-Z])/g,
                    '$1 $2'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim()
                .replace(
                    /\b\w/g,
                    char =>
                        char.toUpperCase()
                );
        },
 
        prepareConfigForVariables(
             config,
            analysis
        ) {
            config =
                 this.normalizeConfig(
                     config
                );


                 return;
            const existing =
            }
                 config.fields || {};


             const current =
             const fields = {};
                this.editorView.state.doc.toString();


             if (current === content) {
             /*
                 return;
            * PAGE is always automatic.
            }
            */
            fields.PAGE =
                 this.getAutomaticPageField();


             this.editorView.dispatch({
             /*
                changes: {
            * Add every variable currently
                     from: 0,
            * detected in the preload.
                     to:
            *
                         this.editorView.state
            * Existing configuration is
                             .doc.length,
            * preserved.
                    insert: content
            *
            * New variables receive a
            * default configuration.
            */
            analysis.variables.forEach(
                name => {
                    if (name === 'PAGE') {
                        return;
                     }
 
                     fields[name] =
                        existing[name] ||
                         this.createDefaultField(
                             name,
                            analysis
                        );
                 }
                 }
             });
             );
        },


        getEditorSelection() {
            /*
            if (
            * Preserve existing fields even
                !this.editorView ||
            * if they are currently unused.
                !this.editorView.state
            *
            ) {
            * This allows users to keep
                return null;
            * configuration around while
            }
            * editing the preload.
            */
            Object.keys(existing).forEach(
                name => {
                    const upper =
                        name.toUpperCase();


            const selection =
                    if (
                 this.editorView.state
                        upper !== 'PAGE' &&
                    .selection.main;
                        !fields[upper]
                    ) {
                        fields[upper] =
                            existing[name];
                    }
                 }
            );


             return {
             config.fields = fields;
                from:
                    selection.from,


                to:
             return config;
                    selection.to,
 
                text:
                    this.editorView.state.doc.sliceString(
                        selection.from,
                        selection.to
                    )
             };
         },
         },


         focusEditor() {
         validateConfig(
             if (this.editorView) {
             config,
                this.editorView.focus();
             analysis
            }
        },
 
        insertEditorText(
            text,
            selectionFrom,
            selectionTo,
             cursorPosition
         ) {
         ) {
             if (!this.editorView) {
             const errors = [];
                return;
             const warnings = [];
             }


             const view =
             if (
                 this.editorView;
                !config ||
                typeof config !== 'object' ||
                 Array.isArray(config)
            ) {
                return {
                    valid: false,
                    errors: [
                        'Config must be an object.'
                    ],
                    warnings: []
                };
            }


             const selection =
             if (
                 view.state.selection.main;
                !config.fields ||
                typeof config.fields !==
                    'object' ||
                 Array.isArray(
                    config.fields
                )
            ) {
                return {
                    valid: false,
                    errors: [
                        '"fields" must be an object.'
                    ],
                    warnings
                };
            }


             const from =
             const configured =
                 selectionFrom ===
                 Object.keys(
                     undefined
                     config.fields
                     ? selection.from
                ).map(
                    : selectionFrom;
                     name =>
                        name.toUpperCase()
                );


             const to =
             analysis.variables.forEach(
                 selectionTo ===
                 name => {
                     undefined
                     if (
                    ? selection.to
                        !configured.includes(
                    : selectionTo;
                            name
 
                        )
            const cursor =
                     ) {
                cursorPosition ===
                        errors.push(
                     undefined
                            'Variable "' +
                    ? from + text.length
                            name +
                    : cursorPosition;
                            '" is not configured.'
 
                        );
            view.dispatch({
                     }
                changes: {
                    from,
                    to,
                     insert: text
                },
 
                selection: {
                    anchor: cursor
                 }
                 }
             });
             );


             view.focus();
             configured.forEach(
        },
                name => {
 
                    if (
        // V2.3 editor tools
                        !analysis.variables.includes(
 
                            name
        wrapSelectionWithVariable() {
                        )
            const selected =
                    ) {
                 this.getEditorSelection();
                        warnings.push(
                            'Field "' +
                            name +
                            '" is not used by the preload.'
                        );
                    }
                 }
            );


             if (
             if (
                 !selected ||
                 !configured.includes(
                selected.from ===
                     'PAGE'
                     selected.to
                )
             ) {
             ) {
                 this.focusEditor();
                 errors.push(
                 return;
                    'PAGE must always be configured.'
                 );
             }
             }


             const replacement =
             Object.entries(
                '%%' +
                config.fields
                selected.text +
            ).forEach(
                '%%';
                ([name, field]) => {
                    if (
                        !field ||
                        typeof field !==
                            'object' ||
                        Array.isArray(field)
                    ) {
                        errors.push(
                            'Field "' +
                            name +
                            '" must be an object.'
                        );
 
                        return;
                    }


            this.insertEditorText(
                    if (!field.type) {
                replacement,
                        warnings.push(
                selected.from,
                            'Field "' +
                selected.to,
                            name +
                selected.from +
                            '" has no type.'
                     replacement.length
                        );
                    }
 
                    if (
                        (
                            field.type ===
                                'select' ||
                            field.type ===
                                'multiselect'
                        ) &&
                        !field.source &&
                        !Array.isArray(
                            field.options
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is a ' +
                            field.type +
                            ' without a source or manual options.'
                        );
                     }
                }
             );
             );
            return {
                valid:
                    !errors.length,
                errors,
                warnings
            };
         },
         },


         insertVariable(name) {
         // Transformation helpers
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();


             if (!name) {
        transformationObject(
                 return;
            transform,
            prefix,
            suffix,
            from,
            to
        ) {
             if (!transform) {
                 return null;
             }
             }


             const selected =
             switch (transform) {
                this.getEditorSelection();
                case 'remove-prefix':
                    return prefix
                        ? {
                            transform,
                            prefix
                        }
                        : null;
 
                case 'remove-suffix':
                    return suffix
                        ? {
                            transform,
                            suffix
                        }
                        : null;
 
                case 'replace':
                    return from === ''
                        ? null
                        : {
                            transform,
                            from,
                            to: to || ''
                        };


            if (!selected) {
                default:
                return;
                    return null;
             }
             }
        },


            const text =
        // UI helpers
                '%%' +
                name +
                '%%';


            this.insertEditorText(
         createSection(
                text,
             title,
                selected.from,
             className
                selected.to
            );
        },
 
         insertCondition(
             type,
             variable
         ) {
         ) {
             type =
             const section =
                 String(type || '')
                 $('<section>')
                     .toLowerCase();
                     .addClass(
                        'preload-editor-section'
                    );


             variable =
             if (className) {
                String(variable || '')
                section.addClass(
                    .trim()
                     className
                     .toUpperCase();
                );
            }


             const selected =
             $('<h2>')
                 this.getEditorSelection();
                .addClass(
                    'preload-editor-section-title'
                )
                .text(title)
                 .appendTo(section);


             if (!selected) {
             return section;
                return;
        },
            }


             let text;
        createButton(
             let cursorOffset;
             label,
            className,
            callback
        ) {
             const button =
                $('<button>')
                    .attr(
                        'type',
                        'button'
                    )
                    .addClass(
                        'preload-editor-button'
                    )
                    .text(label);


             switch (type) {
             if (className) {
                 case 'if': {
                 button.addClass(
                     if (!variable) {
                     className
                        return;
                );
                    }
            }


                     const opening =
            if (callback) {
                        '<!-- @if ' +
                button.on(
                        variable +
                     'click.preloadEditor',
                        ' -->';
                    callback
                );
            }


                    const closing =
            return button;
                        '<!-- @endif -->';
        },


                    if (
        fieldRow(
                        selected.from !==
            label,
                        selected.to
            input,
                    ) {
            description
                        text =
        ) {
                            opening +
            const row =
                            '\n' +
                $('<div>')
                            selected.text +
                    .addClass(
                            '\n' +
                        'preload-editor-field'
                            closing;
                    );


                        cursorOffset =
            $('<label>')
                            text.length;
                .addClass(
                     } else {
                     'preload-editor-label'
                        text =
                )
                            opening +
                .text(label)
                            '\n\n' +
                .appendTo(row);
                            closing;


                         cursorOffset =
            if (description) {
                            opening.length +
                $('<div>')
                            1;
                    .addClass(
                    }
                         'preload-editor-help'
                    )
                    .text(description)
                    .appendTo(row);
            }


                    break;
            row.append(input);
                }


                case 'elseif':
            return row;
                    text =
        },
                        '<!-- @elseif -->';


                     cursorOffset =
        textInput(
                        text.length;
            value,
            placeholder
        ) {
            return $('<input>')
                .attr({
                     type: 'text',
                    value:
                        value == null
                            ? ''
                            : value,


                     break;
                     placeholder:
                        placeholder || ''
                })
                .addClass(
                    'preload-editor-input'
                );
        },


                case 'else':
        textarea(value) {
                     text =
            return $('<textarea>')
                         '<!-- @else -->';
                .val(
                     value == null
                        ? ''
                         : value
                )
                .addClass(
                    'preload-editor-textarea'
                );
        },


                    cursorOffset =
        checkbox(checked) {
                        text.length;
            return $('<input>')
 
                .attr(
                     break;
                     'type',
 
                     'checkbox'
                case 'endif':
                )
                     text =
                 .prop(
                        '<!-- @endif -->';
                    'checked',
 
                    !!checked
                    cursorOffset =
                 )
                        text.length;
                 .addClass(
 
                     'preload-editor-checkbox'
                    break;
                );
 
                 default:
                    return;
            }
 
            this.insertEditorText(
                text,
                selected.from,
                 selected.to,
                 selected.from +
                     cursorOffset
            );
         },
         },


         getToolVariables() {
         select(
             const variables =
            options,
                 new Set(['PAGE']);
            value
        ) {
             const select =
                 $('<select>')
                    .addClass(
                        'preload-editor-select'
                    );


            if (
             options.forEach(
                this.analysis &&
                 option => {
                this.analysis.variables
                     $('<option>')
            ) {
                this.analysis.variables.forEach(
                    name => {
                        variables.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
            }
 
            return Array.from(
                variables
            );
        },
 
        createToolSelect(
            options,
            className
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                    );
 
            if (className) {
                select.addClass(
                    className
                );
            }
 
             options.forEach(
                 option => {
                     $('<option>')
                         .attr(
                         .attr(
                             'value',
                             'value',
Line 1,168: Line 1,252:
                             option.label
                             option.label
                         )
                         )
                         .appendTo(
                        .prop(
                             select
                            'selected',
                         );
                            String(
                 }
                                option.value
             );
                            ) ===
                            String(
                                value ===
                                    undefined
                                    ? ''
                                    : value
                            )
                        )
                         .appendTo(
                             select
                         );
                 }
             );


             return select;
             return select;
         },
         },


         updateEditorToolVariables() {
         // CodeMirror
             if (!this.editorTools) {
 
        destroyEditor() {
             if (!this.editor) {
                this.editorView = null;
                this.editorTools = null;
                 return;
                 return;
             }
             }


             const variables =
             const {
                 this.getToolVariables();
                view,
                 inputHandler
            } = this.editor;


             const current =
             if (
                 this.editorTools
                view &&
                     .variableSelect
                inputHandler
                     .val();
            ) {
                 view.dom.removeEventListener(
                     'input',
                     inputHandler
                );
            }


             const currentCondition =
             if (view) {
                 this.editorTools
                 view.destroy();
                    .conditionVariableSelect
            }
                    .val();


             const populate =
             this.editor = null;
                (
            this.editorView = null;
                    select,
            this.editorTools = null;
                    value
        },
                ) => {
                    select.empty();


                    variables.forEach(
        getEditorView() {
                        name => {
            return this.editorView;
                            $('<option>')
        },
                                .attr(
                                    'value',
                                    name
                                )
                                .text(name)
                                .appendTo(
                                    select
                                );
                        }
                    );


                    if (
        getEditorContent() {
                        value &&
            /*
                        variables.includes(
            * Always read directly from the
                            value
            * CodeMirror state when available.
                        )
            *
                    ) {
            * This is important because the
                        select.val(
            * native textarea may not contain
                            value
            * the actual current document.
                        );
            */
                    } else if (
            if (
                        variables.length
                this.editorView &&
                    ) {
                this.editorView.state
                        select.val(
            ) {
                            variables[0]
                return this.editorView.state.doc.toString();
                        );
            }
                    }
                };


             populate(
             return (
                 this.editorTools
                 this.state.preloadContent ||
                    .variableSelect,
                 ''
                 current
             );
             );
        },
        setEditorContent(content) {
            content =
                String(content || '');


             populate(
             if (!this.editorView) {
                 this.editorTools
                 this.state.preloadContent =
                     .conditionVariableSelect,
                     content;
                currentCondition
            );
        },


        updateConditionVariableVisibility() {
            if (!this.editorTools) {
                 return;
                 return;
             }
             }


             const type =
             const current =
                 this.editorTools
                 this.editorView.state.doc.toString();
                    .conditionTypeSelect
                    .val();


             this.editorTools
            if (current === content) {
                 .conditionVariableWrap
                return;
                .toggle(
            }
                     type === 'if'
 
                 );
             this.editorView.dispatch({
                 changes: {
                    from: 0,
                    to:
                        this.editorView.state
                            .doc.length,
                     insert: content
                 }
            });
         },
         },


         renderEditorTools() {
         getEditorSelection() {
             const tools =
             if (
                 $('<div>')
                 !this.editorView ||
                    .addClass(
                !this.editorView.state
                        'preload-editor-tools'
            ) {
                    );
                return null;
            }


             const wrapGroup =
             const selection =
                 $('<div>')
                 this.editorView.state
                     .addClass(
                     .selection.main;
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-wrap'
                    );


             wrapGroup.append(
             return {
                 this.createButton(
                 from:
                     'Wrap selection with %%',
                     selection.from,
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
            );


            const variableGroup =
                 to:
                 $('<div>')
                     selection.to,
                     .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-variable'
                    );


            $('<span>')
                 text:
                 .addClass(
                     this.editorView.state.doc.sliceString(
                     'preload-editor-tool-label'
                        selection.from,
                )
                        selection.to
                .text(
                     )
                    'Insert existing variable'
            };
                )
        },
                .appendTo(
                     variableGroup
                );


             const variableSelect =
        focusEditor() {
                 this.createToolSelect(
             if (this.editorView) {
                    [],
                 this.editorView.focus();
                    'preload-editor-variable-select'
            }
                );
        },


             variableGroup.append(
        insertEditorText(
                 variableSelect,
            text,
             selectionFrom,
            selectionTo,
            cursorPosition
        ) {
            if (!this.editorView) {
                 return;
            }


                 this.createButton(
            const view =
                    'Insert',
                 this.editorView;
                    'preload-editor-tool-button',
 
                    () =>
            const selection =
                        this.insertVariable(
                view.state.selection.main;
                            variableSelect.val()
                        )
                )
            );


             const conditionGroup =
             const from =
                 $('<div>')
                 selectionFrom ===
                     .addClass(
                     undefined
                        'preload-editor-tool-group ' +
                    ? selection.from
                        'preload-editor-tool-group-condition'
                     : selectionFrom;
                     );


             $('<span>')
             const to =
                 .addClass(
                 selectionTo ===
                     'preload-editor-tool-label'
                     undefined
                )
                     ? selection.to
                .text(
                     : selectionTo;
                     'Insert condition'
                )
                .appendTo(
                     conditionGroup
                );


             const conditionTypeSelect =
             const cursor =
                 this.createToolSelect(
                 cursorPosition ===
                     [
                     undefined
                        {
                     ? from + text.length
                            value: 'if',
                     : cursorPosition;
                            label: '@if'
                        },
                        {
                            value: 'elseif',
                            label: '@elseif'
                        },
                        {
                            value: 'else',
                            label: '@else'
                        },
                        {
                            value: 'endif',
                            label: '@endif'
                        }
                     ],
                     'preload-editor-condition-type-select'
                );


             const conditionVariableSelect =
             view.dispatch({
                 this.createToolSelect(
                 changes: {
                     [],
                    from,
                     'preload-editor-condition-variable-select'
                     to,
                 );
                     insert: text
                 },


            const conditionVariableWrap =
                 selection: {
                 $('<span>')
                     anchor: cursor
                     .addClass(
                }
                        'preload-editor-condition-variable'
            });
                    )
                    .append(
                        conditionVariableSelect
                    );


             const insertCondition =
             view.focus();
                this.createButton(
        },
                    'Insert',
 
                    'preload-editor-tool-button',
        // V2.3 editor tools
                    () =>
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        )
                );


            conditionTypeSelect.on(
        wrapSelectionWithVariable() {
                'change.preloadEditor',
            const selected =
                 () =>
                 this.getEditorSelection();
                    this.updateConditionVariableVisibility()
            );


             conditionGroup.append(
             if (
                 conditionTypeSelect,
                 !selected ||
                 conditionVariableWrap,
                 selected.from ===
                insertCondition
                    selected.to
             );
             ) {
                this.focusEditor();
                return;
            }


             tools.append(
             const replacement =
                 wrapGroup,
                 '%%' +
                 variableGroup,
                 selected.text +
                 conditionGroup
                 '%%';
            );


             this.editorTools = {
             this.insertEditorText(
                 root: tools,
                 replacement,
                 variableSelect,
                 selected.from,
                 conditionTypeSelect,
                 selected.to,
                 conditionVariableSelect,
                 selected.from +
                conditionVariableWrap,
                    replacement.length
                insertCondition
            );
            };
        },


             this.updateEditorToolVariables();
        insertVariable(name) {
            this.updateConditionVariableVisibility();
             name =
                String(name || '')
                    .trim()
                    .toUpperCase();


             return tools;
             if (!name) {
        },
                return;
            }


        updateEditorTools() {
             const selected =
             this.updateEditorToolVariables();
                this.getEditorSelection();
            this.updateConditionVariableVisibility();
        },


        // Preload syntax highlighting
            if (!selected) {
                return;
            }


        getKnownVariables() {
             const text =
             const known =
                 '%%' +
                 new Set(['PAGE']);
                name +
                '%%';


             const fields =
             this.insertEditorText(
                this.state.config &&
                 text,
                this.state.config.fields;
                 selected.from,
 
                 selected.to
            if (
            );
                 fields &&
                 typeof fields ===
                    'object'
            ) {
                Object.keys(
                    fields
                 ).forEach(
                    name => {
                        known.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
            }
 
            return known;
         },
         },


         createSyntaxDecorations(
         insertCondition(
             state,
             type,
             Decoration
             variable
         ) {
         ) {
             const decorations = [];
             type =
                String(type || '')
                    .toLowerCase();


             const source =
             variable =
                 state.doc.toString();
                 String(variable || '')
                    .trim()
                    .toUpperCase();


             const knownVariables =
             const selected =
                 this.getKnownVariables();
                 this.getEditorSelection();


             const variablePattern =
             if (!selected) {
                 /%%([A-Za-z0-9_-]+)%%/g;
                 return;
            }


             let match;
             let text;
            let cursorOffset;


             while (
             switch (type) {
                 (
                 case 'if': {
                     match =
                     if (!variable) {
                        variablePattern.exec(
                        return;
                            source
                     }
                        )
                ) !== null
            ) {
                const name =
                     match[1].toUpperCase();


                const known =
                    const opening =
                    knownVariables.has(
                        '<!-- @if ' +
                         name
                         variable +
                    );
                        ' -->';


                decorations.push(
                     const closing =
                     Decoration.mark({
                         '<!-- @endif -->';
                         class: [
                            'preload-editor-syntax-variable',
                            known
                                ? 'preload-editor-syntax-variable-known'
                                : 'preload-editor-syntax-variable-unknown'
                        ].join(' ')
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
            }


            const conditionPattern =
                    if (
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                        selected.from !==
                        selected.to
                    ) {
                        text =
                            opening +
                            '\n' +
                            selected.text +
                            '\n' +
                            closing;


            while (
                        cursorOffset =
                (
                             text.length;
                    match =
                     } else {
                        conditionPattern.exec(
                         text =
                             source
                            opening +
                        )
                             '\n\n' +
                ) !== null
                             closing;
            ) {
                decorations.push(
                     Decoration.mark({
                         class:
                             'preload-editor-syntax-condition'
                    }).range(
                        match.index,
                        match.index +
                             match[0].length
                    )
                );


                const directiveStart =
                        cursorOffset =
                    match.index +
                            opening.length +
                     match[0].indexOf('@');
                            1;
                     }


                decorations.push(
                     break;
                     Decoration.mark({
                }
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveStart +
                            match[1].length +
                            1
                    )
                );


                 const type =
                 case 'elseif':
                    match[1].toLowerCase();
                    text =
                        '<!-- @elseif -->';


                if (
                     cursorOffset =
                     type !== 'if' &&
                        text.length;
                    type !== 'elseif'
                ) {
                    continue;
                }


                const expression =
                     break;
                     match[2];


                 const expressionStart =
                 case 'else':
                     match.index +
                     text =
                    match[0].indexOf(
                         '<!-- @else -->';
                         expression
                    );


                const expressionPattern =
                    cursorOffset =
                    /[A-Za-z0-9_-]+/g;
                        text.length;


                let expressionMatch;
                    break;


                 while (
                 case 'endif':
                     (
                     text =
                         expressionMatch =
                         '<!-- @endif -->';
                            expressionPattern.exec(
                                expression
                            )
                    ) !== null
                ) {
                    const name =
                        expressionMatch[0]
                            .toUpperCase();


                     if (
                     cursorOffset =
                         !knownVariables.has(
                         text.length;
                            name
                        )
                    ) {
                        continue;
                    }


                     const start =
                     break;
                        expressionStart +
                        expressionMatch.index;


                    decorations.push(
                default:
                        Decoration.mark({
                     return;
                            class:
                                'preload-editor-syntax-condition-variable'
                        }).range(
                            start,
                            start +
                                expressionMatch[0]
                                    .length
                        )
                     );
                }
             }
             }


             return Decoration.set(
             this.insertEditorText(
                 decorations,
                 text,
                 true
                 selected.from,
                selected.to,
                selected.from +
                    cursorOffset
             );
             );
         },
         },


         async installSyntaxHighlighting(
         getToolVariables() {
            view
             const variables =
        ) {
                 new Set(['PAGE']);
             await mw.loader.using(
                 'ext.CodeMirror.v6.lib'
            );


             const require =
             if (
                mw.loader.require;
                this.analysis &&
                this.analysis.variables
            ) {
                this.analysis.variables.forEach(
                    name => {
                        variables.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
            }


             const {
             return Array.from(
                StateField,
                 variables
                StateEffect,
                EditorView,
                Decoration
            } = require(
                 'ext.CodeMirror.v6.lib'
             );
             );
        },


             const syntaxField =
        createToolSelect(
                 StateField.define({
            options,
                     create: state =>
            className
                        this.createSyntaxDecorations(
        ) {
                            state,
             const select =
                            Decoration
                 $('<select>')
                        ),
                     .addClass(
                        'preload-editor-tool-select'
                    );


                    update: (
            if (className) {
                        decorations,
                select.addClass(
                        transaction
                    className
                    ) =>
                );
                        transaction.docChanged
            }
                            ? this.createSyntaxDecorations(
                                transaction.state,
                                Decoration
                            )
                            : decorations,


                     provide: field =>
            options.forEach(
                         EditorView.decorations.from(
                option => {
                             field
                     $('<option>')
                         .attr(
                            'value',
                            option.value
                        )
                        .text(
                             option.label
                         )
                         )
                 });
                        .appendTo(
                            select
                        );
                 }
            );


             view.dispatch({
             return select;
                effects:
                    StateEffect.appendConfig.of(
                        syntaxField
                    )
            });
 
            this.editor.syntaxField =
                syntaxField;
         },
         },


         async mountCodeMirror(
         updateEditorToolVariables() {
             textarea
            if (!this.editorTools) {
        ) {
                return;
             return new Promise(
            }
 
             const variables =
                this.getToolVariables();
 
            const current =
                this.editorTools
                    .variableSelect
                    .val();
 
             const currentCondition =
                this.editorTools
                    .conditionVariableSelect
                    .val();
 
            const populate =
                 (
                 (
                     resolve,
                     select,
                     reject
                     value
                 ) => {
                 ) => {
                     let settled = false;
                     select.empty();


                     const cleanup =
                     variables.forEach(
                         () => {
                         name => {
                             mw.hook(
                             $('<option>')
                                'dev.CCM.ready'
                                .attr(
                            ).remove(
                                    'value',
                                 onReady
                                    name
                            );
                                 )
 
                                .text(name)
                            mw.hook(
                                 .appendTo(
                                 'dev.CCM.load'
                                    select
                            ).remove(
                                 );
                                 onLoad
                         }
                            );
                    );
                         };


                     const onReady =
                     if (
                         async cm => {
                        value &&
                             if (
                        variables.includes(
                                settled ||
                            value
                                !cm ||
                         )
                                cm.textarea !==
                    ) {
                                    textarea ||
                        select.val(
                                !cm.view
                             value
                             ) {
                        );
                                return;
                    } else if (
                            }
                        variables.length
                    ) {
                        select.val(
                             variables[0]
                        );
                    }
                };


                            settled = true;
            populate(
                            cleanup();
                this.editorTools
                    .variableSelect,
                current
            );


                            const view =
            populate(
                                cm.view;
                this.editorTools
                    .conditionVariableSelect,
                currentCondition
            );
        },


                            const inputHandler =
        updateConditionVariableVisibility() {
                                () => {
            if (!this.editorTools) {
                                    this.state
                return;
                                        .preloadContent =
            }
                                        view.state.doc.toString();


                                    this.state.dirty =
            const type =
                                        true;
                this.editorTools
                    .conditionTypeSelect
                    .val();


                                    /*
            this.editorTools
                                    * Keep the editor state
                .conditionVariableWrap
                                    * synchronized, but do not
                .toggle(
                                    * rebuild the right panel
                    type === 'if'
                                    * on every CodeMirror event.
                );
                                    *
        },
                                    * The Detect variables
                                    * button is the explicit
                                    * refresh action.
                                    */
                                    this.state
                                        .preloadContent =
                                        view.state.doc.toString();
                                };


                            view.dom.addEventListener(
        renderEditorTools() {
                                'input',
            const tools =
                                inputHandler
                $('<div>')
                            );
                    .addClass(
                        'preload-editor-tools'
                    );


                            view.dom.classList.add(
            const wrapGroup =
                                'preload-editor-codemirror'
                $('<div>')
                            );
                    .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-wrap'
                    );


                            const wrapper =
            wrapGroup.append(
                                view.dom.closest(
                this.createButton(
                                    '.ext-codemirror-wrapper'
                    'Wrap selection with %%',
                                );
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
            );


                            if (wrapper) {
            const variableGroup =
                                wrapper.classList.add(
                $('<div>')
                                    'preload-editor-codemirror-wrapper'
                    .addClass(
                                );
                        'preload-editor-tool-group ' +
                            }
                        'preload-editor-tool-group-variable'
                    );


                            this.editor = {
            $('<span>')
                                cm,
                .addClass(
                                view,
                    'preload-editor-tool-label'
                                textarea,
                )
                                inputHandler
                .text(
                            };
                    'Insert existing variable'
 
                )
                            this.editorView =
                .appendTo(
                                view;
                    variableGroup
                );


                            this.state
            const variableSelect =
                                .preloadContent =
                this.createToolSelect(
                                view.state.doc.toString();
                    [],
                    'preload-editor-variable-select'
                );


                            try {
            variableGroup.append(
                                await this.installSyntaxHighlighting(
                variableSelect,
                                    view
                                );


                                resolve(view);
                this.createButton(
                            } catch (
                    'Insert',
                                error
                    'preload-editor-tool-button',
                             ) {
                    () =>
                                reject(error);
                        this.insertVariable(
                            }
                             variableSelect.val()
                        };
                        )
                )
            );


                    const onLoad =
            const conditionGroup =
                        loader => {
                $('<div>')
                            if (
                    .addClass(
                                settled ||
                        'preload-editor-tool-group ' +
                                typeof loader !==
                        'preload-editor-tool-group-condition'
                                    'function'
                    );
                            ) {
                                return;
                            }


                            try {
            $('<span>')
                                loader(
                .addClass(
                                    textarea
                    'preload-editor-tool-label'
                                );
                )
                            } catch (
                .text(
                                error
                    'Insert condition'
                            ) {
                )
                                settled = true;
                .appendTo(
                                cleanup();
                    conditionGroup
                                reject(
                );
                                    error
                                );
                            }
                        };


                    mw.hook(
            const conditionTypeSelect =
                         'dev.CCM.ready'
                this.createToolSelect(
                    ).add(
                    [
                         onReady
                        {
                    );
                            value: 'if',
 
                            label: '@if'
                    mw.hook(
                        },
                         'dev.CCM.load'
                         {
                    ).add(
                            value: 'elseif',
                         onLoad
                            label: '@elseif'
                     );
                        },
                 }
                         {
            );
                            value: 'else',
        },
                            label: '@else'
                        },
                         {
                            value: 'endif',
                            label: '@endif'
                         }
                     ],
                    'preload-editor-condition-type-select'
                 );


        // Main UI
            const conditionVariableSelect =
                this.createToolSelect(
                    [],
                    'preload-editor-condition-variable-select'
                );


        init() {
             const conditionVariableWrap =
             const root =
                 $('<span>')
                $('#mw-content-text');
                     .addClass(
 
                         'preload-editor-condition-variable'
            if (!root.length) {
                return;
            }
 
            this.root =
                 $('<div>')
                     .attr(
                        'id',
                         'preload-editor'
                     )
                     )
                     .addClass(
                     .append(
                         'preload-editor'
                         conditionVariableSelect
                     );
                     );


            root.append(
             const insertCondition =
                this.root
            );
 
            this.renderShell();
        },
 
        renderShell() {
            this.destroyEditor();
            this.root.empty();
 
             const loader =
                this.createSection(
                    'Preload'
                );
 
            const titleInput =
                this.textInput(
                    this.state.preloadTitle,
                    'Template:Example/Preload'
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );
 
            const loadButton =
                 this.createButton(
                 this.createButton(
                     'Load',
                     'Insert',
                     'preload-editor-button-primary',
                     'preload-editor-tool-button',
                     () =>
                     () =>
                         this.loadExisting(
                         this.insertCondition(
                             titleInput.val()
                             conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                         )
                         )
                 );
                 );


             const newButton =
             conditionTypeSelect.on(
                this.createButton(
                'change.preloadEditor',
                    'New',
                () =>
                    '',
                    this.updateConditionVariableVisibility()
                    () =>
            );
                        this.startNew()
                );


             const sampleButton =
             conditionGroup.append(
                this.createButton(
                 conditionTypeSelect,
                    'Load Sample Page',
                conditionVariableWrap,
                    '',
                insertCondition
                    () =>
                        this.loadSamplePage()
                );
 
            loader.append(
                 $('<div>')
                    .addClass(
                        'preload-editor-toolbar'
                    )
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    )
             );
             );


             this.root.append(
             tools.append(
                 loader
                 wrapGroup,
                variableGroup,
                conditionGroup
             );
             );


             this.status =
             this.editorTools = {
                 $('<div>')
                 root: tools,
                    .addClass(
                variableSelect,
                        'preload-editor-status'
                conditionTypeSelect,
                    );
                conditionVariableSelect,
                conditionVariableWrap,
                insertCondition
            };


             this.root.append(
             this.updateEditorToolVariables();
                this.status
            this.updateConditionVariableVisibility();
            );


             this.workspace =
             return tools;
                $('<div>')
        },
                    .addClass(
                        'preload-editor-workspace'
                    );


             this.root.append(
        updateEditorTools() {
                this.workspace
             this.updateEditorToolVariables();
            );
             this.updateConditionVariableVisibility();
 
             this.renderWelcome();
         },
         },


         renderWelcome() {
         // Preload syntax highlighting
            this.workspace.empty();


             const section =
        getKnownVariables() {
                 this.createSection(
             const known =
                    'Get started'
                 new Set(['PAGE']);
                );


             $('<p>')
             const fields =
                 .text(
                 this.state.config &&
                    'Load an existing preload, create a new one, or start from a sample wiki page.'
                 this.state.config.fields;
                )
                 .appendTo(
                    section
                );


             const tips =
             if (
                 $('<ul>')
                fields &&
                     .addClass(
                 typeof fields ===
                        'preload-editor-tips'
                     'object'
                    );
            ) {
 
                 Object.keys(
            [
                    fields
                 'Existing preload: loads the preload and its config.json if available.',
                 ).forEach(
                'New preload: starts with the standard Preload Template structure.',
                    name => {
                 'Sample page: loads normal wiki page content so you can turn it into a preload.'
                         known.add(
            ].forEach(
                            String(
                text => {
                                name
                    $('<li>')
                             ).toUpperCase()
                         .text(text)
                        .appendTo(
                             tips
                         );
                         );
                }
                    }
            );
                );
            }


             section.append(
             return known;
                tips
            );
 
            this.workspace.append(
                section
            );
         },
         },


         setStatus(
         createSyntaxDecorations(
             message,
             state,
             type
             Decoration
         ) {
         ) {
             if (!this.status) {
             const decorations = [];
                return;
            }


             this.status.removeClass(
             const source =
                'is-error',
                state.doc.toString();
                'is-success',
                'is-warning',
                'is-loading'
            );


             if (type) {
             const knownVariables =
                 this.status.addClass(
                 this.getKnownVariables();
                    'is-' + type
                );
            }


             this.status.text(
             const variablePattern =
                 message || ''
                 /%%([A-Za-z0-9_-]+)%%/g;
            );
        },


        // Load / create
            let match;


        async loadExisting(
            while (
            title
                (
        ) {
                    match =
             title =
                        variablePattern.exec(
                 String(title || '')
                            source
                     .trim();
                        )
                ) !== null
             ) {
                 const name =
                     match[1].toUpperCase();


            if (!title) {
                 const known =
                 mw.notify(
                     knownVariables.has(
                     'Enter a preload page title first.',
                         name
                    {
                     );
                         type: 'warn'
                     }
                );


                 return;
                 decorations.push(
                    Decoration.mark({
                        class: [
                            'preload-editor-syntax-variable',
                            known
                                ? 'preload-editor-syntax-variable-known'
                                : 'preload-editor-syntax-variable-unknown'
                        ].join(' ')
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
             }
             }


             this.setStatus(
             const conditionPattern =
                 'Loading preload...',
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                'loading'
            );


             try {
             while (
                 const page =
                (
                     await this.getPage(
                    match =
                         title
                        conditionPattern.exec(
                     );
                            source
                        )
                ) !== null
            ) {
                 decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                     }).range(
                         match.index,
                        match.index +
                            match[0].length
                     )
                );


                 if (!page) {
                 const directiveStart =
                     const suggestions =
                     match.index +
                        await this.findSimilarPreloads(
                    match[0].indexOf('@');
                            title
                        );


                     this.renderMissingPage(
                decorations.push(
                         title,
                    Decoration.mark({
                         suggestions
                        class:
                     );
                            'preload-editor-syntax-directive'
                     }).range(
                         directiveStart,
                         directiveStart +
                            match[1].length +
                            1
                     )
                );


                     this.setStatus(
                const type =
                        'Preload not found.',
                     match[1].toLowerCase();
                        'warning'
                    );


                     return;
                if (
                     type !== 'if' &&
                    type !== 'elseif'
                ) {
                    continue;
                 }
                 }


                 await this.loadDefinition(
                 const expression =
                     page.title,
                     match[2];
                    page.content
                );


                 this.setStatus(
                 const expressionStart =
                     'Preload loaded.',
                     match.index +
                     'success'
                     match[0].indexOf(
                );
                        expression
            } catch (
                     );
                error
            ) {
                console.error(
                    'Preload Editor:',
                     error
                );


                 this.setStatus(
                 const expressionPattern =
                     error.message ||
                     /[A-Za-z0-9_-]+/g;
                        'Could not load preload.',
                    'error'
                );
            }
        },


        async findSimilarPreloads(
                let expressionMatch;
            title
        ) {
            let query =
                title
                    .replace(
                        /\/Preload$/i,
                        ''
                    )
                    .trim();


            if (!query) {
                while (
                query = title;
                    (
            }
                        expressionMatch =
                            expressionPattern.exec(
                                expression
                            )
                    ) !== null
                ) {
                    const name =
                        expressionMatch[0]
                            .toUpperCase();


            try {
                    if (
                const results =
                        !knownVariables.has(
                    await this.searchPages(
                            name
                         query
                         )
                     );
                     ) {
                        continue;
                    }
 
                    const start =
                        expressionStart +
                        expressionMatch.index;


                return results.filter(
                    decorations.push(
                    result =>
                        Decoration.mark({
                         /\/Preload$/i.test(
                            class:
                             result.title
                                'preload-editor-syntax-condition-variable'
                         }).range(
                             start,
                            start +
                                expressionMatch[0]
                                    .length
                         )
                         )
                );
                    );
            } catch (
                }
                error
             }
             ) {
                console.error(
                    'Preload Editor search:',
                    error
                );


                 return [];
            return Decoration.set(
             }
                decorations,
                 true
             );
         },
         },


         renderMissingPage(
         async installSyntaxHighlighting(
             title,
             view
            suggestions
         ) {
         ) {
             this.workspace.empty();
             await mw.loader.using(
                'ext.CodeMirror.v6.lib'
            );


             const section =
             const require =
                 this.createSection(
                 mw.loader.require;
                    'Preload not found'
                );


             $('<p>')
             const {
                 .text(
                 StateField,
                    'No preload exists at "' +
                StateEffect,
                    title +
                EditorView,
                    '".'
                Decoration
                )
            } = require(
                 .appendTo(
                 'ext.CodeMirror.v6.lib'
                    section
            );
                );


             if (
             const syntaxField =
                 suggestions.length
                 StateField.define({
            ) {
                    create: state =>
                $('<h3>')
                        this.createSyntaxDecorations(
                    .text(
                            state,
                        'Similar preloads'
                            Decoration
                    )
                         ),
                    .appendTo(
                         section
                    );


                const list =
                     update: (
                     $('<div>')
                         decorations,
                         .addClass(
                         transaction
                            'preload-editor-suggestions'
                     ) =>
                         );
                         transaction.docChanged
 
                             ? this.createSyntaxDecorations(
                suggestions.forEach(
                                 transaction.state,
                     result => {
                                 Decoration
                         list.append(
                             this.createButton(
                                 result.title,
                                 'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                             )
                             )
                        );
                            : decorations,
                    }
                );


                section.append(
                     provide: field =>
                    list
                         EditorView.decorations.from(
                );
                             field
            }
 
            section.append(
                this.createButton(
                    'Create this preload',
                     'preload-editor-button-primary',
                    () =>
                         this.startNew(
                             title
                         )
                         )
                 )
                 });
            );


             this.workspace.append(
             view.dispatch({
                section
                effects:
             );
                    StateEffect.appendConfig.of(
                        syntaxField
                    )
             });
 
            this.editor.syntaxField =
                syntaxField;
         },
         },


         async startNew(
         async mountCodeMirror(
             title
             textarea
         ) {
         ) {
             title =
             return new Promise(
                 String(title || '')
                 (
                     .trim();
                    resolve,
                    reject
                ) => {
                     let settled = false;


            this.state = {
                    const cleanup =
                preloadTitle: title,
                        () => {
                preloadSource: '',
                            mw.hook(
                preloadContent:
                                'dev.CCM.ready'
                    this.defaults.preload,
                            ).remove(
                                onReady
                            );


                config:
                            mw.hook(
                    this.emptyConfig(),
                                'dev.CCM.load'
                            ).remove(
                                onLoad
                            );
                        };


                variables: ['PAGE'],
                    const onReady =
                conditions: [],
                        async cm => {
                selectedVariable: 'PAGE',
                            if (
                mode: 'new',
                                settled ||
                dirty: true
                                !cm ||
            };
                                cm.textarea !==
                                    textarea ||
                                !cm.view
                            ) {
                                return;
                            }


            this.renderEditor();
                            settled = true;
                            cleanup();


            this.setStatus(
                            const view =
                'Creating a new preload.',
                                cm.view;
                'success'
            );
        },


        async loadSamplePage() {
                            const inputHandler =
            const result =
                                () => {
                await this.createPromptDialog(
                                    this.state
                    'Load Sample Page',
                                        .preloadContent =
                    'Enter the title of a normal wiki page to use as the starting point.'
                                        view.state.doc.toString();
                );


            if (!result) {
                                    this.state.dirty =
                return;
                                        true;
            }


            this.setStatus(
                                    /*
                'Loading sample page...',
                                    * Persist immediately.
                'loading'
                                    *
            );
                                    * This is deliberately not debounced:
                                    * the purpose of the local draft is
                                    * crash recovery.
                                    */
                                    this.saveDraft();
                                };


            try {
                            view.dom.addEventListener(
                const page =
                                'input',
                    await this.getPage(
                                inputHandler
                        result
                            );
                    );


                if (!page) {
                            view.dom.classList.add(
                    throw new Error(
                                'preload-editor-codemirror'
                        'Sample page not found: ' +
                            );
                        result
                    );
                }


                const sample =
                            const wrapper =
                    this.convertSampleToPreload(
                                view.dom.closest(
                        page.content
                                    '.ext-codemirror-wrapper'
                    );
                                );


                this.state = {
                            if (wrapper) {
                    preloadTitle: '',
                                wrapper.classList.add(
                    preloadSource: '',
                                    'preload-editor-codemirror-wrapper'
                    preloadContent:
                                );
                        sample,
                            }


                    config:
                            this.editor = {
                        this.emptyConfig(),
                                cm,
 
                                view,
                    variables: ['PAGE'],
                                textarea,
                    conditions: [],
                                inputHandler
                    selectedVariable: 'PAGE',
                            };
                    mode: 'new',
 
                    dirty: true
                            this.editorView =
                };
                                view;


                this.reanalyze();
                            this.state
                this.renderEditor();
                                .preloadContent =
                                view.state.doc.toString();


                this.setStatus(
                            try {
                    'Sample page loaded.',
                                await this.installSyntaxHighlighting(
                    'success'
                                    view
                );
                                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor sample:',
                    error
                );


                this.setStatus(
                                /*
                    error.message ||
                                * Make sure the restored/new
                        'Could not load sample page.',
                                * state is also persisted once
                    'error'
                                * CodeMirror is fully mounted.
                );
                                */
            }
                                if (
        },
                                    this.state.dirty
                                ) {
                                    this.saveDraft();
                                }


        convertSampleToPreload(
                                resolve(view);
            source
                            } catch (
        ) {
                                error
            let content =
                            ) {
                String(source || '')
                                reject(error);
                    .trim();
                            }
                        };


            const include =
                    const onLoad =
                content.match(
                        loader => {
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                            if (
                );
                                settled ||
                                typeof loader !==
                                    'function'
                            ) {
                                return;
                            }


            if (include) {
                            try {
                content =
                                loader(
                    include[1].trim();
                                    textarea
            }
                                );
                            } catch (
                                error
                            ) {
                                settled = true;
                                cleanup();
                                reject(
                                    error
                                );
                            }
                        };


            content =
                     mw.hook(
                content
                         'dev.CCM.ready'
                     .replace(
                     ).add(
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                        onReady
                         ''
                     );
                     )
                     .trim();


            return (
                    mw.hook(
                '<includeonly>\n' +
                        'dev.CCM.load'
                content +
                    ).add(
                '\n</includeonly>\n' +
                        onLoad
                 '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
                    );
                 }
             );
             );
         },
         },


         createPromptDialog(
         // Main UI
            title,
            message
        ) {
            return new Promise(
                resolve => {
                    const overlay =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );


                    const box =
        init() {
                        $('<div>')
            const root =
                            .addClass(
                $('#mw-content-text');
                                'preload-editor-prompt'
                            );


                    $('<h2>')
            if (!root.length) {
                        .text(title)
                return;
                        .appendTo(
            }
                            box
                        );


                    $('<p>')
            this.root =
                        .text(message)
                $('<div>')
                        .appendTo(
                    .attr(
                            box
                        'id',
                        );
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                    );


                    const input =
            root.append(
                        this.textInput();
                this.root
            );


                    const buttons =
            this.renderShell();
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-buttons'
                            );


                    const cancel =
            /*
                        this.createButton(
            * Restore after the shell exists.
                            'Cancel',
            *
                            '',
            * This means a crash/reload can immediately
                            () => {
            * bring the previous working document back.
                                overlay.remove();
            */
                                resolve(null);
            this.restoreDraft();
                            }
                        );


                    const confirm =
            /*
                        this.createButton(
            * Extra safety net for normal reloads/navigation.
                            'Load',
            *
                            'preload-editor-button-primary',
            * Browser crashes cannot always fire beforeunload,
                            () => {
            * which is why we also persist on every edit.
                                const value =
            */
                                    String(
            window.addEventListener(
                                        input.val() ||
                'beforeunload',
                                        ''
                () => {
                                    ).trim();
                    if (
                        this.state.dirty
                    ) {
                        this.saveDraft();
                    }
                }
            );
        },


                                if (!value) {
        renderShell() {
                                    return;
            this.destroyEditor();
                                }
            this.root.empty();


                                overlay.remove();
            const loader =
                                resolve(value);
                this.createSection(
                            }
                    'Preload'
                        );
                );


                     buttons.append(
            const titleInput =
                        cancel,
                this.textInput(
                        confirm
                     this.state.preloadTitle,
                    );
                    'Template:Example/Preload'
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );


                    box.append(
            /*
                        input,
            * Keep the title in state for new/untitled drafts
                         buttons
            * as well. This makes crash recovery more useful
                    );
            * before the preload has ever been saved.
            */
            titleInput.on(
                'input.preloadEditor',
                () => {
                    this.state.preloadTitle =
                         String(
                            titleInput.val() ||
                            ''
                        ).trim();


                     overlay.append(
                     if (
                         box
                        this.state.dirty &&
                     );
                        this.state.mode ===
                            'new'
                    ) {
                         this.saveDraft();
                     }
                }
            );


                     $(document.body)
            const loadButton =
                         .append(
                this.createButton(
                             overlay
                    'Load',
                         );
                    'preload-editor-button-primary',
                     () =>
                         this.loadExisting(
                             titleInput.val()
                         )
                );


                    input.trigger(
            const newButton =
                        'focus'
                this.createButton(
                     );
                    'New',
                    '',
                     () =>
                        this.startNew()
                );


                    input.on(
            const sampleButton =
                        'keydown.preloadEditor',
                this.createButton(
                        event => {
                    'Load Sample Page',
                            if (
                    '',
                                event.key ===
                    () =>
                                'Enter'
                        this.loadSamplePage()
                            ) {
                );
                                confirm.trigger(
 
                                    'click'
            loader.append(
                                );
                $('<div>')
                            }
                    .addClass(
 
                        'preload-editor-toolbar'
                            if (
                    )
                                event.key ===
                    .append(
                                'Escape'
                        titleInput,
                            ) {
                        loadButton,
                                cancel.trigger(
                        newButton,
                                    'click'
                         sampleButton
                                );
                     )
                            }
                         }
                     );
                }
             );
             );
        },


        async loadDefinition(
             this.root.append(
            title,
                loader
             source
            );
        ) {
            const content =
                this.extractIncludeonly(
                    source
                );


             let config =
             this.status =
                 this.emptyConfig();
                 $('<div>')
                    .addClass(
                        'preload-editor-status'
                    );


             const configTitle =
             this.root.append(
                 title.replace(
                 this.status
                    /\/+$/,
            );
                    ''
                ) +
                '/config.json';


             const configPage =
             this.workspace =
                await this.getPage(
                 $('<div>')
                    configTitle
                     .addClass(
                 );
                         'preload-editor-workspace'
 
            if (configPage) {
                try {
                     config =
                        JSON.parse(
                            configPage.content
                        );
                } catch (
                    error
                ) {
                    throw new Error(
                         'The existing config.json contains invalid JSON: ' +
                        error.message
                     );
                     );
                }
            }


             const analysis =
             this.root.append(
                 this.analyzePreload(
                 this.workspace
                    content
            );
                );


             config =
             this.renderWelcome();
                this.prepareConfigForVariables(
        },
                    config,
                    analysis
                );


             this.state = {
        renderWelcome() {
                preloadTitle: title,
             this.workspace.empty();
                preloadSource: source,
                preloadContent: source,
                config,


                 variables:
            const section =
                     analysis.variables,
                 this.createSection(
                     'Get started'
                );


                 conditions:
            $('<p>')
                     analysis.conditions,
                 .text(
                     'Load an existing preload, create a new one, or start from a sample wiki page.'
                )
                .appendTo(
                    section
                );


                 selectedVariable:
            const tips =
                     analysis.variables.includes(
                 $('<ul>')
                         'PAGE'
                     .addClass(
                     )
                         'preload-editor-tips'
                        ? 'PAGE'
                     );
                        : (
                            analysis.variables[0] ||
                            'PAGE'
                        ),


                 mode: 'edit',
            [
                 dirty: false
                 'Existing preload: loads the preload and its config.json if available.',
             };
                'New preload: starts with the standard Preload Template structure.',
 
                 'Sample page: loads normal wiki page content so you can turn it into a preload.'
            this.renderEditor();
             ].forEach(
        },
                text => {
                    $('<li>')
                        .text(text)
                        .appendTo(
                            tips
                        );
                }
            );


        // Editor
            section.append(
                tips
            );


        renderEditor() {
             this.workspace.append(
             this.destroyEditor();
                section
             this.workspace.empty();
             );
        },


             const layout =
        setStatus(
                $('<div>')
            message,
                    .addClass(
             type
                        'preload-editor-layout'
        ) {
                    );
            if (!this.status) {
                return;
            }


             const left =
             this.status.removeClass(
                 $('<div>')
                 'is-error',
                    .addClass(
                'is-success',
                        'preload-editor-left'
                'is-warning',
                    );
                'is-loading'
            );


             const right =
             if (type) {
                $('<div>')
                 this.status.addClass(
                    .addClass(
                    'is-' + type
                        'preload-editor-right'
                    );
 
            const editorSection =
                 this.createSection(
                    'Preload',
                    'preload-editor-main-section'
                );
 
            const meta =
                $('<div>')
                    .addClass(
                        'preload-editor-meta'
                    );
 
            $('<strong>')
                .text(
                    this.state.preloadTitle ||
                    'New preload'
                )
                .appendTo(
                    meta
                 );
                 );
            if (
                this.state.mode ===
                'new'
            ) {
                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                    .appendTo(
                        meta
                    );
             }
             }


             editorSection.append(
             this.status.text(
                 meta
                 message || ''
             );
             );
        },


            editorSection.append(
        // Load / create
                this.renderEditorTools()
            );


             const textarea =
        async loadExisting(
                this.textarea(
             title
                    this.state.preloadContent
        ) {
                 )
            title =
                    .attr(
                 String(title || '')
                        'id',
                     .trim();
                        'preload-editor-content'
                    )
                     .addClass(
                        'preload-editor-content'
                    );


             const editorMount =
             if (!title) {
                $('<div>')
                mw.notify(
                    .addClass(
                    'Enter a preload page title first.',
                        'preload-editor-codemirror-mount'
                     {
                     )
                         type: 'warn'
                    .append(
                     }
                         textarea
                );
                     );


            editorSection.append(
                 return;
                 editorMount
             }
             );


             left.append(
             this.setStatus(
                 editorSection
                 'Loading preload...',
                'loading'
             );
             );


             /*
             try {
            * RIGHT PANEL
                const page =
            *
                    await this.getPage(
            * Analysis
                        title
            * Variables
                    );
            * Variable configuration
 
            * Generated configuration
                if (!page) {
            * Actions
                    const suggestions =
            */
                        await this.findSimilarPreloads(
                            title
                        );


            const analysisSection =
                    this.renderMissingPage(
                this.createSection(
                        title,
                     'Analysis'
                        suggestions
                );
                     );


            const analysisContainer =
                    this.setStatus(
                $('<div>')
                        'Preload not found.',
                    .addClass(
                         'warning'
                         'preload-editor-analysis'
                     );
                     );


            analysisSection.append(
                    return;
                 analysisContainer
                 }
            );


            const variablesSection =
                 await this.loadDefinition(
                 this.createSection(
                     page.title,
                     'Variables'
                    page.content
                 );
                 );


            /*
                this.setStatus(
            * Explicit variable detection button.
                    'Preload loaded.',
            *
                    'success'
            * This is intentionally separate from
                );
            * CodeMirror's input event because
            } catch (
            * CodeMirror 6 owns its own document
                error
            * state and does not always synchronize
             ) {
            * through the native textarea/input event.
                 console.error(
            */
                     'Preload Editor:',
             const detectVariablesButton =
                     error
                 this.createButton(
                );
                     'Detect variables',
 
                     'preload-editor-button-primary preload-editor-detect-variables',
                this.setStatus(
                     () =>
                    error.message ||
                        this.detectVariables()
                        'Could not load preload.',
                     'error'
                 );
                 );
            }
        },


             const detectHelp =
        async findSimilarPreloads(
                 $('<div>')
             title
                     .addClass(
        ) {
                         'preload-editor-help preload-editor-detect-variables-help'
            let query =
                 title
                     .replace(
                        /\/Preload$/i,
                         ''
                     )
                     )
                     .text(
                     .trim();
                        'Refresh the variable list after editing the preload.'
                    );


             const detectControls =
             if (!query) {
                $('<div>')
                query = title;
                    .addClass(
            }
                        'preload-editor-variable-detect'
 
                    )
            try {
                     .append(
                const results =
                         detectVariablesButton,
                     await this.searchPages(
                        detectHelp
                         query
                     );
                     );


             variablesSection.append(
                return results.filter(
                 detectControls
                    result =>
             );
                        /\/Preload$/i.test(
                            result.title
                        )
                );
             } catch (
                 error
             ) {
                console.error(
                    'Preload Editor search:',
                    error
                );


            const variableContainer =
                 return [];
                 $('<div>')
            }
                    .addClass(
        },
                        'preload-editor-variables'
                    );


             variablesSection.append(
        renderMissingPage(
                variableContainer
             title,
             );
            suggestions
        ) {
             this.workspace.empty();


             const configurationSection =
             const section =
                 this.createSection(
                 this.createSection(
                     'Variable configuration'
                     'Preload not found'
                 );
                 );


             const configurationContainer =
             $('<p>')
                $('<div>')
                .text(
                    .addClass(
                    'No preload exists at "' +
                        'preload-editor-variable-configuration'
                     title +
                     );
                    '".'
 
                 )
            configurationSection.append(
                 .appendTo(
                 configurationContainer
                     section
            );
 
            const configSection =
                 this.createSection(
                     'Generated configuration'
                 );
                 );


             const configPreview =
             if (
                 $('<pre>')
                suggestions.length
                     .addClass(
            ) {
                         'preload-editor-config-preview'
                 $('<h3>')
                     .text(
                         'Similar preloads'
                    )
                    .appendTo(
                        section
                     );
                     );


            configSection.append(
                const list =
                configPreview
                    $('<div>')
            );
                        .addClass(
                            'preload-editor-suggestions'
                        );


            const actions =
                suggestions.forEach(
                $('<div>')
                    result => {
                    .addClass(
                        list.append(
                        'preload-editor-actions'
                            this.createButton(
                     );
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                     }
                );
 
                section.append(
                    list
                );
            }


             actions.append(
             section.append(
                 this.createButton(
                 this.createButton(
                     'Save Preload + Config',
                     'Create this preload',
                     'preload-editor-button-primary',
                     'preload-editor-button-primary',
                     () =>
                     () =>
                         this.saveAll()
                         this.startNew(
                ),
                            title
 
                         )
                this.createButton(
                    'Save Preload Only',
                    '',
                    () =>
                         this.savePreloadOnly()
                 )
                 )
             );
             );


             right.append(
             this.workspace.append(
                 analysisSection,
                 section
                variablesSection,
                configurationSection,
                configSection,
                actions
             );
             );
        },


             layout.append(
        async startNew(
                left,
             title
                 right
        ) {
            );
            title =
                 String(title || '')
                    .trim();


             this.workspace.append(
             this.state = {
                 layout
                preloadTitle: title,
            );
                preloadSource: '',
                 preloadContent:
                    this.defaults.preload,


            this.reanalyze();
                config:
                    this.emptyConfig(),


            this.renderVariables(
                variables: ['PAGE'],
                 variableContainer
                conditions: [],
             );
                selectedVariable: 'PAGE',
                mode: 'new',
                 dirty: true
             };


             this.renderAnalysis(
             this.saveDraft();
                analysisContainer
            );


             this.renderVariableConfiguration(
             this.renderEditor();
                configurationContainer
            );


             this.renderConfigPreview(
             this.setStatus(
                 configPreview
                 'Creating a new preload.',
                'success'
             );
             );
        },


             this.updateEditorTools();
        async loadSamplePage() {
 
             const result =
             this.mountCodeMirror(
                await this.createPromptDialog(
                 textarea.get(0)
                    'Load Sample Page',
             ).catch(
                    'Enter the title of a normal wiki page to use as the starting point.'
                 error => {
                );
                     console.error(
 
                         'Preload Editor CodeMirror:',
            if (!result) {
                        error
                return;
            }
 
             this.setStatus(
                 'Loading sample page...',
                'loading'
            );
 
             try {
                 const page =
                     await this.getPage(
                         result
                     );
                     );


                     this.setStatus(
                if (!page) {
                         error.message ||
                     throw new Error(
                            'Could not initialize CodeMirror.',
                         'Sample page not found: ' +
                         'error'
                         result
                     );
                     );
                 }
                 }
            );
        },


        /*
                const sample =
        * Explicitly re-read the CodeMirror document
                    this.convertSampleToPreload(
        * and refresh every part of the right panel.
                        page.content
        *
                    );
        * This is the important new part.
        */
        detectVariables() {
            const source =
                this.getEditorContent();


            /*
                this.state = {
            * Make sure the editor's actual document
                    preloadTitle: '',
            * becomes the canonical preload content.
                    preloadSource: '',
            */
                    preloadContent:
            this.state.preloadContent =
                        sample,
                source;


            /*
                    config:
            * Mark the preload as modified because
                        this.emptyConfig(),
            * the user has edited the CodeMirror
            * document.
            */
            this.state.dirty = true;


            /*
                    variables: ['PAGE'],
            * Re-run the complete preload analysis.
                    conditions: [],
            *
                    selectedVariable: 'PAGE',
            * This discovers new variables such as:
                    mode: 'new',
            *
                    dirty: true
            * %%ZHS%%
                };
            *
            * and creates their default config
            * entries.
            */
            this.reanalyze();


            /*
                this.reanalyze();
            * Refresh the right panel.
            */
            this.renderVariablesBySelector();


            this.renderVariableConfigurationBySelector();
                this.saveDraft();


            this.renderAnalysisBySelector();
                this.renderEditor();


             this.renderConfigPreviewBySelector();
                this.setStatus(
                    'Sample page loaded.',
                    'success'
                );
             } catch (
                error
            ) {
                console.error(
                    'Preload Editor sample:',
                    error
                );


            /*
                 this.setStatus(
            * Refresh the variable selectors in
                     error.message ||
            * the editor toolbar too.
                         'Could not load sample page.',
            */
                    'error'
            this.updateEditorTools();
                );
 
            /*
            * Refresh syntax highlighting so a newly
            * detected variable becomes "known".
            */
            if (
                this.editorView &&
                this.editor &&
                this.editor.syntaxField
            ) {
                /*
                * The syntax decoration field normally
                * updates when CodeMirror's document
                * changes. Here we force a tiny
                * configuration refresh by replacing
                * the current syntax field with the
                * same configuration.
                *
                * If the field is already up to date,
                * CodeMirror simply keeps the current
                * document unchanged.
                */
                 try {
                    this.editorView.dispatch({
                        effects:
                            this.editorView.state
                                .field(
                                    this.editor.syntaxField
                                )
                                ? []
                                : []
                    });
                } catch (
                     error
                ) {
                    /*
                    * Syntax highlighting is cosmetic;
                    * never let it break variable
                    * detection.
                    */
                    console.warn(
                         'Preload Editor syntax refresh:',
                        error
                    );
                }
             }
             }
            this.setStatus(
                'Variables detected from the current preload.',
                'success'
            );
         },
         },


         reanalyze() {
         convertSampleToPreload(
             const source =
             source
                this.getEditorContent();
        ) {
 
             let content =
             const content =
                 String(source || '')
                 this.extractIncludeonly(
                     .trim();
                     source
                );


             const analysis =
             const include =
                 this.analyzePreload(
                 content.match(
                     content
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                 );
                 );


             this.state.preloadContent =
             if (include) {
                source;
                content =
                    include[1].trim();
            }


             this.state.variables =
             content =
                 analysis.variables;
                 content
                    .replace(
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                        ''
                    )
                    .trim();


             this.state.conditions =
             return (
                analysis.conditions;
                '<includeonly>\n' +
                content +
                '\n</includeonly>\n' +
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
            );
        },


             this.state.config =
        createPromptDialog(
                 this.prepareConfigForVariables(
             title,
                     this.state.config ||
            message
                         this.emptyConfig(),
        ) {
                    analysis
            return new Promise(
                );
                 resolve => {
                     const overlay =
                         $('<div>')
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );


            this.analysis =
                    const box =
                analysis;
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt'
                            );


            /*
                    $('<h2>')
            * PAGE is always available.
                        .text(title)
            *
                        .appendTo(
            * If the selected variable
                             box
            * disappeared, select PAGE or
                        );
            * the first currently used variable.
            */
            const available =
                ['PAGE'].concat(
                    analysis.variables.filter(
                        name =>
                             name !==
                            'PAGE'
                    )
                );


            if (
                    $('<p>')
                !available.includes(
                        .text(message)
                    this.state.selectedVariable
                        .appendTo(
                )
                            box
            ) {
                        );
                this.state.selectedVariable =
                    available[0] ||
                    'PAGE';
            }
        },


        renderAnalysis(
                    const input =
            container
                        this.textInput();
        ) {
            container.empty();


            const analysis =
                    const buttons =
                this.analysis ||
                        $('<div>')
                this.analyzePreload(
                            .addClass(
                    this.extractIncludeonly(
                                'preload-editor-prompt-buttons'
                        this.getEditorContent()
                            );
                    )
                );


            const variableCount =
                    const cancel =
                analysis.variables.length;
                        this.createButton(
                            'Cancel',
                            '',
                            () => {
                                overlay.remove();
                                resolve(null);
                            }
                        );


            const conditionCount =
                    const confirm =
                analysis.conditions.length;
                        this.createButton(
                            'Load',
                            'preload-editor-button-primary',
                            () => {
                                const value =
                                    String(
                                        input.val() ||
                                        ''
                                    ).trim();


            const configuredCount =
                                if (!value) {
                Object.keys(
                                    return;
                     this.state.config &&
                                }
                     this.state.config.fields
 
                         ? this.state.config.fields
                                overlay.remove();
                         : {}
                                resolve(value);
                ).length;
                            }
                        );
 
                     buttons.append(
                        cancel,
                        confirm
                    );
 
                     box.append(
                         input,
                         buttons
                    );


            const summary =
                     overlay.append(
                $('<div>')
                         box
                     .addClass(
                         'preload-editor-analysis-summary'
                     );
                     );


            const createMetric =
                     $(document.body)
                (
                    value,
                    label
                ) =>
                     $('<div>')
                        .addClass(
                            'preload-editor-analysis-metric'
                        )
                         .append(
                         .append(
                             $('<strong>')
                             overlay
                                .addClass(
                        );
                                    'preload-editor-analysis-value'
                                )
                                .text(value),


                            $('<span>')
                    input.trigger(
                                .addClass(
                        'focus'
                                    'preload-editor-analysis-label'
                    );
                                )
                                .text(label)
                        );


            summary.append(
                    input.on(
                createMetric(
                        'keydown.preloadEditor',
                    variableCount,
                        event => {
                    variableCount === 1
                            if (
                        ? 'variable'
                                event.key ===
                        : 'variables'
                                'Enter'
                ),
                            ) {
                                confirm.trigger(
                                    'click'
                                );
                            }


                createMetric(
                            if (
                    conditionCount,
                                event.key ===
                    conditionCount === 1
                                'Escape'
                        ? 'condition marker'
                            ) {
                        : 'condition markers'
                                cancel.trigger(
                 ),
                                    'click'
                                );
                            }
                        }
                    );
                 }
            );
        },


                createMetric(
        async loadDefinition(
                    configuredCount,
            title,
                    configuredCount === 1
            source
                        ? 'configured field'
        ) {
                        : 'configured fields'
            const content =
                 )
                this.extractIncludeonly(
            );
                    source
                 );


             container.append(
             let config =
                summary
                this.emptyConfig();
            );


             if (
             const configTitle =
                 analysis.conditionVariables
                 title.replace(
                    .length
                     /\/+$/,
            ) {
                    ''
                container.append(
                ) +
                     $('<div>')
                '/config.json';
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis
                                .conditionVariables
                                .join(', ')
                        )
                );
            }


             const validation =
             const configPage =
                 this.validateConfig(
                 await this.getPage(
                     this.state.config,
                     configTitle
                    analysis
                 );
                 );


             if (
             if (configPage) {
                validation.errors.length
                 try {
            ) {
                     config =
                 const errors =
                         JSON.parse(
                     $('<ul>')
                             configPage.content
                         .addClass(
                             'preload-editor-validation-errors'
                         );
                         );
                } catch (
                    error
                ) {
                    throw new Error(
                        'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
            }


                validation.errors.forEach(
            const analysis =
                    error => {
                this.analyzePreload(
                        $('<li>')
                     content
                            .text(error)
                            .appendTo(
                                errors
                            );
                     }
                 );
                 );


                 container.append(
            config =
                     errors
                 this.prepareConfigForVariables(
                     config,
                    analysis
                 );
                 );
            }


             if (
             this.state = {
                 validation.warnings.length
                 preloadTitle: title,
            ) {
                preloadSource: source,
                 const warnings =
                 preloadContent: source,
                    $('<ul>')
                config,
                        .addClass(
 
                            'preload-editor-validation-warnings'
                variables:
                        );
                    analysis.variables,


                 validation.warnings.forEach(
                 conditions:
                     warning => {
                     analysis.conditions,
                        $('<li>')
                            .text(warning)
                            .appendTo(
                                warnings
                            );
                    }
                );


                 container.append(
                 selectedVariable:
                     warnings
                    analysis.variables.includes(
                );
                        'PAGE'
            }
                     )
                        ? 'PAGE'
                        : (
                            analysis.variables[0] ||
                            'PAGE'
                        ),


            if (
                 mode: 'edit',
                !validation.errors.length &&
                 dirty: false
                 !validation.warnings.length
             };
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                 );
             }
        },


        renderAnalysisBySelector() {
            /*
            const container =
            * Loading the current server version means
                this.root &&
            * that this is now the canonical working copy.
                this.root.find(
            *
                    '.preload-editor-analysis'
            * Any old local draft for this exact preload
                );
            * has either already been restored or was
            * explicitly declined.
            */
            this.clearDraft(title);


             if (
             this.renderEditor();
                container &&
                container.length
            ) {
                this.renderAnalysis(
                    container
                );
            }
         },
         },


         /*
         // Editor
        * Variable list
        */


         renderVariables(
         renderEditor() {
             container
             this.destroyEditor();
        ) {
             this.workspace.empty();
             container.empty();


             const analysis =
            const layout =
                 this.analysis ||
                $('<div>')
                 this.analyzePreload(
                    .addClass(
                     this.extractIncludeonly(
                        'preload-editor-layout'
                         this.getEditorContent()
                    );
                     )
 
                 );
            const left =
                $('<div>')
                    .addClass(
                        'preload-editor-left'
                    );
 
            const right =
                $('<div>')
                    .addClass(
                        'preload-editor-right'
                    );
 
            const editorSection =
                this.createSection(
                    'Preload',
                    'preload-editor-main-section'
                );
 
            const meta =
                $('<div>')
                    .addClass(
                        'preload-editor-meta'
                    );
 
            $('<strong>')
                .text(
                    this.state.preloadTitle ||
                    'New preload'
                )
                .appendTo(
                    meta
                );
 
            if (
                this.state.mode ===
                'new'
            ) {
                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                    .appendTo(
                        meta
                    );
            }
 
            editorSection.append(
                meta
            );
 
            editorSection.append(
                this.renderEditorTools()
            );
 
            const textarea =
                this.textarea(
                    this.state.preloadContent
                )
                    .attr(
                        'id',
                        'preload-editor-content'
                    )
                    .addClass(
                        'preload-editor-content'
                    );
 
            const editorMount =
                $('<div>')
                    .addClass(
                        'preload-editor-codemirror-mount'
                    )
                    .append(
                        textarea
                    );
 
            editorSection.append(
                editorMount
            );
 
            left.append(
                editorSection
            );
 
            /*
            * RIGHT PANEL
            *
            * Analysis
            * Variables
            * Variable configuration
            * Generated configuration
            * Actions
            */
 
            const analysisSection =
                this.createSection(
                    'Analysis'
                );
 
            const analysisContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis'
                    );
 
            analysisSection.append(
                analysisContainer
            );
 
            const variablesSection =
                this.createSection(
                    'Variables'
                );
 
            /*
            * Explicit variable detection button.
            */
            const detectVariablesButton =
                this.createButton(
                    'Detect variables',
                    'preload-editor-button-primary preload-editor-detect-variables',
                    () =>
                        this.detectVariables()
                );
 
            const detectHelp =
                $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-detect-variables-help'
                    )
                    .text(
                        'Refresh the variable list after editing the preload.'
                    );
 
            const detectControls =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-detect'
                    )
                    .append(
                        detectVariablesButton,
                        detectHelp
                    );
 
            variablesSection.append(
                detectControls
            );
 
            const variableContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variables'
                    );
 
            variablesSection.append(
                variableContainer
            );
 
            const configurationSection =
                this.createSection(
                    'Variable configuration'
                );
 
            const configurationContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-configuration'
                    );
 
            configurationSection.append(
                configurationContainer
            );
 
            const configSection =
                this.createSection(
                    'Generated configuration'
                );
 
            const configPreview =
                $('<pre>')
                    .addClass(
                        'preload-editor-config-preview'
                    );
 
            configSection.append(
                configPreview
            );
 
            const actions =
                $('<div>')
                    .addClass(
                        'preload-editor-actions'
                    );
 
            actions.append(
                this.createButton(
                    'Save Preload + Config',
                    'preload-editor-button-primary',
                    () =>
                        this.saveAll()
                ),
 
                this.createButton(
                    'Save Preload Only',
                    '',
                    () =>
                        this.savePreloadOnly()
                )
            );
 
            right.append(
                analysisSection,
                variablesSection,
                configurationSection,
                configSection,
                actions
            );
 
            layout.append(
                left,
                right
            );
 
            this.workspace.append(
                layout
            );
 
            this.reanalyze();
 
            this.renderVariables(
                variableContainer
            );
 
            this.renderAnalysis(
                analysisContainer
            );
 
            this.renderVariableConfiguration(
                configurationContainer
            );
 
            this.renderConfigPreview(
                configPreview
            );
 
            this.updateEditorTools();
 
            this.mountCodeMirror(
                textarea.get(0)
            ).catch(
                error => {
                    console.error(
                        'Preload Editor CodeMirror:',
                        error
                    );
 
                    this.setStatus(
                        error.message ||
                            'Could not initialize CodeMirror.',
                        'error'
                    );
                }
            );
        },
 
        /*
        * Explicitly re-read the CodeMirror document
        * and refresh every part of the right panel.
        */
        detectVariables() {
            const source =
                this.getEditorContent();
 
            this.state.preloadContent =
                source;
 
            this.state.dirty =
                true;
 
            this.reanalyze();
 
            this.renderVariablesBySelector();
 
            this.renderVariableConfigurationBySelector();
 
            this.renderAnalysisBySelector();
 
            this.renderConfigPreviewBySelector();
 
            this.updateEditorTools();
 
            if (
                this.editorView &&
                this.editor &&
                this.editor.syntaxField
            ) {
                try {
                    this.editorView.dispatch({
                        effects:
                            this.editorView.state
                                .field(
                                    this.editor.syntaxField
                                )
                                ? []
                                : []
                    });
                } catch (
                    error
                ) {
                    console.warn(
                        'Preload Editor syntax refresh:',
                        error
                    );
                }
            }
 
            /*
            * Detection itself changes/synchronizes the
            * working state, so persist the resulting
            * draft immediately.
            */
            this.saveDraft();
 
            this.setStatus(
                'Variables detected from the current preload.',
                'success'
            );
        },
 
        reanalyze() {
            const source =
                this.getEditorContent();
 
            const content =
                this.extractIncludeonly(
                    source
                );
 
            const analysis =
                this.analyzePreload(
                    content
                );
 
            this.state.preloadContent =
                source;
 
            this.state.variables =
                analysis.variables;
 
            this.state.conditions =
                analysis.conditions;
 
            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );
 
            this.analysis =
                analysis;
 
            const available =
                ['PAGE'].concat(
                    analysis.variables.filter(
                        name =>
                            name !==
                            'PAGE'
                    )
                );
 
            if (
                !available.includes(
                    this.state.selectedVariable
                )
            ) {
                this.state.selectedVariable =
                    available[0] ||
                    'PAGE';
            }
        },
 
        renderAnalysis(
            container
        ) {
            container.empty();
 
            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );
 
            const variableCount =
                analysis.variables.length;
 
            const conditionCount =
                analysis.conditions.length;
 
            const configuredCount =
                Object.keys(
                    this.state.config &&
                    this.state.config.fields
                        ? this.state.config.fields
                        : {}
                ).length;
 
            const summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );
 
            const createMetric =
                (
                    value,
                    label
                ) =>
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-metric'
                        )
                        .append(
                            $('<strong>')
                                .addClass(
                                    'preload-editor-analysis-value'
                                )
                                .text(value),
 
                            $('<span>')
                                .addClass(
                                    'preload-editor-analysis-label'
                                )
                                .text(label)
                        );
 
            summary.append(
                createMetric(
                    variableCount,
                    variableCount === 1
                        ? 'variable'
                        : 'variables'
                ),
 
                createMetric(
                    conditionCount,
                    conditionCount === 1
                        ? 'condition marker'
                        : 'condition markers'
                ),
 
                createMetric(
                    configuredCount,
                    configuredCount === 1
                        ? 'configured field'
                        : 'configured fields'
                )
            );
 
            container.append(
                summary
            );
 
            if (
                analysis.conditionVariables
                    .length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis
                                .conditionVariables
                                .join(', ')
                        )
                );
            }
 
            const validation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );
 
            if (
                validation.errors.length
            ) {
                const errors =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-errors'
                        );
 
                validation.errors.forEach(
                    error => {
                        $('<li>')
                            .text(error)
                            .appendTo(
                                errors
                            );
                    }
                );
 
                container.append(
                    errors
                );
            }
 
            if (
                validation.warnings.length
            ) {
                const warnings =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-warnings'
                        );
 
                validation.warnings.forEach(
                    warning => {
                        $('<li>')
                            .text(warning)
                            .appendTo(
                                warnings
                            );
                    }
                );
 
                container.append(
                    warnings
                );
            }
 
            if (
                !validation.errors.length &&
                !validation.warnings.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                );
            }
        },
 
        renderAnalysisBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-analysis'
                );
 
            if (
                container &&
                container.length
            ) {
                this.renderAnalysis(
                    container
                );
            }
        },
 
        /*
        * Variable list
        */
 
        renderVariables(
            container
        ) {
            container.empty();
 
             const analysis =
                 this.analysis ||
                 this.analyzePreload(
                     this.extractIncludeonly(
                         this.getEditorContent()
                     )
                 );
 
            const variables =
                analysis.variables.slice();
 
            const ordered =
                ['PAGE'];
 
            variables.forEach(
                name => {
                    if (
                        name !== 'PAGE' &&
                        !ordered.includes(
                            name
                        )
                    ) {
                        ordered.push(
                            name
                        );
                    }
                }
            );
 
            /*
            * Preserve configured-but-unused fields
            * in the compact list.
            */
            const configuredFields =
                this.state.config &&
                this.state.config.fields
                    ? Object.keys(
                        this.state.config.fields
                    )
                    : [];
 
            configuredFields.forEach(
                name => {
                    const upper =
                        name.toUpperCase();
 
                    if (
                        upper !== 'PAGE' &&
                        !ordered.includes(
                            upper
                        )
                    ) {
                        ordered.push(
                            upper
                        );
                    }
                }
            );
 
            const list =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-list'
                    );
 
            ordered.forEach(
                name => {
                    const isSelected =
                        name ===
                        this.state
                            .selectedVariable;
 
                    const isUsed =
                        analysis.variables.includes(
                            name
                        );
 
                    const item =
                        $('<button>')
                            .attr({
                                type: 'button',
                                'data-variable':
                                    name
                            })
                            .addClass(
                                'preload-editor-variable-item'
                            )
                            .toggleClass(
                                'is-selected',
                                isSelected
                            )
                            .on(
                                'click.preloadEditor',
                                () =>
                                    this.selectVariable(
                                        name
                                    )
                            );
 
                    const nameLabel =
                        $('<span>')
                            .addClass(
                                'preload-editor-variable-name'
                            )
                            .text(name);
 
                    item.append(
                        nameLabel
                    );


            const variables =
                analysis.variables.slice();
            const ordered =
                ['PAGE'];
            variables.forEach(
                name => {
                     if (
                     if (
                         name !== 'PAGE' &&
                         name ===
                         !ordered.includes(
                        'PAGE'
                             name
                    ) {
                         )
                         item.append(
                             $('<span>')
                                .addClass(
                                    'preload-editor-badge'
                                )
                                .text(
                                    'AUTOMATIC'
                                )
                         );
                    } else if (
                        !isUsed
                     ) {
                     ) {
                         ordered.push(
                         item.append(
                             name
                             $('<span>')
                                .addClass(
                                    'preload-editor-badge is-warning'
                                )
                                .text(
                                    'UNUSED'
                                )
                         );
                         );
                     }
                     }
                    list.append(
                        item
                    );
                 }
                 }
             );
             );


             const list =
             container.append(
                $('<div>')
                list
                    .addClass(
            );
                        'preload-editor-variable-list'
                    );


             ordered.forEach(
             if (
                name => {
                !ordered.length
                    const isSelected =
            ) {
                         name ===
                container.append(
                         this.state
                    $('<div>')
                             .selectedVariable;
                        .addClass(
                            'preload-editor-empty'
                         )
                         .text(
                             'No variables found in the preload.'
                        )
                );
            }
        },


                    const isUsed =
        renderVariablesBySelector() {
                        analysis.variables.includes(
            const container =
                            name
                this.root &&
                        );
                this.root.find(
                    '.preload-editor-variables'
                );


                    const item =
            if (
                        $('<button>')
                container &&
                            .attr({
                container.length
                                type: 'button',
            ) {
                                'data-variable':
                this.renderVariables(
                                    name
                    container
                            })
                );
                            .addClass(
            }
                                'preload-editor-variable-item'
        },
                            )
                            .toggleClass(
                                'is-selected',
                                isSelected
                            )
                            .on(
                                'click.preloadEditor',
                                () =>
                                    this.selectVariable(
                                        name
                                    )
                            );


                    const nameLabel =
        /*
                        $('<span>')
        * Select a variable and display only
                            .addClass(
        * its configuration.
                                'preload-editor-variable-name'
        *
                            )
        * No scrolling.
                            .text(name);
        */
        selectVariable(
            name
        ) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();


                    item.append(
            if (!name) {
                        nameLabel
                return;
                    );
            }


                    if (
            const available =
                        name ===
                ['PAGE'].concat(
                        'PAGE'
                     (
                    ) {
                         this.analysis &&
                        item.append(
                         this.analysis.variables
                            $('<span>')
                             ? this.analysis
                                .addClass(
                                 .variables
                                    'preload-editor-badge'
                            : []
                                )
                    ).filter(
                                .text(
                         variable =>
                                    'AUTOMATIC'
                            variable !==
                                )
                            'PAGE'
                        );
                     )
                     } else if (
                 );
                         !isUsed
                    ) {
                         item.append(
                             $('<span>')
                                 .addClass(
                                    'preload-editor-badge is-warning'
                                )
                                .text(
                                    'UNUSED'
                                )
                         );
                    }
 
                    list.append(
                        item
                     );
                 }
            );
 
            container.append(
                list
            );


             if (
             if (
                 !ordered.length
                 !available.includes(
                    name
                )
             ) {
             ) {
                 container.append(
                 return;
                    $('<div>')
                        .addClass(
                            'preload-editor-empty'
                        )
                        .text(
                            'No variables found in the preload.'
                        )
                );
             }
             }
            this.state.selectedVariable =
                name;
            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
         },
         },


         renderVariablesBySelector() {
         renderVariableConfigurationBySelector() {
             const container =
             const container =
                 this.root &&
                 this.root &&
                 this.root.find(
                 this.root.find(
                     '.preload-editor-variables'
                     '.preload-editor-variable-configuration'
                 );
                 );


Line 3,317: Line 3,916:
                 container.length
                 container.length
             ) {
             ) {
                 this.renderVariables(
                 this.renderVariableConfiguration(
                     container
                     container
                 );
                 );
Line 3,323: Line 3,922:
         },
         },


         /*
         renderVariableConfiguration(
        * Select a variable and display only
             container
        * its configuration.
        *
        * No scrolling.
        */
        selectVariable(
             name
         ) {
         ) {
             name =
             container.empty();
                String(name || '')
                    .trim()
                    .toUpperCase();


            if (!name) {
             const analysis =
                return;
                 this.analysis ||
            }
                 this.analyzePreload(
 
            const available =
                ['PAGE'].concat(
                    (
                        this.analysis &&
                        this.analysis.variables
                            ? this.analysis
                                .variables
                            : []
                    ).filter(
                        variable =>
                            variable !==
                            'PAGE'
                    )
                );
 
            if (
                !available.includes(
                    name
                )
            ) {
                return;
            }
 
            this.state.selectedVariable =
                name;
 
            this.renderVariablesBySelector();
 
            this.renderVariableConfigurationBySelector();
        },
 
        renderVariableConfigurationBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variable-configuration'
                );
 
            if (
                container &&
                container.length
            ) {
                this.renderVariableConfiguration(
                    container
                );
            }
        },
 
        renderVariableConfiguration(
            container
        ) {
            container.empty();
 
             const analysis =
                 this.analysis ||
                 this.analyzePreload(
                     this.extractIncludeonly(
                     this.extractIncludeonly(
                         this.getEditorContent()
                         this.getEditorContent()
Line 4,473: Line 5,006:


         markDirty() {
         markDirty() {
             this.state.dirty = true;
             this.state.dirty =
                true;
 
            /*
            * Any configuration change is also a
            * local draft change.
            */
            this.saveDraft();
         },
         },


Line 4,518: Line 5,058:
                     validation
                     validation
                 );
                 );
                /*
                * Keep the local draft because the
                * save did not complete.
                */
                this.saveDraft();


                 return;
                 return;
Line 4,547: Line 5,093:
                 this.state.dirty =
                 this.state.dirty =
                     false;
                     false;
                /*
                * The preload has now been explicitly
                * saved, so its local recovery copy is
                * no longer needed.
                */
                this.clearDraft(
                    title
                );


                 this.setStatus(
                 this.setStatus(
Line 4,567: Line 5,122:
                     error
                     error
                 );
                 );
                /*
                * Keep the local draft intact if
                * the wiki save failed.
                */
                this.saveDraft();


                 this.setStatus(
                 this.setStatus(
Line 4,620: Line 5,181:
             * synchronized with anything newly
             * synchronized with anything newly
             * typed into the preload.
             * typed into the preload.
            *
            * This means even if the user forgot
            * to click Detect variables, saving
            * will still catch new variables.
             */
             */
             this.reanalyze();
             this.reanalyze();
Line 4,636: Line 5,193:
                     validation
                     validation
                 );
                 );
                /*
                * Keep the recovery copy.
                */
                this.saveDraft();


                 return;
                 return;
Line 4,689: Line 5,251:
                 this.state.preloadTitle =
                 this.state.preloadTitle =
                     title;
                     title;
                /*
                * Both the preload and config are now
                * safely on the wiki.
                */
                this.clearDraft(
                    title
                );


                 this.setStatus(
                 this.setStatus(
Line 4,709: Line 5,279:
                     error
                     error
                 );
                 );
                /*
                * IMPORTANT:
                * if either wiki save fails, retain the
                * browser draft so nothing is lost.
                */
                this.saveDraft();


                 this.setStatus(
                 this.setStatus(
Line 4,796: Line 5,373:
             * Synchronize configuration one final
             * Synchronize configuration one final
             * time before saving.
             * time before saving.
            *
            * This catches variables even if the
            * user never clicked Detect variables.
             */
             */
             this.state.config =
             this.state.config =
Line 4,882: Line 5,456:
         ) {
         ) {
             this.renderAnalysisBySelector();
             this.renderAnalysisBySelector();
            /*
            * Keep the local recovery copy because
            * validation prevented the wiki save.
            */
            this.saveDraft();


             this.setStatus(
             this.setStatus(

Revision as of 10:16, 21 September 2026

(function (mw, $) {
    'use strict';

    const PreloadEditor = {
        version: '0.6.0',

        state: {
            preloadTitle: '',
            preloadSource: '',
            preloadContent: '',
            config: null,
            variables: [],
            conditions: [],
            selectedVariable: 'PAGE',
            mode: 'idle',
            dirty: false
        },

        editor: null,
        editorView: null,
        analysis: null,
        root: null,
        workspace: null,
        status: null,
        editorTools: null,

        /*
         * Browser-local draft storage.
         *
         * This is deliberately separate from the wiki.
         * Nothing is saved to MediaWiki until the user
         * explicitly clicks one of the Save buttons.
         */
        draftStorageKey:
            'PetitPlanet.PreloadEditor.drafts.v1',

        defaults: {
            preload:
                '<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
        },

        // Browser-local draft storage

        getDraftStorage() {
            try {
                const raw =
                    window.localStorage.getItem(
                        this.draftStorageKey
                    );

                if (!raw) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }

                const parsed =
                    JSON.parse(raw);

                if (
                    !parsed ||
                    typeof parsed !== 'object' ||
                    Array.isArray(parsed)
                ) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }

                if (
                    !parsed.drafts ||
                    typeof parsed.drafts !== 'object' ||
                    Array.isArray(parsed.drafts)
                ) {
                    parsed.drafts = {};
                }

                return parsed;
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not read local drafts.',
                    error
                );

                return {
                    active: null,
                    drafts: {}
                };
            }
        },

        setDraftStorage(storage) {
            try {
                window.localStorage.setItem(
                    this.draftStorageKey,
                    JSON.stringify(storage)
                );

                return true;
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not save local draft.',
                    error
                );

                return false;
            }
        },

        getDraftId(title) {
            title =
                String(title || '')
                    .trim();

            /*
             * Untitled drafts are still useful for a new preload
             * before the user has entered/saved a title.
             */
            if (!title) {
                return '__untitled__';
            }

            return title.toLowerCase();
        },

        getDraftSnapshot() {
            let content =
                this.getEditorContent();

            /*
             * If CodeMirror is not mounted yet, use the state
             * value instead.
             */
            if (
                !this.editorView &&
                !content
            ) {
                content =
                    this.state.preloadContent ||
                    '';
            }

            let config = null;

            try {
                config =
                    this.state.config
                        ? JSON.parse(
                            JSON.stringify(
                                this.state.config
                            )
                        )
                        : this.emptyConfig();
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not clone config for local draft.',
                    error
                );

                config =
                    this.emptyConfig();
            }

            return {
                version: 1,

                preloadTitle:
                    String(
                        this.state.preloadTitle ||
                        ''
                    ),

                preloadSource:
                    String(
                        this.state.preloadSource ||
                        ''
                    ),

                preloadContent:
                    String(
                        content ||
                        ''
                    ),

                config,

                variables:
                    Array.isArray(
                        this.state.variables
                    )
                        ? this.state.variables.slice()
                        : [],

                conditions:
                    Array.isArray(
                        this.state.conditions
                    )
                        ? this.state.conditions.slice()
                        : [],

                selectedVariable:
                    String(
                        this.state.selectedVariable ||
                        'PAGE'
                    ),

                mode:
                    this.state.mode ||
                    'edit',

                dirty: true,

                savedAt:
                    Date.now()
            };
        },

        saveDraft() {
            /*
             * Do not save while the editor is merely showing
             * a loaded/saved page with no local changes.
             */
            if (!this.state.dirty) {
                return;
            }

            const snapshot =
                this.getDraftSnapshot();

            const id =
                this.getDraftId(
                    snapshot.preloadTitle
                );

            const storage =
                this.getDraftStorage();

            storage.drafts[id] =
                snapshot;

            /*
             * Remember which draft was being worked on most
             * recently. This is what lets the editor restore
             * the right thing after a crash/reload.
             */
            storage.active = id;

            this.setDraftStorage(
                storage
            );
        },

        clearDraft(title) {
            const storage =
                this.getDraftStorage();

            const id =
                this.getDraftId(
                    title
                );

            if (
                storage.drafts &&
                storage.drafts[id]
            ) {
                delete storage.drafts[id];
            }

            if (
                storage.active === id
            ) {
                storage.active = null;
            }

            this.setDraftStorage(
                storage
            );
        },

        getMostRecentDraft() {
            const storage =
                this.getDraftStorage();

            if (
                !storage.drafts ||
                !Object.keys(
                    storage.drafts
                ).length
            ) {
                return null;
            }

            /*
             * Prefer the explicitly active draft.
             */
            if (
                storage.active &&
                storage.drafts[
                    storage.active
                ]
            ) {
                return {
                    id: storage.active,
                    draft:
                        storage.drafts[
                            storage.active
                        ]
                };
            }

            /*
             * Fallback: find the most recently saved draft.
             */
            let latestId = null;
            let latestDraft = null;

            Object.entries(
                storage.drafts
            ).forEach(
                ([id, draft]) => {
                    if (
                        !draft ||
                        typeof draft !==
                            'object'
                    ) {
                        return;
                    }

                    if (
                        !latestDraft ||
                        (
                            Number(
                                draft.savedAt
                            ) || 0
                        ) >
                        (
                            Number(
                                latestDraft.savedAt
                            ) || 0
                        )
                    ) {
                        latestId = id;
                        latestDraft = draft;
                    }
                }
            );

            if (
                !latestDraft
            ) {
                return null;
            }

            return {
                id: latestId,
                draft: latestDraft
            };
        },

        restoreDraft() {
            const result =
                this.getMostRecentDraft();

            if (
                !result ||
                !result.draft
            ) {
                return false;
            }

            const draft =
                result.draft;

            const title =
                String(
                    draft.preloadTitle ||
                    ''
                ).trim();

            const displayTitle =
                title ||
                'an untitled preload';

            const savedAt =
                draft.savedAt
                    ? new Date(
                        draft.savedAt
                    ).toLocaleString()
                    : '';

            const message =
                'Preload Editor found unsaved local work for ' +
                displayTitle +
                (savedAt
                    ? '\n\nLast saved locally: ' +
                      savedAt
                    : '') +
                '\n\nRestore it?';

            const restore =
                window.confirm(
                    message
                );

            if (!restore) {
                /*
                 * The user explicitly rejected recovery,
                 * so remove that draft instead of asking
                 * about the same stale work on every reload.
                 */
                this.clearDraft(
                    title
                );

                return false;
            }

            let config =
                this.emptyConfig();

            if (
                draft.config &&
                typeof draft.config ===
                    'object' &&
                !Array.isArray(
                    draft.config
                )
            ) {
                try {
                    config =
                        this.normalizeConfig(
                            JSON.parse(
                                JSON.stringify(
                                    draft.config
                                )
                            )
                        );
                } catch (
                    error
                ) {
                    console.warn(
                        'Preload Editor: Could not restore local config.',
                        error
                    );
                }
            }

            this.state = {
                preloadTitle:
                    title,

                preloadSource:
                    String(
                        draft.preloadSource ||
                        ''
                    ),

                preloadContent:
                    String(
                        draft.preloadContent ||
                        ''
                    ),

                config,

                variables:
                    Array.isArray(
                        draft.variables
                    )
                        ? draft.variables.slice()
                        : [],

                conditions:
                    Array.isArray(
                        draft.conditions
                    )
                        ? draft.conditions.slice()
                        : [],

                selectedVariable:
                    String(
                        draft.selectedVariable ||
                        'PAGE'
                    ),

                mode:
                    draft.mode ||
                    'edit',

                dirty: true
            };

            /*
             * Reanalyze from the actual restored preload.
             * This also recreates any variable configuration
             * that may have been introduced before the crash.
             */
            this.reanalyze();

            this.renderEditor();

            this.setStatus(
                'Restored unsaved local work from this browser.',
                'warning'
            );

            mw.notify(
                'Unsaved Preload Editor work restored.',
                {
                    type: 'success'
                }
            );

            return true;
        },

        // API

        async getPage(title) {
            const response = await new mw.Api().get({
                action: 'query',
                prop: 'revisions',
                titles: title,
                rvprop: 'content',
                rvslots: 'main',
                formatversion: 2
            });

            const page =
                response &&
                response.query &&
                response.query.pages &&
                response.query.pages[0];

            if (!page || page.missing) return null;

            const content =
                page.revisions &&
                page.revisions[0] &&
                page.revisions[0].slots &&
                page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;

            return {
                title: page.title || title,
                content:
                    typeof content === 'string'
                        ? content
                        : ''
            };
        },

        async pageExists(title) {
            return !!await this.getPage(title);
        },

        async searchPages(query) {
            const response = await new mw.Api().get({
                action: 'query',
                list: 'search',
                srsearch: query,
                srlimit: 10,
                srnamespace: 10,
                formatversion: 2
            });

            return (
                response &&
                response.query &&
                response.query.search
            ) || [];
        },

        async savePage(
            title,
            content,
            summary,
            createOnly
        ) {
            const params = {
                action: 'edit',
                title,
                text: content,
                summary:
                    summary ||
                    'Updated with Preload Editor',
                formatversion: 2
            };

            if (createOnly) {
                params.createonly = true;
            }

            return new mw.Api().postWithToken(
                'csrf',
                params
            );
        },

        // Preload parsing

        extractIncludeonly(source) {
            const match =
                String(source || '').match(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                );

            return match
                ? match[1].trim()
                : String(source || '').trim();
        },

        findVariables(content) {
            const variables = [];
            const pattern =
                /%%([A-Za-z0-9_-]+)%%/g;

            let match;

            while (
                (match = pattern.exec(content)) !==
                null
            ) {
                const name =
                    match[1].toUpperCase();

                if (!variables.includes(name)) {
                    variables.push(name);
                }
            }

            return variables;
        },

        findConditions(content) {
            const conditions = [];
            const pattern =
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;

            let match;

            while (
                (match = pattern.exec(content)) !==
                null
            ) {
                conditions.push({
                    type:
                        match[1].toLowerCase(),
                    expression:
                        match[2].trim(),
                    index: match.index
                });
            }

            return conditions;
        },

        findConditionVariables(content) {
            const variables = [];

            this.findConditions(content).forEach(
                condition => {
                    if (
                        condition.type !==
                            'if' &&
                        condition.type !==
                            'elseif'
                    ) {
                        return;
                    }

                    const matches =
                        condition.expression.match(
                            /[A-Za-z0-9_-]+/g
                        ) || [];

                    matches.forEach(name => {
                        name =
                            name.toUpperCase();

                        if (
                            !variables.includes(
                                name
                            )
                        ) {
                            variables.push(
                                name
                            );
                        }
                    });
                }
            );

            return variables;
        },

        analyzePreload(content) {
            return {
                variables:
                    this.findVariables(
                        content
                    ),

                conditions:
                    this.findConditions(
                        content
                    ),

                conditionVariables:
                    this.findConditionVariables(
                        content
                    )
            };
        },

        // Config

        getAutomaticPageField() {
            return {
                label: 'Page Name',
                description: 'Name of the page.',
                type: 'page',
                required: true,
                namespace: 'Main',
                'allow-new': true
            };
        },

        emptyConfig() {
            return {
                settings: {
                    incomplete: 'add-stub'
                },

                fields: {
                    PAGE:
                        this.getAutomaticPageField()
                }
            };
        },

        normalizeConfig(config) {
            if (
                !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                config =
                    this.emptyConfig();
            }

            if (
                !config.settings ||
                typeof config.settings !==
                    'object' ||
                Array.isArray(
                    config.settings
                )
            ) {
                config.settings = {};
            }

            if (
                !config.fields ||
                typeof config.fields !==
                    'object' ||
                Array.isArray(
                    config.fields
                )
            ) {
                config.fields = {};
            }

            /*
             * PAGE is always a special,
             * automatic field.
             *
             * Its configuration is precompiled
             * and cannot be edited.
             */
            config.fields.PAGE =
                this.getAutomaticPageField();

            return config;
        },

        createDefaultField(
            name,
            analysis
        ) {
            return {
                label:
                    this.humanizeName(
                        name
                    ),

                description: '',

                type:
                    analysis.conditionVariables.includes(
                        name
                    )
                        ? 'boolean'
                        : 'text'
            };
        },

        humanizeName(name) {
            return String(name || '')
                .replace(
                    /[_-]+/g,
                    ' '
                )
                .replace(
                    /([a-z])([A-Z])/g,
                    '$1 $2'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim()
                .replace(
                    /\b\w/g,
                    char =>
                        char.toUpperCase()
                );
        },

        prepareConfigForVariables(
            config,
            analysis
        ) {
            config =
                this.normalizeConfig(
                    config
                );

            const existing =
                config.fields || {};

            const fields = {};

            /*
             * PAGE is always automatic.
             */
            fields.PAGE =
                this.getAutomaticPageField();

            /*
             * Add every variable currently
             * detected in the preload.
             *
             * Existing configuration is
             * preserved.
             *
             * New variables receive a
             * default configuration.
             */
            analysis.variables.forEach(
                name => {
                    if (name === 'PAGE') {
                        return;
                    }

                    fields[name] =
                        existing[name] ||
                        this.createDefaultField(
                            name,
                            analysis
                        );
                }
            );

            /*
             * Preserve existing fields even
             * if they are currently unused.
             *
             * This allows users to keep
             * configuration around while
             * editing the preload.
             */
            Object.keys(existing).forEach(
                name => {
                    const upper =
                        name.toUpperCase();

                    if (
                        upper !== 'PAGE' &&
                        !fields[upper]
                    ) {
                        fields[upper] =
                            existing[name];
                    }
                }
            );

            config.fields = fields;

            return config;
        },

        validateConfig(
            config,
            analysis
        ) {
            const errors = [];
            const warnings = [];

            if (
                !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                return {
                    valid: false,
                    errors: [
                        'Config must be an object.'
                    ],
                    warnings: []
                };
            }

            if (
                !config.fields ||
                typeof config.fields !==
                    'object' ||
                Array.isArray(
                    config.fields
                )
            ) {
                return {
                    valid: false,
                    errors: [
                        '"fields" must be an object.'
                    ],
                    warnings
                };
            }

            const configured =
                Object.keys(
                    config.fields
                ).map(
                    name =>
                        name.toUpperCase()
                );

            analysis.variables.forEach(
                name => {
                    if (
                        !configured.includes(
                            name
                        )
                    ) {
                        errors.push(
                            'Variable "' +
                            name +
                            '" is not configured.'
                        );
                    }
                }
            );

            configured.forEach(
                name => {
                    if (
                        !analysis.variables.includes(
                            name
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is not used by the preload.'
                        );
                    }
                }
            );

            if (
                !configured.includes(
                    'PAGE'
                )
            ) {
                errors.push(
                    'PAGE must always be configured.'
                );
            }

            Object.entries(
                config.fields
            ).forEach(
                ([name, field]) => {
                    if (
                        !field ||
                        typeof field !==
                            'object' ||
                        Array.isArray(field)
                    ) {
                        errors.push(
                            'Field "' +
                            name +
                            '" must be an object.'
                        );

                        return;
                    }

                    if (!field.type) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" has no type.'
                        );
                    }

                    if (
                        (
                            field.type ===
                                'select' ||
                            field.type ===
                                'multiselect'
                        ) &&
                        !field.source &&
                        !Array.isArray(
                            field.options
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is a ' +
                            field.type +
                            ' without a source or manual options.'
                        );
                    }
                }
            );

            return {
                valid:
                    !errors.length,
                errors,
                warnings
            };
        },

        // Transformation helpers

        transformationObject(
            transform,
            prefix,
            suffix,
            from,
            to
        ) {
            if (!transform) {
                return null;
            }

            switch (transform) {
                case 'remove-prefix':
                    return prefix
                        ? {
                            transform,
                            prefix
                        }
                        : null;

                case 'remove-suffix':
                    return suffix
                        ? {
                            transform,
                            suffix
                        }
                        : null;

                case 'replace':
                    return from === ''
                        ? null
                        : {
                            transform,
                            from,
                            to: to || ''
                        };

                default:
                    return null;
            }
        },

        // UI helpers

        createSection(
            title,
            className
        ) {
            const section =
                $('<section>')
                    .addClass(
                        'preload-editor-section'
                    );

            if (className) {
                section.addClass(
                    className
                );
            }

            $('<h2>')
                .addClass(
                    'preload-editor-section-title'
                )
                .text(title)
                .appendTo(section);

            return section;
        },

        createButton(
            label,
            className,
            callback
        ) {
            const button =
                $('<button>')
                    .attr(
                        'type',
                        'button'
                    )
                    .addClass(
                        'preload-editor-button'
                    )
                    .text(label);

            if (className) {
                button.addClass(
                    className
                );
            }

            if (callback) {
                button.on(
                    'click.preloadEditor',
                    callback
                );
            }

            return button;
        },

        fieldRow(
            label,
            input,
            description
        ) {
            const row =
                $('<div>')
                    .addClass(
                        'preload-editor-field'
                    );

            $('<label>')
                .addClass(
                    'preload-editor-label'
                )
                .text(label)
                .appendTo(row);

            if (description) {
                $('<div>')
                    .addClass(
                        'preload-editor-help'
                    )
                    .text(description)
                    .appendTo(row);
            }

            row.append(input);

            return row;
        },

        textInput(
            value,
            placeholder
        ) {
            return $('<input>')
                .attr({
                    type: 'text',
                    value:
                        value == null
                            ? ''
                            : value,

                    placeholder:
                        placeholder || ''
                })
                .addClass(
                    'preload-editor-input'
                );
        },

        textarea(value) {
            return $('<textarea>')
                .val(
                    value == null
                        ? ''
                        : value
                )
                .addClass(
                    'preload-editor-textarea'
                );
        },

        checkbox(checked) {
            return $('<input>')
                .attr(
                    'type',
                    'checkbox'
                )
                .prop(
                    'checked',
                    !!checked
                )
                .addClass(
                    'preload-editor-checkbox'
                );
        },

        select(
            options,
            value
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-select'
                    );

            options.forEach(
                option => {
                    $('<option>')
                        .attr(
                            'value',
                            option.value
                        )
                        .text(
                            option.label
                        )
                        .prop(
                            'selected',
                            String(
                                option.value
                            ) ===
                            String(
                                value ===
                                    undefined
                                    ? ''
                                    : value
                            )
                        )
                        .appendTo(
                            select
                        );
                }
            );

            return select;
        },

        // CodeMirror

        destroyEditor() {
            if (!this.editor) {
                this.editorView = null;
                this.editorTools = null;
                return;
            }

            const {
                view,
                inputHandler
            } = this.editor;

            if (
                view &&
                inputHandler
            ) {
                view.dom.removeEventListener(
                    'input',
                    inputHandler
                );
            }

            if (view) {
                view.destroy();
            }

            this.editor = null;
            this.editorView = null;
            this.editorTools = null;
        },

        getEditorView() {
            return this.editorView;
        },

        getEditorContent() {
            /*
             * Always read directly from the
             * CodeMirror state when available.
             *
             * This is important because the
             * native textarea may not contain
             * the actual current document.
             */
            if (
                this.editorView &&
                this.editorView.state
            ) {
                return this.editorView.state.doc.toString();
            }

            return (
                this.state.preloadContent ||
                ''
            );
        },

        setEditorContent(content) {
            content =
                String(content || '');

            if (!this.editorView) {
                this.state.preloadContent =
                    content;

                return;
            }

            const current =
                this.editorView.state.doc.toString();

            if (current === content) {
                return;
            }

            this.editorView.dispatch({
                changes: {
                    from: 0,
                    to:
                        this.editorView.state
                            .doc.length,
                    insert: content
                }
            });
        },

        getEditorSelection() {
            if (
                !this.editorView ||
                !this.editorView.state
            ) {
                return null;
            }

            const selection =
                this.editorView.state
                    .selection.main;

            return {
                from:
                    selection.from,

                to:
                    selection.to,

                text:
                    this.editorView.state.doc.sliceString(
                        selection.from,
                        selection.to
                    )
            };
        },

        focusEditor() {
            if (this.editorView) {
                this.editorView.focus();
            }
        },

        insertEditorText(
            text,
            selectionFrom,
            selectionTo,
            cursorPosition
        ) {
            if (!this.editorView) {
                return;
            }

            const view =
                this.editorView;

            const selection =
                view.state.selection.main;

            const from =
                selectionFrom ===
                    undefined
                    ? selection.from
                    : selectionFrom;

            const to =
                selectionTo ===
                    undefined
                    ? selection.to
                    : selectionTo;

            const cursor =
                cursorPosition ===
                    undefined
                    ? from + text.length
                    : cursorPosition;

            view.dispatch({
                changes: {
                    from,
                    to,
                    insert: text
                },

                selection: {
                    anchor: cursor
                }
            });

            view.focus();
        },

        // V2.3 editor tools

        wrapSelectionWithVariable() {
            const selected =
                this.getEditorSelection();

            if (
                !selected ||
                selected.from ===
                    selected.to
            ) {
                this.focusEditor();
                return;
            }

            const replacement =
                '%%' +
                selected.text +
                '%%';

            this.insertEditorText(
                replacement,
                selected.from,
                selected.to,
                selected.from +
                    replacement.length
            );
        },

        insertVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();

            if (!name) {
                return;
            }

            const selected =
                this.getEditorSelection();

            if (!selected) {
                return;
            }

            const text =
                '%%' +
                name +
                '%%';

            this.insertEditorText(
                text,
                selected.from,
                selected.to
            );
        },

        insertCondition(
            type,
            variable
        ) {
            type =
                String(type || '')
                    .toLowerCase();

            variable =
                String(variable || '')
                    .trim()
                    .toUpperCase();

            const selected =
                this.getEditorSelection();

            if (!selected) {
                return;
            }

            let text;
            let cursorOffset;

            switch (type) {
                case 'if': {
                    if (!variable) {
                        return;
                    }

                    const opening =
                        '<!-- @if ' +
                        variable +
                        ' -->';

                    const closing =
                        '<!-- @endif -->';

                    if (
                        selected.from !==
                        selected.to
                    ) {
                        text =
                            opening +
                            '\n' +
                            selected.text +
                            '\n' +
                            closing;

                        cursorOffset =
                            text.length;
                    } else {
                        text =
                            opening +
                            '\n\n' +
                            closing;

                        cursorOffset =
                            opening.length +
                            1;
                    }

                    break;
                }

                case 'elseif':
                    text =
                        '<!-- @elseif -->';

                    cursorOffset =
                        text.length;

                    break;

                case 'else':
                    text =
                        '<!-- @else -->';

                    cursorOffset =
                        text.length;

                    break;

                case 'endif':
                    text =
                        '<!-- @endif -->';

                    cursorOffset =
                        text.length;

                    break;

                default:
                    return;
            }

            this.insertEditorText(
                text,
                selected.from,
                selected.to,
                selected.from +
                    cursorOffset
            );
        },

        getToolVariables() {
            const variables =
                new Set(['PAGE']);

            if (
                this.analysis &&
                this.analysis.variables
            ) {
                this.analysis.variables.forEach(
                    name => {
                        variables.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
            }

            return Array.from(
                variables
            );
        },

        createToolSelect(
            options,
            className
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                    );

            if (className) {
                select.addClass(
                    className
                );
            }

            options.forEach(
                option => {
                    $('<option>')
                        .attr(
                            'value',
                            option.value
                        )
                        .text(
                            option.label
                        )
                        .appendTo(
                            select
                        );
                }
            );

            return select;
        },

        updateEditorToolVariables() {
            if (!this.editorTools) {
                return;
            }

            const variables =
                this.getToolVariables();

            const current =
                this.editorTools
                    .variableSelect
                    .val();

            const currentCondition =
                this.editorTools
                    .conditionVariableSelect
                    .val();

            const populate =
                (
                    select,
                    value
                ) => {
                    select.empty();

                    variables.forEach(
                        name => {
                            $('<option>')
                                .attr(
                                    'value',
                                    name
                                )
                                .text(name)
                                .appendTo(
                                    select
                                );
                        }
                    );

                    if (
                        value &&
                        variables.includes(
                            value
                        )
                    ) {
                        select.val(
                            value
                        );
                    } else if (
                        variables.length
                    ) {
                        select.val(
                            variables[0]
                        );
                    }
                };

            populate(
                this.editorTools
                    .variableSelect,
                current
            );

            populate(
                this.editorTools
                    .conditionVariableSelect,
                currentCondition
            );
        },

        updateConditionVariableVisibility() {
            if (!this.editorTools) {
                return;
            }

            const type =
                this.editorTools
                    .conditionTypeSelect
                    .val();

            this.editorTools
                .conditionVariableWrap
                .toggle(
                    type === 'if'
                );
        },

        renderEditorTools() {
            const tools =
                $('<div>')
                    .addClass(
                        'preload-editor-tools'
                    );

            const wrapGroup =
                $('<div>')
                    .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-wrap'
                    );

            wrapGroup.append(
                this.createButton(
                    'Wrap selection with %%',
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
            );

            const variableGroup =
                $('<div>')
                    .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-variable'
                    );

            $('<span>')
                .addClass(
                    'preload-editor-tool-label'
                )
                .text(
                    'Insert existing variable'
                )
                .appendTo(
                    variableGroup
                );

            const variableSelect =
                this.createToolSelect(
                    [],
                    'preload-editor-variable-select'
                );

            variableGroup.append(
                variableSelect,

                this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertVariable(
                            variableSelect.val()
                        )
                )
            );

            const conditionGroup =
                $('<div>')
                    .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-condition'
                    );

            $('<span>')
                .addClass(
                    'preload-editor-tool-label'
                )
                .text(
                    'Insert condition'
                )
                .appendTo(
                    conditionGroup
                );

            const conditionTypeSelect =
                this.createToolSelect(
                    [
                        {
                            value: 'if',
                            label: '@if'
                        },
                        {
                            value: 'elseif',
                            label: '@elseif'
                        },
                        {
                            value: 'else',
                            label: '@else'
                        },
                        {
                            value: 'endif',
                            label: '@endif'
                        }
                    ],
                    'preload-editor-condition-type-select'
                );

            const conditionVariableSelect =
                this.createToolSelect(
                    [],
                    'preload-editor-condition-variable-select'
                );

            const conditionVariableWrap =
                $('<span>')
                    .addClass(
                        'preload-editor-condition-variable'
                    )
                    .append(
                        conditionVariableSelect
                    );

            const insertCondition =
                this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        )
                );

            conditionTypeSelect.on(
                'change.preloadEditor',
                () =>
                    this.updateConditionVariableVisibility()
            );

            conditionGroup.append(
                conditionTypeSelect,
                conditionVariableWrap,
                insertCondition
            );

            tools.append(
                wrapGroup,
                variableGroup,
                conditionGroup
            );

            this.editorTools = {
                root: tools,
                variableSelect,
                conditionTypeSelect,
                conditionVariableSelect,
                conditionVariableWrap,
                insertCondition
            };

            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();

            return tools;
        },

        updateEditorTools() {
            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();
        },

        // Preload syntax highlighting

        getKnownVariables() {
            const known =
                new Set(['PAGE']);

            const fields =
                this.state.config &&
                this.state.config.fields;

            if (
                fields &&
                typeof fields ===
                    'object'
            ) {
                Object.keys(
                    fields
                ).forEach(
                    name => {
                        known.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
            }

            return known;
        },

        createSyntaxDecorations(
            state,
            Decoration
        ) {
            const decorations = [];

            const source =
                state.doc.toString();

            const knownVariables =
                this.getKnownVariables();

            const variablePattern =
                /%%([A-Za-z0-9_-]+)%%/g;

            let match;

            while (
                (
                    match =
                        variablePattern.exec(
                            source
                        )
                ) !== null
            ) {
                const name =
                    match[1].toUpperCase();

                const known =
                    knownVariables.has(
                        name
                    );

                decorations.push(
                    Decoration.mark({
                        class: [
                            'preload-editor-syntax-variable',
                            known
                                ? 'preload-editor-syntax-variable-known'
                                : 'preload-editor-syntax-variable-unknown'
                        ].join(' ')
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
            }

            const conditionPattern =
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;

            while (
                (
                    match =
                        conditionPattern.exec(
                            source
                        )
                ) !== null
            ) {
                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );

                const directiveStart =
                    match.index +
                    match[0].indexOf('@');

                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveStart +
                            match[1].length +
                            1
                    )
                );

                const type =
                    match[1].toLowerCase();

                if (
                    type !== 'if' &&
                    type !== 'elseif'
                ) {
                    continue;
                }

                const expression =
                    match[2];

                const expressionStart =
                    match.index +
                    match[0].indexOf(
                        expression
                    );

                const expressionPattern =
                    /[A-Za-z0-9_-]+/g;

                let expressionMatch;

                while (
                    (
                        expressionMatch =
                            expressionPattern.exec(
                                expression
                            )
                    ) !== null
                ) {
                    const name =
                        expressionMatch[0]
                            .toUpperCase();

                    if (
                        !knownVariables.has(
                            name
                        )
                    ) {
                        continue;
                    }

                    const start =
                        expressionStart +
                        expressionMatch.index;

                    decorations.push(
                        Decoration.mark({
                            class:
                                'preload-editor-syntax-condition-variable'
                        }).range(
                            start,
                            start +
                                expressionMatch[0]
                                    .length
                        )
                    );
                }
            }

            return Decoration.set(
                decorations,
                true
            );
        },

        async installSyntaxHighlighting(
            view
        ) {
            await mw.loader.using(
                'ext.CodeMirror.v6.lib'
            );

            const require =
                mw.loader.require;

            const {
                StateField,
                StateEffect,
                EditorView,
                Decoration
            } = require(
                'ext.CodeMirror.v6.lib'
            );

            const syntaxField =
                StateField.define({
                    create: state =>
                        this.createSyntaxDecorations(
                            state,
                            Decoration
                        ),

                    update: (
                        decorations,
                        transaction
                    ) =>
                        transaction.docChanged
                            ? this.createSyntaxDecorations(
                                transaction.state,
                                Decoration
                            )
                            : decorations,

                    provide: field =>
                        EditorView.decorations.from(
                            field
                        )
                });

            view.dispatch({
                effects:
                    StateEffect.appendConfig.of(
                        syntaxField
                    )
            });

            this.editor.syntaxField =
                syntaxField;
        },

        async mountCodeMirror(
            textarea
        ) {
            return new Promise(
                (
                    resolve,
                    reject
                ) => {
                    let settled = false;

                    const cleanup =
                        () => {
                            mw.hook(
                                'dev.CCM.ready'
                            ).remove(
                                onReady
                            );

                            mw.hook(
                                'dev.CCM.load'
                            ).remove(
                                onLoad
                            );
                        };

                    const onReady =
                        async cm => {
                            if (
                                settled ||
                                !cm ||
                                cm.textarea !==
                                    textarea ||
                                !cm.view
                            ) {
                                return;
                            }

                            settled = true;
                            cleanup();

                            const view =
                                cm.view;

                            const inputHandler =
                                () => {
                                    this.state
                                        .preloadContent =
                                        view.state.doc.toString();

                                    this.state.dirty =
                                        true;

                                    /*
                                     * Persist immediately.
                                     *
                                     * This is deliberately not debounced:
                                     * the purpose of the local draft is
                                     * crash recovery.
                                     */
                                    this.saveDraft();
                                };

                            view.dom.addEventListener(
                                'input',
                                inputHandler
                            );

                            view.dom.classList.add(
                                'preload-editor-codemirror'
                            );

                            const wrapper =
                                view.dom.closest(
                                    '.ext-codemirror-wrapper'
                                );

                            if (wrapper) {
                                wrapper.classList.add(
                                    'preload-editor-codemirror-wrapper'
                                );
                            }

                            this.editor = {
                                cm,
                                view,
                                textarea,
                                inputHandler
                            };

                            this.editorView =
                                view;

                            this.state
                                .preloadContent =
                                view.state.doc.toString();

                            try {
                                await this.installSyntaxHighlighting(
                                    view
                                );

                                /*
                                 * Make sure the restored/new
                                 * state is also persisted once
                                 * CodeMirror is fully mounted.
                                 */
                                if (
                                    this.state.dirty
                                ) {
                                    this.saveDraft();
                                }

                                resolve(view);
                            } catch (
                                error
                            ) {
                                reject(error);
                            }
                        };

                    const onLoad =
                        loader => {
                            if (
                                settled ||
                                typeof loader !==
                                    'function'
                            ) {
                                return;
                            }

                            try {
                                loader(
                                    textarea
                                );
                            } catch (
                                error
                            ) {
                                settled = true;
                                cleanup();
                                reject(
                                    error
                                );
                            }
                        };

                    mw.hook(
                        'dev.CCM.ready'
                    ).add(
                        onReady
                    );

                    mw.hook(
                        'dev.CCM.load'
                    ).add(
                        onLoad
                    );
                }
            );
        },

        // Main UI

        init() {
            const root =
                $('#mw-content-text');

            if (!root.length) {
                return;
            }

            this.root =
                $('<div>')
                    .attr(
                        'id',
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                    );

            root.append(
                this.root
            );

            this.renderShell();

            /*
             * Restore after the shell exists.
             *
             * This means a crash/reload can immediately
             * bring the previous working document back.
             */
            this.restoreDraft();

            /*
             * Extra safety net for normal reloads/navigation.
             *
             * Browser crashes cannot always fire beforeunload,
             * which is why we also persist on every edit.
             */
            window.addEventListener(
                'beforeunload',
                () => {
                    if (
                        this.state.dirty
                    ) {
                        this.saveDraft();
                    }
                }
            );
        },

        renderShell() {
            this.destroyEditor();
            this.root.empty();

            const loader =
                this.createSection(
                    'Preload'
                );

            const titleInput =
                this.textInput(
                    this.state.preloadTitle,
                    'Template:Example/Preload'
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );

            /*
             * Keep the title in state for new/untitled drafts
             * as well. This makes crash recovery more useful
             * before the preload has ever been saved.
             */
            titleInput.on(
                'input.preloadEditor',
                () => {
                    this.state.preloadTitle =
                        String(
                            titleInput.val() ||
                            ''
                        ).trim();

                    if (
                        this.state.dirty &&
                        this.state.mode ===
                            'new'
                    ) {
                        this.saveDraft();
                    }
                }
            );

            const loadButton =
                this.createButton(
                    'Load',
                    'preload-editor-button-primary',
                    () =>
                        this.loadExisting(
                            titleInput.val()
                        )
                );

            const newButton =
                this.createButton(
                    'New',
                    '',
                    () =>
                        this.startNew()
                );

            const sampleButton =
                this.createButton(
                    'Load Sample Page',
                    '',
                    () =>
                        this.loadSamplePage()
                );

            loader.append(
                $('<div>')
                    .addClass(
                        'preload-editor-toolbar'
                    )
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    )
            );

            this.root.append(
                loader
            );

            this.status =
                $('<div>')
                    .addClass(
                        'preload-editor-status'
                    );

            this.root.append(
                this.status
            );

            this.workspace =
                $('<div>')
                    .addClass(
                        'preload-editor-workspace'
                    );

            this.root.append(
                this.workspace
            );

            this.renderWelcome();
        },

        renderWelcome() {
            this.workspace.empty();

            const section =
                this.createSection(
                    'Get started'
                );

            $('<p>')
                .text(
                    'Load an existing preload, create a new one, or start from a sample wiki page.'
                )
                .appendTo(
                    section
                );

            const tips =
                $('<ul>')
                    .addClass(
                        'preload-editor-tips'
                    );

            [
                'Existing preload: loads the preload and its config.json if available.',
                'New preload: starts with the standard Preload Template structure.',
                'Sample page: loads normal wiki page content so you can turn it into a preload.'
            ].forEach(
                text => {
                    $('<li>')
                        .text(text)
                        .appendTo(
                            tips
                        );
                }
            );

            section.append(
                tips
            );

            this.workspace.append(
                section
            );
        },

        setStatus(
            message,
            type
        ) {
            if (!this.status) {
                return;
            }

            this.status.removeClass(
                'is-error',
                'is-success',
                'is-warning',
                'is-loading'
            );

            if (type) {
                this.status.addClass(
                    'is-' + type
                );
            }

            this.status.text(
                message || ''
            );
        },

        // Load / create

        async loadExisting(
            title
        ) {
            title =
                String(title || '')
                    .trim();

            if (!title) {
                mw.notify(
                    'Enter a preload page title first.',
                    {
                        type: 'warn'
                    }
                );

                return;
            }

            this.setStatus(
                'Loading preload...',
                'loading'
            );

            try {
                const page =
                    await this.getPage(
                        title
                    );

                if (!page) {
                    const suggestions =
                        await this.findSimilarPreloads(
                            title
                        );

                    this.renderMissingPage(
                        title,
                        suggestions
                    );

                    this.setStatus(
                        'Preload not found.',
                        'warning'
                    );

                    return;
                }

                await this.loadDefinition(
                    page.title,
                    page.content
                );

                this.setStatus(
                    'Preload loaded.',
                    'success'
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor:',
                    error
                );

                this.setStatus(
                    error.message ||
                        'Could not load preload.',
                    'error'
                );
            }
        },

        async findSimilarPreloads(
            title
        ) {
            let query =
                title
                    .replace(
                        /\/Preload$/i,
                        ''
                    )
                    .trim();

            if (!query) {
                query = title;
            }

            try {
                const results =
                    await this.searchPages(
                        query
                    );

                return results.filter(
                    result =>
                        /\/Preload$/i.test(
                            result.title
                        )
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor search:',
                    error
                );

                return [];
            }
        },

        renderMissingPage(
            title,
            suggestions
        ) {
            this.workspace.empty();

            const section =
                this.createSection(
                    'Preload not found'
                );

            $('<p>')
                .text(
                    'No preload exists at "' +
                    title +
                    '".'
                )
                .appendTo(
                    section
                );

            if (
                suggestions.length
            ) {
                $('<h3>')
                    .text(
                        'Similar preloads'
                    )
                    .appendTo(
                        section
                    );

                const list =
                    $('<div>')
                        .addClass(
                            'preload-editor-suggestions'
                        );

                suggestions.forEach(
                    result => {
                        list.append(
                            this.createButton(
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                    }
                );

                section.append(
                    list
                );
            }

            section.append(
                this.createButton(
                    'Create this preload',
                    'preload-editor-button-primary',
                    () =>
                        this.startNew(
                            title
                        )
                )
            );

            this.workspace.append(
                section
            );
        },

        async startNew(
            title
        ) {
            title =
                String(title || '')
                    .trim();

            this.state = {
                preloadTitle: title,
                preloadSource: '',
                preloadContent:
                    this.defaults.preload,

                config:
                    this.emptyConfig(),

                variables: ['PAGE'],
                conditions: [],
                selectedVariable: 'PAGE',
                mode: 'new',
                dirty: true
            };

            this.saveDraft();

            this.renderEditor();

            this.setStatus(
                'Creating a new preload.',
                'success'
            );
        },

        async loadSamplePage() {
            const result =
                await this.createPromptDialog(
                    'Load Sample Page',
                    'Enter the title of a normal wiki page to use as the starting point.'
                );

            if (!result) {
                return;
            }

            this.setStatus(
                'Loading sample page...',
                'loading'
            );

            try {
                const page =
                    await this.getPage(
                        result
                    );

                if (!page) {
                    throw new Error(
                        'Sample page not found: ' +
                        result
                    );
                }

                const sample =
                    this.convertSampleToPreload(
                        page.content
                    );

                this.state = {
                    preloadTitle: '',
                    preloadSource: '',
                    preloadContent:
                        sample,

                    config:
                        this.emptyConfig(),

                    variables: ['PAGE'],
                    conditions: [],
                    selectedVariable: 'PAGE',
                    mode: 'new',
                    dirty: true
                };

                this.reanalyze();

                this.saveDraft();

                this.renderEditor();

                this.setStatus(
                    'Sample page loaded.',
                    'success'
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor sample:',
                    error
                );

                this.setStatus(
                    error.message ||
                        'Could not load sample page.',
                    'error'
                );
            }
        },

        convertSampleToPreload(
            source
        ) {
            let content =
                String(source || '')
                    .trim();

            const include =
                content.match(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                );

            if (include) {
                content =
                    include[1].trim();
            }

            content =
                content
                    .replace(
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                        ''
                    )
                    .trim();

            return (
                '<includeonly>\n' +
                content +
                '\n</includeonly>\n' +
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
            );
        },

        createPromptDialog(
            title,
            message
        ) {
            return new Promise(
                resolve => {
                    const overlay =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );

                    const box =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt'
                            );

                    $('<h2>')
                        .text(title)
                        .appendTo(
                            box
                        );

                    $('<p>')
                        .text(message)
                        .appendTo(
                            box
                        );

                    const input =
                        this.textInput();

                    const buttons =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-buttons'
                            );

                    const cancel =
                        this.createButton(
                            'Cancel',
                            '',
                            () => {
                                overlay.remove();
                                resolve(null);
                            }
                        );

                    const confirm =
                        this.createButton(
                            'Load',
                            'preload-editor-button-primary',
                            () => {
                                const value =
                                    String(
                                        input.val() ||
                                        ''
                                    ).trim();

                                if (!value) {
                                    return;
                                }

                                overlay.remove();
                                resolve(value);
                            }
                        );

                    buttons.append(
                        cancel,
                        confirm
                    );

                    box.append(
                        input,
                        buttons
                    );

                    overlay.append(
                        box
                    );

                    $(document.body)
                        .append(
                            overlay
                        );

                    input.trigger(
                        'focus'
                    );

                    input.on(
                        'keydown.preloadEditor',
                        event => {
                            if (
                                event.key ===
                                'Enter'
                            ) {
                                confirm.trigger(
                                    'click'
                                );
                            }

                            if (
                                event.key ===
                                'Escape'
                            ) {
                                cancel.trigger(
                                    'click'
                                );
                            }
                        }
                    );
                }
            );
        },

        async loadDefinition(
            title,
            source
        ) {
            const content =
                this.extractIncludeonly(
                    source
                );

            let config =
                this.emptyConfig();

            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';

            const configPage =
                await this.getPage(
                    configTitle
                );

            if (configPage) {
                try {
                    config =
                        JSON.parse(
                            configPage.content
                        );
                } catch (
                    error
                ) {
                    throw new Error(
                        'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
            }

            const analysis =
                this.analyzePreload(
                    content
                );

            config =
                this.prepareConfigForVariables(
                    config,
                    analysis
                );

            this.state = {
                preloadTitle: title,
                preloadSource: source,
                preloadContent: source,
                config,

                variables:
                    analysis.variables,

                conditions:
                    analysis.conditions,

                selectedVariable:
                    analysis.variables.includes(
                        'PAGE'
                    )
                        ? 'PAGE'
                        : (
                            analysis.variables[0] ||
                            'PAGE'
                        ),

                mode: 'edit',
                dirty: false
            };

            /*
             * Loading the current server version means
             * that this is now the canonical working copy.
             *
             * Any old local draft for this exact preload
             * has either already been restored or was
             * explicitly declined.
             */
            this.clearDraft(title);

            this.renderEditor();
        },

        // Editor

        renderEditor() {
            this.destroyEditor();
            this.workspace.empty();

            const layout =
                $('<div>')
                    .addClass(
                        'preload-editor-layout'
                    );

            const left =
                $('<div>')
                    .addClass(
                        'preload-editor-left'
                    );

            const right =
                $('<div>')
                    .addClass(
                        'preload-editor-right'
                    );

            const editorSection =
                this.createSection(
                    'Preload',
                    'preload-editor-main-section'
                );

            const meta =
                $('<div>')
                    .addClass(
                        'preload-editor-meta'
                    );

            $('<strong>')
                .text(
                    this.state.preloadTitle ||
                    'New preload'
                )
                .appendTo(
                    meta
                );

            if (
                this.state.mode ===
                'new'
            ) {
                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                    .appendTo(
                        meta
                    );
            }

            editorSection.append(
                meta
            );

            editorSection.append(
                this.renderEditorTools()
            );

            const textarea =
                this.textarea(
                    this.state.preloadContent
                )
                    .attr(
                        'id',
                        'preload-editor-content'
                    )
                    .addClass(
                        'preload-editor-content'
                    );

            const editorMount =
                $('<div>')
                    .addClass(
                        'preload-editor-codemirror-mount'
                    )
                    .append(
                        textarea
                    );

            editorSection.append(
                editorMount
            );

            left.append(
                editorSection
            );

            /*
             * RIGHT PANEL
             *
             * Analysis
             * Variables
             * Variable configuration
             * Generated configuration
             * Actions
             */

            const analysisSection =
                this.createSection(
                    'Analysis'
                );

            const analysisContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis'
                    );

            analysisSection.append(
                analysisContainer
            );

            const variablesSection =
                this.createSection(
                    'Variables'
                );

            /*
             * Explicit variable detection button.
             */
            const detectVariablesButton =
                this.createButton(
                    'Detect variables',
                    'preload-editor-button-primary preload-editor-detect-variables',
                    () =>
                        this.detectVariables()
                );

            const detectHelp =
                $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-detect-variables-help'
                    )
                    .text(
                        'Refresh the variable list after editing the preload.'
                    );

            const detectControls =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-detect'
                    )
                    .append(
                        detectVariablesButton,
                        detectHelp
                    );

            variablesSection.append(
                detectControls
            );

            const variableContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variables'
                    );

            variablesSection.append(
                variableContainer
            );

            const configurationSection =
                this.createSection(
                    'Variable configuration'
                );

            const configurationContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-configuration'
                    );

            configurationSection.append(
                configurationContainer
            );

            const configSection =
                this.createSection(
                    'Generated configuration'
                );

            const configPreview =
                $('<pre>')
                    .addClass(
                        'preload-editor-config-preview'
                    );

            configSection.append(
                configPreview
            );

            const actions =
                $('<div>')
                    .addClass(
                        'preload-editor-actions'
                    );

            actions.append(
                this.createButton(
                    'Save Preload + Config',
                    'preload-editor-button-primary',
                    () =>
                        this.saveAll()
                ),

                this.createButton(
                    'Save Preload Only',
                    '',
                    () =>
                        this.savePreloadOnly()
                )
            );

            right.append(
                analysisSection,
                variablesSection,
                configurationSection,
                configSection,
                actions
            );

            layout.append(
                left,
                right
            );

            this.workspace.append(
                layout
            );

            this.reanalyze();

            this.renderVariables(
                variableContainer
            );

            this.renderAnalysis(
                analysisContainer
            );

            this.renderVariableConfiguration(
                configurationContainer
            );

            this.renderConfigPreview(
                configPreview
            );

            this.updateEditorTools();

            this.mountCodeMirror(
                textarea.get(0)
            ).catch(
                error => {
                    console.error(
                        'Preload Editor CodeMirror:',
                        error
                    );

                    this.setStatus(
                        error.message ||
                            'Could not initialize CodeMirror.',
                        'error'
                    );
                }
            );
        },

        /*
         * Explicitly re-read the CodeMirror document
         * and refresh every part of the right panel.
         */
        detectVariables() {
            const source =
                this.getEditorContent();

            this.state.preloadContent =
                source;

            this.state.dirty =
                true;

            this.reanalyze();

            this.renderVariablesBySelector();

            this.renderVariableConfigurationBySelector();

            this.renderAnalysisBySelector();

            this.renderConfigPreviewBySelector();

            this.updateEditorTools();

            if (
                this.editorView &&
                this.editor &&
                this.editor.syntaxField
            ) {
                try {
                    this.editorView.dispatch({
                        effects:
                            this.editorView.state
                                .field(
                                    this.editor.syntaxField
                                )
                                ? []
                                : []
                    });
                } catch (
                    error
                ) {
                    console.warn(
                        'Preload Editor syntax refresh:',
                        error
                    );
                }
            }

            /*
             * Detection itself changes/synchronizes the
             * working state, so persist the resulting
             * draft immediately.
             */
            this.saveDraft();

            this.setStatus(
                'Variables detected from the current preload.',
                'success'
            );
        },

        reanalyze() {
            const source =
                this.getEditorContent();

            const content =
                this.extractIncludeonly(
                    source
                );

            const analysis =
                this.analyzePreload(
                    content
                );

            this.state.preloadContent =
                source;

            this.state.variables =
                analysis.variables;

            this.state.conditions =
                analysis.conditions;

            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );

            this.analysis =
                analysis;

            const available =
                ['PAGE'].concat(
                    analysis.variables.filter(
                        name =>
                            name !==
                            'PAGE'
                    )
                );

            if (
                !available.includes(
                    this.state.selectedVariable
                )
            ) {
                this.state.selectedVariable =
                    available[0] ||
                    'PAGE';
            }
        },

        renderAnalysis(
            container
        ) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const variableCount =
                analysis.variables.length;

            const conditionCount =
                analysis.conditions.length;

            const configuredCount =
                Object.keys(
                    this.state.config &&
                    this.state.config.fields
                        ? this.state.config.fields
                        : {}
                ).length;

            const summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );

            const createMetric =
                (
                    value,
                    label
                ) =>
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-metric'
                        )
                        .append(
                            $('<strong>')
                                .addClass(
                                    'preload-editor-analysis-value'
                                )
                                .text(value),

                            $('<span>')
                                .addClass(
                                    'preload-editor-analysis-label'
                                )
                                .text(label)
                        );

            summary.append(
                createMetric(
                    variableCount,
                    variableCount === 1
                        ? 'variable'
                        : 'variables'
                ),

                createMetric(
                    conditionCount,
                    conditionCount === 1
                        ? 'condition marker'
                        : 'condition markers'
                ),

                createMetric(
                    configuredCount,
                    configuredCount === 1
                        ? 'configured field'
                        : 'configured fields'
                )
            );

            container.append(
                summary
            );

            if (
                analysis.conditionVariables
                    .length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis
                                .conditionVariables
                                .join(', ')
                        )
                );
            }

            const validation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );

            if (
                validation.errors.length
            ) {
                const errors =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-errors'
                        );

                validation.errors.forEach(
                    error => {
                        $('<li>')
                            .text(error)
                            .appendTo(
                                errors
                            );
                    }
                );

                container.append(
                    errors
                );
            }

            if (
                validation.warnings.length
            ) {
                const warnings =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-warnings'
                        );

                validation.warnings.forEach(
                    warning => {
                        $('<li>')
                            .text(warning)
                            .appendTo(
                                warnings
                            );
                    }
                );

                container.append(
                    warnings
                );
            }

            if (
                !validation.errors.length &&
                !validation.warnings.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                );
            }
        },

        renderAnalysisBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-analysis'
                );

            if (
                container &&
                container.length
            ) {
                this.renderAnalysis(
                    container
                );
            }
        },

        /*
         * Variable list
         */

        renderVariables(
            container
        ) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const variables =
                analysis.variables.slice();

            const ordered =
                ['PAGE'];

            variables.forEach(
                name => {
                    if (
                        name !== 'PAGE' &&
                        !ordered.includes(
                            name
                        )
                    ) {
                        ordered.push(
                            name
                        );
                    }
                }
            );

            /*
             * Preserve configured-but-unused fields
             * in the compact list.
             */
            const configuredFields =
                this.state.config &&
                this.state.config.fields
                    ? Object.keys(
                        this.state.config.fields
                    )
                    : [];

            configuredFields.forEach(
                name => {
                    const upper =
                        name.toUpperCase();

                    if (
                        upper !== 'PAGE' &&
                        !ordered.includes(
                            upper
                        )
                    ) {
                        ordered.push(
                            upper
                        );
                    }
                }
            );

            const list =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-list'
                    );

            ordered.forEach(
                name => {
                    const isSelected =
                        name ===
                        this.state
                            .selectedVariable;

                    const isUsed =
                        analysis.variables.includes(
                            name
                        );

                    const item =
                        $('<button>')
                            .attr({
                                type: 'button',
                                'data-variable':
                                    name
                            })
                            .addClass(
                                'preload-editor-variable-item'
                            )
                            .toggleClass(
                                'is-selected',
                                isSelected
                            )
                            .on(
                                'click.preloadEditor',
                                () =>
                                    this.selectVariable(
                                        name
                                    )
                            );

                    const nameLabel =
                        $('<span>')
                            .addClass(
                                'preload-editor-variable-name'
                            )
                            .text(name);

                    item.append(
                        nameLabel
                    );

                    if (
                        name ===
                        'PAGE'
                    ) {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge'
                                )
                                .text(
                                    'AUTOMATIC'
                                )
                        );
                    } else if (
                        !isUsed
                    ) {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge is-warning'
                                )
                                .text(
                                    'UNUSED'
                                )
                        );
                    }

                    list.append(
                        item
                    );
                }
            );

            container.append(
                list
            );

            if (
                !ordered.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-empty'
                        )
                        .text(
                            'No variables found in the preload.'
                        )
                );
            }
        },

        renderVariablesBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variables'
                );

            if (
                container &&
                container.length
            ) {
                this.renderVariables(
                    container
                );
            }
        },

        /*
         * Select a variable and display only
         * its configuration.
         *
         * No scrolling.
         */
        selectVariable(
            name
        ) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();

            if (!name) {
                return;
            }

            const available =
                ['PAGE'].concat(
                    (
                        this.analysis &&
                        this.analysis.variables
                            ? this.analysis
                                .variables
                            : []
                    ).filter(
                        variable =>
                            variable !==
                            'PAGE'
                    )
                );

            if (
                !available.includes(
                    name
                )
            ) {
                return;
            }

            this.state.selectedVariable =
                name;

            this.renderVariablesBySelector();

            this.renderVariableConfigurationBySelector();
        },

        renderVariableConfigurationBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variable-configuration'
                );

            if (
                container &&
                container.length
            ) {
                this.renderVariableConfiguration(
                    container
                );
            }
        },

        renderVariableConfiguration(
            container
        ) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const name =
                this.state.selectedVariable ||
                'PAGE';

            const fields =
                this.state.config &&
                this.state.config.fields
                    ? this.state.config.fields
                    : {};

            const field =
                fields[name] ||
                this.createDefaultField(
                    name,
                    analysis
                );

            if (
                name ===
                'PAGE'
            ) {
                container.append(
                    this.createAutomaticPageCard(
                        field
                    )
                );

                return;
            }

            container.append(
                this.createVariableCard(
                    name,
                    field,
                    analysis
                )
            );
        },

        /*
         * PAGE is automatic/precompiled.
         */

        createAutomaticPageCard(
            field
        ) {
            const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable preload-editor-variable-automatic'
                    )
                    .attr(
                        'data-variable',
                        'PAGE'
                    );

            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );

            header.append(
                $('<h3>')
                    .text('PAGE'),

                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text(
                        'AUTOMATIC'
                    )
            );

            card.append(
                header
            );

            $('<p>')
                .addClass(
                    'preload-editor-analysis-note'
                )
                .text(
                    'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
                )
                .appendTo(
                    card
                );

            const label =
                this.textInput(
                    field.label ||
                    'Page Name'
                ).prop(
                    'disabled',
                    true
                );

            const description =
                this.textarea(
                    field.description ||
                    'Name of the page.'
                )
                    .addClass(
                        'preload-editor-small-textarea'
                    )
                    .prop(
                        'disabled',
                        true
                    );

            const type =
                this.textInput(
                    field.type ||
                    'page'
                ).prop(
                    'disabled',
                    true
                );

            const required =
                this.checkbox(
                    field.required !==
                        false
                ).prop(
                    'disabled',
                    true
                );

            const namespace =
                this.textInput(
                    field.namespace ||
                    'Main'
                ).prop(
                    'disabled',
                    true
                );

            const allowNew =
                this.checkbox(
                    field['allow-new'] ===
                        true
                ).prop(
                    'disabled',
                    true
                );

            card.append(
                this.fieldRow(
                    'Label',
                    label
                ),

                this.fieldRow(
                    'Description',
                    description
                ),

                this.fieldRow(
                    'Type',
                    type
                ),

                this.fieldRow(
                    'Required',
                    required
                ),

                this.fieldRow(
                    'Namespace',
                    namespace
                ),

                this.fieldRow(
                    'Allow new page',
                    allowNew
                )
            );

            return card;
        },

        createVariableCard(
            name,
            field,
            analysis
        ) {
            const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );

            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );

            header.append(
                $('<h3>')
                    .text(name)
            );

            if (
                !analysis.variables.includes(
                    name
                )
            ) {
                header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text(
                            'UNUSED'
                        )
                );
            }

            card.append(
                header
            );

            const label =
                this.textInput(
                    field.label || ''
                );

            label.on(
                'input.preloadEditor',
                () => {
                    field.label =
                        label.val();

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Label',
                    label
                )
            );

            const description =
                this.textarea(
                    field.description ||
                    ''
                ).addClass(
                    'preload-editor-small-textarea'
                );

            description.on(
                'input.preloadEditor',
                () => {
                    field.description =
                        description.val();

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Description',
                    description
                )
            );

            const types = [
                [
                    'text',
                    'Text'
                ],
                [
                    'textarea',
                    'Textarea'
                ],
                [
                    'number',
                    'Number'
                ],
                [
                    'boolean',
                    'Boolean'
                ],
                [
                    'select',
                    'Select'
                ],
                [
                    'multiselect',
                    'Multi-select'
                ],
                [
                    'page',
                    'Page'
                ],
                [
                    'user',
                    'User'
                ],
                [
                    'date',
                    'Date'
                ]
            ];

            const typeSelect =
                this.select(
                    types.map(
                        (
                            [
                                value,
                                label
                            ]
                        ) => ({
                            value,
                            label
                        })
                    ),
                    field.type ||
                        'text'
                );

            typeSelect.on(
                'change.preloadEditor',
                () => {
                    field.type =
                        typeSelect.val();

                    this.markDirty();

                    this.renderVariableConfigurationBySelector();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Type',
                    typeSelect
                )
            );

            const required =
                this.checkbox(
                    field.required ===
                        true
                );

            required.on(
                'change.preloadEditor',
                () => {
                    field.required =
                        required.prop(
                            'checked'
                        );

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Required',
                    required
                )
            );

            const placeholder =
                this.textInput(
                    field.placeholder ||
                    ''
                );

            placeholder.on(
                'input.preloadEditor',
                () => {
                    const value =
                        placeholder.val();

                    if (value) {
                        field.placeholder =
                            value;
                    } else {
                        delete field.placeholder;
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Placeholder',
                    placeholder
                )
            );

            const defaultInput =
                this.textInput(
                    field.default ===
                        undefined
                        ? ''
                        : field.default
                );

            defaultInput.on(
                'input.preloadEditor',
                () => {
                    const value =
                        defaultInput.val();

                    if (value) {
                        field.default =
                            value;
                    } else {
                        delete field.default;
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Default',
                    defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            );

            if (
                field.type ===
                'textarea'
            ) {
                const maxRows =
                    this.textInput(
                        field.maxRows ||
                        ''
                    ).attr(
                        'type',
                        'number'
                    );

                maxRows.on(
                    'input.preloadEditor',
                    () => {
                        const value =
                            parseInt(
                                maxRows.val(),
                                10
                            );

                        if (
                            Number.isFinite(
                                value
                            ) &&
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }

                        this.markDirty();

                        this.renderConfigPreviewBySelector();
                    }
                );

                card.append(
                    this.fieldRow(
                        'Maximum rows',
                        maxRows
                    )
                );
            }

            if (
                field.type ===
                    'select' ||
                field.type ===
                    'multiselect'
            ) {
                this.renderSelectConfiguration(
                    card,
                    field
                );
            }

            return card;
        },

        renderSelectConfiguration(
            card,
            field
        ) {
            const source =
                this.textInput(
                    field.source || '',
                    'category:Example'
                );

            source.on(
                'input.preloadEditor',
                () => {
                    const value =
                        source.val();

                    if (value) {
                        field.source =
                            value;
                    } else {
                        delete field.source;
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Source',
                    source,
                    'Currently supported source format: category:Category Name'
                )
            );

            const sourcePrefix =
                this.textInput(
                    field['source-prefix'] ||
                    ''
                );

            sourcePrefix.on(
                'input.preloadEditor',
                () => {
                    const value =
                        sourcePrefix.val();

                    if (value) {
                        field[
                            'source-prefix'
                        ] = value;
                    } else {
                        delete field[
                            'source-prefix'
                        ];
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Source prefix',
                    sourcePrefix,
                    'Only source values beginning with this text are included.'
                )
            );

            const excludeCategory =
                this.textInput(
                    field.exclude &&
                    field.exclude.category ||
                    ''
                );

            excludeCategory.on(
                'input.preloadEditor',
                () => {
                    const value =
                        excludeCategory.val();

                    if (value) {
                        field.exclude =
                            Object.assign(
                                {},
                                field.exclude ||
                                    {},
                                {
                                    category:
                                        value
                                }
                            );
                    } else if (
                        field.exclude
                    ) {
                        delete field.exclude
                            .category;

                        if (
                            !Object.keys(
                                field.exclude
                            ).length
                        ) {
                            delete field.exclude;
                        }
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Exclude category',
                    excludeCategory
                )
            );

            card.append(
                this.createTransformControls(
                    field,
                    'value',
                    'Value transformation'
                ),

                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );

            const optionsValue =
                Array.isArray(
                    field.options
                )
                    ? field.options
                        .map(
                            option => {
                                if (
                                    typeof option ===
                                    'string'
                                ) {
                                    return option;
                                }

                                return (
                                    String(
                                        option.value ||
                                        ''
                                    ) +
                                    ' | ' +
                                    String(
                                        option.label ||
                                        option.value ||
                                        ''
                                    )
                                );
                            }
                        )
                        .join('\n')
                    : '';

            const options =
                this.textarea(
                    optionsValue
                ).addClass(
                    'preload-editor-small-textarea'
                );

            options.on(
                'input.preloadEditor',
                () => {
                    const lines =
                        options
                            .val()
                            .split('\n')
                            .map(
                                line =>
                                    line.trim()
                            )
                            .filter(
                                Boolean
                            );

                    if (!lines.length) {
                        delete field.options;
                    } else {
                        field.options =
                            lines.map(
                                line => {
                                    const parts =
                                        line.split(
                                            /\s*\|\s*/
                                        );

                                    if (
                                        parts.length ===
                                        1
                                    ) {
                                        return parts[0];
                                    }

                                    return {
                                        value:
                                            parts[0],

                                        label:
                                            parts
                                                .slice(
                                                    1
                                                )
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Manual options',
                    options,
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                )
            );
        },

        createTransformControls(
            field,
            property,
            title
        ) {
            const wrapper =
                $('<div>')
                    .addClass(
                        'preload-editor-transform'
                    );

            $('<h4>')
                .text(title)
                .appendTo(
                    wrapper
                );

            const existing =
                field[property] &&
                typeof field[property] ===
                    'object'
                    ? field[property]
                    : {};

            const transform =
                this.select(
                    [
                        {
                            value: '',
                            label:
                                'No transformation'
                        },
                        {
                            value:
                                'remove-prefix',
                            label:
                                'Remove prefix'
                        },
                        {
                            value:
                                'remove-suffix',
                            label:
                                'Remove suffix'
                        },
                        {
                            value: 'replace',
                            label:
                                'Replace'
                        }
                    ],
                    existing.transform ||
                        ''
                );

            const prefix =
                this.textInput(
                    existing.prefix ||
                    ''
                );

            const suffix =
                this.textInput(
                    existing.suffix ||
                    ''
                );

            const from =
                this.textInput(
                    existing.from ||
                    ''
                );

            const to =
                this.textInput(
                    existing.to ||
                    ''
                );

            const controls =
                $('<div>')
                    .addClass(
                        'preload-editor-transform-controls'
                    );

            const prefixRow =
                this.fieldRow(
                    'Prefix',
                    prefix
                );

            const suffixRow =
                this.fieldRow(
                    'Suffix',
                    suffix
                );

            const fromRow =
                this.fieldRow(
                    'From',
                    from
                );

            const toRow =
                this.fieldRow(
                    'To',
                    to
                );

            const renderInputs =
                () => {
                    const type =
                        transform.val();

                    prefixRow.toggle(
                        type ===
                            'remove-prefix'
                    );

                    suffixRow.toggle(
                        type ===
                            'remove-suffix'
                    );

                    fromRow.toggle(
                        type ===
                            'replace'
                    );

                    toRow.toggle(
                        type ===
                            'replace'
                    );
                };

            const update =
                () => {
                    const type =
                        transform.val();

                    let object =
                        null;

                    if (
                        type ===
                            'remove-prefix' &&
                        prefix.val()
                    ) {
                        object = {
                            transform:
                                type,
                            prefix:
                                prefix.val()
                        };
                    } else if (
                        type ===
                            'remove-suffix' &&
                        suffix.val()
                    ) {
                        object = {
                            transform:
                                type,
                            suffix:
                                suffix.val()
                        };
                    } else if (
                        type ===
                            'replace' &&
                        from.val()
                    ) {
                        object = {
                            transform:
                                type,
                            from:
                                from.val(),
                            to:
                                to.val()
                        };
                    }

                    if (object) {
                        field[property] =
                            object;
                    } else {
                        delete field[property];
                    }

                    this.markDirty();

                    this.renderConfigPreviewBySelector();
                };

            transform.on(
                'change.preloadEditor',
                () => {
                    update();
                    renderInputs();
                }
            );

            [
                prefix,
                suffix,
                from,
                to
            ].forEach(
                input => {
                    input.on(
                        'input.preloadEditor',
                        update
                    );
                }
            );

            controls.append(
                this.fieldRow(
                    'Transformation',
                    transform
                ),

                prefixRow,
                suffixRow,
                fromRow,
                toRow
            );

            renderInputs();

            wrapper.append(
                controls
            );

            return wrapper;
        },

        // Config output

        getCleanConfig() {
            const config =
                JSON.parse(
                    JSON.stringify(
                        this.state.config
                    )
                );

            const fields =
                config.fields || {};

            const orderedFields =
                {};

            /*
             * PAGE is always first and automatic.
             */
            orderedFields.PAGE =
                this.getAutomaticPageField();

            Object.keys(
                fields
            )
                .filter(
                    name =>
                        name.toUpperCase() !==
                        'PAGE'
                )
                .forEach(
                    name => {
                        orderedFields[name] =
                            fields[name];
                    }
                );

            config.fields =
                orderedFields;

            return config;
        },

        getConfigJSON() {
            return JSON.stringify(
                this.getCleanConfig(),
                null,
                4
            );
        },

        renderConfigPreview(
            container
        ) {
            if (container) {
                container.text(
                    this.getConfigJSON()
                );
            }
        },

        renderConfigPreviewBySelector() {
            const preview =
                this.root &&
                this.root.find(
                    '.preload-editor-config-preview'
                );

            if (
                preview &&
                preview.length
            ) {
                this.renderConfigPreview(
                    preview
                );
            }

            this.renderAnalysisBySelector();
        },

        markDirty() {
            this.state.dirty =
                true;

            /*
             * Any configuration change is also a
             * local draft change.
             */
            this.saveDraft();
        },

        // Save

        async savePreloadOnly() {
            let title =
                String(
                    this.state.preloadTitle ||
                    ''
                ).trim();

            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );

                if (!result) {
                    return;
                }

                title = result;

                this.state.preloadTitle =
                    title;
            }

            /*
             * Always pull the actual CodeMirror
             * document before saving.
             */
            this.state.preloadContent =
                this.getEditorContent();

            const validation =
                this.validateBeforeSave();

            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );

                /*
                 * Keep the local draft because the
                 * save did not complete.
                 */
                this.saveDraft();

                return;
            }

            this.setStatus(
                'Saving preload...',
                'loading'
            );

            try {
                const exists =
                    await this.pageExists(
                        title
                    );

                await this.savePage(
                    title,
                    this.state.preloadContent,
                    exists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !exists
                );

                this.state.mode =
                    'edit';

                this.state.dirty =
                    false;

                /*
                 * The preload has now been explicitly
                 * saved, so its local recovery copy is
                 * no longer needed.
                 */
                this.clearDraft(
                    title
                );

                this.setStatus(
                    'Preload saved successfully.',
                    'success'
                );

                mw.notify(
                    'Preload saved.',
                    {
                        type:
                            'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                /*
                 * Keep the local draft intact if
                 * the wiki save failed.
                 */
                this.saveDraft();

                this.setStatus(
                    error.message ||
                        'Could not save preload.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not save preload.',
                    {
                        type:
                            'error'
                    }
                );
            }
        },

        async saveAll() {
            let title =
                String(
                    this.state.preloadTitle ||
                    ''
                ).trim();

            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );

                if (!result) {
                    return;
                }

                title = result;

                this.state.preloadTitle =
                    title;
            }

            /*
             * Always use the actual CodeMirror
             * document when saving.
             */
            this.state.preloadContent =
                this.getEditorContent();

            /*
             * Make sure the configuration is
             * synchronized with anything newly
             * typed into the preload.
             */
            this.reanalyze();

            const validation =
                this.validateBeforeSave();

            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );

                /*
                 * Keep the recovery copy.
                 */
                this.saveDraft();

                return;
            }

            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';

            this.setStatus(
                'Saving preload and config...',
                'loading'
            );

            try {
                const preloadExists =
                    await this.pageExists(
                        title
                    );

                await this.savePage(
                    title,
                    this.state.preloadContent,
                    preloadExists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !preloadExists
                );

                const configExists =
                    await this.pageExists(
                        configTitle
                    );

                await this.savePage(
                    configTitle,
                    this.getConfigJSON(),
                    configExists
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor',
                    !configExists
                );

                this.state.mode =
                    'edit';

                this.state.dirty =
                    false;

                this.state.preloadTitle =
                    title;

                /*
                 * Both the preload and config are now
                 * safely on the wiki.
                 */
                this.clearDraft(
                    title
                );

                this.setStatus(
                    'Preload and config saved successfully.',
                    'success'
                );

                mw.notify(
                    'Preload and config saved.',
                    {
                        type:
                            'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                /*
                 * IMPORTANT:
                 * if either wiki save fails, retain the
                 * browser draft so nothing is lost.
                 */
                this.saveDraft();

                this.setStatus(
                    error.message ||
                        'Could not save preload/config.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not save preload/config.',
                    {
                        type:
                            'error'
                    }
                );
            }
        },

        validateBeforeSave() {
            const errors = [];
            const warnings = [];

            const title =
                String(
                    this.state.preloadTitle ||
                    ''
                ).trim();

            if (!title) {
                errors.push(
                    'A preload title is required.'
                );
            }

            if (
                title &&
                !/\/Preload$/i.test(
                    title
                )
            ) {
                warnings.push(
                    'The title does not end with "/Preload".'
                );
            }

            const content =
                this.getEditorContent();

            this.state.preloadContent =
                content;

            if (!content.trim()) {
                errors.push(
                    'The preload cannot be empty.'
                );
            }

            if (
                !/<includeonly>/i.test(
                    content
                )
            ) {
                warnings.push(
                    'The preload does not contain an <includeonly> section.'
                );
            }

            if (
                !/<noinclude>/i.test(
                    content
                )
            ) {
                warnings.push(
                    'The preload does not contain a <noinclude> section.'
                );
            }

            const analysis =
                this.analyzePreload(
                    this.extractIncludeonly(
                        content
                    )
                );

            /*
             * Synchronize configuration one final
             * time before saving.
             */
            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );

            const configValidation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );

            errors.push(
                ...configValidation.errors
            );

            warnings.push(
                ...configValidation.warnings
            );

            let depth = 0;

            analysis.conditions.forEach(
                condition => {
                    if (
                        condition.type ===
                        'if'
                    ) {
                        depth++;
                    } else if (
                        condition.type ===
                        'endif'
                    ) {
                        depth--;

                        if (
                            depth < 0
                        ) {
                            errors.push(
                                'Found @endif without a matching @if.'
                            );

                            depth = 0;
                        }
                    } else if (
                        condition.type ===
                            'elseif' ||
                        condition.type ===
                            'else'
                    ) {
                        if (
                            depth === 0
                        ) {
                            errors.push(
                                '@' +
                                condition.type +
                                ' exists outside an @if block.'
                            );
                        }
                    }
                }
            );

            if (depth > 0) {
                errors.push(
                    'One or more @if blocks are missing @endif.'
                );
            }

            return {
                errors,
                warnings,
                valid:
                    errors.length === 0
            };
        },

        showValidationErrors(
            validation
        ) {
            this.renderAnalysisBySelector();

            /*
             * Keep the local recovery copy because
             * validation prevented the wiki save.
             */
            this.saveDraft();

            this.setStatus(
                validation.errors.join(
                    ' '
                ),
                'error'
            );

            mw.notify(
                validation.errors.join(
                    '\n'
                ),
                {
                    type:
                        'error'
                }
            );
        }
    };

    mw.PreloadEditor =
        PreloadEditor;

    $(function () {
        if (
            mw.config.get(
                'wgPageName'
            ) !==
            'Petit_Planet:Preload_Editor'
        ) {
            return;
        }

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));