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 1: Line 1:
(function (mw, $) {
(function (mw, $) {
'use strict';
    'use strict';




const PreloadEditor = {
    const PreloadEditor = {
    version: '0.2.0',
        version: '0.2.3',


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


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


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


    // API
        // API


    async getPage(title) {
        async getPage(title) {
        const api = new mw.Api();
            const api = new mw.Api();


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


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


        if (!page || page.missing) {
            if (!page || page.missing) {
            return null;
                return null;
        }
            }
 
            const content =
                page.revisions &&
                page.revisions[0] &&
                page.revisions[0].slots &&
                page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;


        const content =
            return {
            page.revisions &&
                title: page.title || title,
            page.revisions[0] &&
                content:
            page.revisions[0].slots &&
                    typeof content === 'string'
            page.revisions[0].slots.main &&
                        ? content
             page.revisions[0].slots.main.content;
                        : ''
             };
        },


         return {
         async pageExists(title) {
             title: page.title || title,
             return !!await this.getPage(title);
            content:
         },
                typeof content === 'string'
                    ? content
                    : ''
         };
    },


    async pageExists(title) {
        async searchPages(query) {
        return !!await this.getPage(title);
            const api = new mw.Api();
    },


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


        const response = await api.get({
            return (
            action: 'query',
                response &&
            list: 'search',
                response.query &&
            srsearch: query,
                response.query.search
            srlimit: 10,
                    ? response.query.search
             srnamespace: 10,
                    : []
            formatversion: 2
             );
         });
         },


         return (
         async savePage(
             response &&
             title,
             response.query &&
             content,
             response.query.search
             summary,
                ? response.query.search
            createOnly
                : []
        ) {
        );
            const api = new mw.Api();
    },


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


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


        if (createOnly) {
            return api.postWithToken(
             params.createonly = true;
                'csrf',
         }
                params
             );
         },


         return api.postWithToken('csrf', params);
         // Preload parsing
    },


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


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


         return match
         findVariables(content) {
             ? match[1].trim()
             const variables = [];
             : String(source || '').trim();
             const pattern =
    },
                /%%([A-Za-z0-9_-]+)%%/g;


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


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


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


        return variables;
            return variables;
    },
        },


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


        while ((match = pattern.exec(content)) !== null) {
             let match;
             conditions.push({
                type: match[1].toLowerCase(),
                expression: match[2].trim(),
                index: match.index
            });
        }
 
        return conditions;
    },
 
    findConditionVariables(content) {
        const variables = [];
        const conditions = this.findConditions(content);


        conditions.forEach(condition => {
             while (
             if (
                 (match = pattern.exec(content)) !== null
                 condition.type !== 'if' &&
                condition.type !== 'elseif'
             ) {
             ) {
                 return;
                 conditions.push({
                    type:
                        match[1].toLowerCase(),
                    expression:
                        match[2].trim(),
                    index:
                        match.index
                });
             }
             }


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


             matches.forEach(name => {
        findConditionVariables(content) {
                const upper = name.toUpperCase();
             const variables = [];
            const conditions =
                this.findConditions(content);


                 if (!variables.includes(upper)) {
            conditions.forEach(condition => {
                     variables.push(upper);
                 if (
                    condition.type !== 'if' &&
                    condition.type !== 'elseif'
                ) {
                     return;
                 }
                 }
                const matches =
                    condition.expression.match(
                        /[A-Za-z0-9_-]+/g
                    ) || [];
                matches.forEach(name => {
                    const upper =
                        name.toUpperCase();
                    if (
                        !variables.includes(upper)
                    ) {
                        variables.push(upper);
                    }
                });
             });
             });
        });


        return variables;
            return variables;
    },
        },
 
        analyzePreload(content) {
            const variables =
                this.findVariables(content);
 
            const conditions =
                this.findConditions(content);


    analyzePreload(content) {
            const conditionVariables =
        const variables = this.findVariables(content);
                this.findConditionVariables(
        const conditions = this.findConditions(content);
                    content
        const conditionVariables =
                );
            this.findConditionVariables(content);


        return {
            return {
            variables,
                variables,
            conditions,
                conditions,
            conditionVariables
                conditionVariables
        };
            };
    },
        },


    // Config
        // Config


    emptyConfig() {
        emptyConfig() {
        return {
            return {
            settings: {
                settings: {
                incomplete: 'add-stub'
                    incomplete: 'add-stub'
            },
                },
            fields: {
                fields: {
                PAGE: {
                    PAGE: {
                    label: 'Page Name',
                        label: 'Page Name',
                    description: 'Name of the page.',
                        description:
                    type: 'page',
                            'Name of the page.',
                    required: true,
                        type: 'page',
                    namespace: 'Main',
                        required: true,
                    'allow-new': true
                        namespace: 'Main',
                        'allow-new': true
                    }
                 }
                 }
            };
        },
        normalizeConfig(config) {
            if (
                !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                config = this.emptyConfig();
             }
             }
        };
    },


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


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


        if (
            if (!config.fields.PAGE) {
            !config.fields ||
                config.fields.PAGE = {
            typeof config.fields !== 'object' ||
                    label: 'Page Name',
            Array.isArray(config.fields)
                    description:
        ) {
                        'Name of the page.',
            config.fields = {};
                    type: 'page',
        }
                    required: true,
                    namespace: 'Main',
                    'allow-new': true
                };
            }


        if (!config.fields.PAGE) {
             return config;
             config.fields.PAGE = {
         },
                label: 'Page Name',
                description: 'Name of the page.',
                type: 'page',
                required: true,
                namespace: 'Main',
                'allow-new': true
            };
         }


         return config;
         createDefaultField(name, analysis) {
    },
            const isConditionVariable =
                analysis.conditionVariables.includes(
                    name
                );


    createDefaultField(name, analysis) {
            return {
        const isConditionVariable =
                label:
             analysis.conditionVariables.includes(name);
                    this.humanizeName(name),
                description: '',
                type:
                    isConditionVariable
                        ? 'boolean'
                        : 'text'
             };
        },


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


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


    prepareConfigForVariables(config, analysis) {
            const existing =
        config = this.normalizeConfig(config);
                config.fields || {};


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


        fields.PAGE = existing.PAGE || {
            fields.PAGE =
            label: 'Page Name',
                existing.PAGE || {
            description: 'Name of the page.',
                    label: 'Page Name',
            type: 'page',
                    description:
            required: true,
                        'Name of the page.',
            namespace: 'Main',
                    type: 'page',
            'allow-new': true
                    required: true,
        };
                    namespace: 'Main',
                    'allow-new': true
                };


        analysis.variables.forEach(name => {
            analysis.variables.forEach(name => {
            if (name === 'PAGE') {
                if (name === 'PAGE') {
                return;
                    return;
            }
                }


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


        Object.keys(existing).forEach(name => {
            Object.keys(existing).forEach(
            const upper = name.toUpperCase();
                name => {
                    const upper =
                        name.toUpperCase();


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


        config.fields = fields;
            config.fields = fields;
        return config;
            return config;
    },
        },


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


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


        if (
            if (
            !config.fields ||
                !config.fields ||
            typeof config.fields !== 'object' ||
                typeof config.fields !== 'object' ||
            Array.isArray(config.fields)
                Array.isArray(config.fields)
        ) {
             ) {
             errors.push('"fields" must be an object.');
 
            return {
                valid: false,
                errors,
                warnings
            };
        }
 
        const configured =
            Object.keys(config.fields).map(
                name => name.toUpperCase()
            );
 
        analysis.variables.forEach(name => {
            if (!configured.includes(name)) {
                 errors.push(
                 errors.push(
                     'Variable "' +
                     '"fields" must be an object.'
                    name +
                    '" is not configured.'
                 );
                 );
                return {
                    valid: false,
                    errors,
                    warnings
                };
             }
             }
        });


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


        if (!configured.includes('PAGE')) {
             analysis.variables.forEach(
             errors.push('PAGE must always be configured.');
                name => {
        }
                    if (
 
                        !configured.includes(name)
        Object.entries(config.fields).forEach(
                    ) {
            ([name, field]) => {
                        errors.push(
                if (
                            'Variable "' +
                    !field ||
                            name +
                    typeof field !== 'object' ||
                            '" is not configured.'
                    Array.isArray(field)
                        );
                ) {
                     }
                    errors.push(
                        'Field "' +
                        name +
                        '" must be an object.'
                    );
                     return;
                }
 
                if (!field.type) {
                    warnings.push(
                        'Field "' +
                        name +
                        '" has no type.'
                    );
                 }
                 }
            );


            configured.forEach(name => {
                 if (
                 if (
                     (
                     !analysis.variables.includes(
                         field.type === 'select' ||
                         name
                        field.type === 'multiselect'
                     )
                    ) &&
                     !field.source &&
                    !Array.isArray(field.options)
                 ) {
                 ) {
                     warnings.push(
                     warnings.push(
                         'Field "' +
                         'Field "' +
                         name +
                         name +
                         '" is a ' +
                         '" is not used by the preload.'
                        field.type +
                        ' without a source or manual options.'
                     );
                     );
                 }
                 }
             }
             });
        );


        return {
            if (!configured.includes('PAGE')) {
            valid: errors.length === 0,
                errors.push(
            errors,
                    'PAGE must always be configured.'
            warnings
                );
        };
            }
    },


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


    transformationObject(
                    if (!field.type) {
        transform,
                        warnings.push(
        prefix,
                            'Field "' +
        suffix,
                            name +
        from,
                            '" has no type.'
        to
                         );
    ) {
        if (!transform) {
            return null;
        }
 
        switch (transform) {
            case 'remove-prefix':
                return prefix
                    ? {
                        transform: 'remove-prefix',
                         prefix
                     }
                     }
                    : null;


            case 'remove-suffix':
                    if (
                return suffix
                        (
                     ? {
                            field.type === 'select' ||
                         transform: 'remove-suffix',
                            field.type === 'multiselect'
                         suffix
                        ) &&
                        !field.source &&
                        !Array.isArray(
                            field.options
                        )
                     ) {
                         warnings.push(
                            'Field "' +
                            name +
                            '" is a ' +
                            field.type +
                            ' without a source or manual options.'
                         );
                     }
                     }
                    : null;
                }
            );


             case 'replace':
             return {
                return from === ''
                valid:
                    ? null
                    errors.length === 0,
                    : {
                errors,
                        transform: 'replace',
                warnings
                        from,
            };
                        to: to || ''
        },
                    };


            default:
         // Transformation helpers
                return null;
         }
    },


    // UI helpers
        transformationObject(
 
            transform,
    createSection(title, className) {
            prefix,
        const section =
            suffix,
             $('<section>')
            from,
                 .addClass('preload-editor-section');
            to
        ) {
             if (!transform) {
                 return null;
            }


        if (className) {
            switch (transform) {
            section.addClass(className);
                case 'remove-prefix':
        }
                    return prefix
                        ? {
                            transform:
                                'remove-prefix',
                            prefix
                        }
                        : null;


        $('<h2>')
                case 'remove-suffix':
            .addClass('preload-editor-section-title')
                    return suffix
            .text(title)
                        ? {
            .appendTo(section);
                            transform:
                                'remove-suffix',
                            suffix
                        }
                        : null;


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


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


         if (className) {
         // UI helpers
            button.addClass(className);
        }


         if (callback) {
         createSection(
             button.on(
            title,
                'click.preloadEditor',
            className
                callback
        ) {
            );
             const section =
        }
                $('<section>')
                    .addClass(
                        'preload-editor-section'
                    );


        return button;
            if (className) {
    },
                section.addClass(className);
            }


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


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


         if (description) {
         createButton(
             $('<div>')
            label,
                .addClass('preload-editor-help')
            className,
                .text(description)
            callback
                .appendTo(row);
        ) {
        }
             const button =
                $('<button>')
                    .attr(
                        'type',
                        'button'
                    )
                    .addClass(
                        'preload-editor-button'
                    )
                    .text(label);


        row.append(input);
            if (className) {
        return row;
                button.addClass(className);
    },
            }


    textInput(value, placeholder) {
            if (callback) {
        return $('<input>')
                button.on(
            .attr({
                    'click.preloadEditor',
                type: 'text',
                     callback
                value:
                 );
                    value === undefined ||
             }
                     value === null
                        ? ''
                        : value,
                 placeholder: placeholder || ''
            })
             .addClass('preload-editor-input');
    },


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


    checkbox(checked) {
        fieldRow(
        return $('<input>')
            label,
             .attr('type', 'checkbox')
            input,
             .prop('checked', !!checked)
             description
            .addClass('preload-editor-checkbox');
        ) {
    },
             const row =
                $('<div>')
                    .addClass(
                        'preload-editor-field'
                    );


    select(options, value) {
             $('<label>')
        const select =
                 .addClass(
             $('<select>')
                    'preload-editor-label'
                 .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);
                 .text(label)
        });
                .appendTo(row);


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


    // CodeMirror
            row.append(input);
            return row;
        },


    destroyEditor() {
        textInput(
        if (!this.editor) {
            value,
            this.editorView = null;
            placeholder
            return;
        ) {
         }
            return $('<input>')
                .attr({
                    type: 'text',
                    value:
                        value === undefined ||
                        value === null
                            ? ''
                            : value,
                    placeholder:
                        placeholder || ''
                })
                .addClass(
                    'preload-editor-input'
                );
         },


         const {
         textarea(value) {
             view,
             return $('<textarea>')
             inputHandler
                .val(
        } = this.editor;
                    value === undefined ||
                    value === null
                        ? ''
                        : value
                )
                .addClass(
                    'preload-editor-textarea'
                );
        },
 
        checkbox(checked) {
             return $('<input>')
                .attr(
                    'type',
                    'checkbox'
                )
                .prop(
                    'checked',
                    !!checked
                )
                .addClass(
                    'preload-editor-checkbox'
                );
        },


         if (view && inputHandler) {
         select(options, value) {
             view.dom.removeEventListener(
             const select =
                'input',
                $('<select>')
                inputHandler
                    .addClass(
            );
                        'preload-editor-select'
        }
                    );


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


        this.editor = null;
            return select;
         this.editorView = null;
         },
    },


    getEditorView() {
         // CodeMirror
         return this.editorView;
    },


    getEditorContent() {
        destroyEditor() {
        if (
            if (!this.editor) {
            this.editorView &&
                this.editorView = null;
            this.editorView.state
                this.editorTools = null;
        ) {
                return;
            return this.editorView.state.doc.toString();
            }
        }


        return this.state.preloadContent || '';
            const {
    },
                view,
                inputHandler
            } = this.editor;


    setEditorContent(content) {
            if (view && inputHandler) {
        content = String(content || '');
                view.dom.removeEventListener(
                    'input',
                    inputHandler
                );
            }


        if (!this.editorView) {
            if (view) {
            this.state.preloadContent = content;
                view.destroy();
             return;
             }
        }


        const current =
            this.editor = null;
             this.editorView.state.doc.toString();
             this.editorView = null;
            this.editorTools = null;
        },


         if (current === content) {
         getEditorView() {
             return;
             return this.editorView;
         }
         },


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


    // Preload syntax highlighting
            return (
                this.state.preloadContent || ''
            );
        },


    getKnownVariables() {
        setEditorContent(content) {
        const known = new Set(['PAGE']);
            content =
                String(content || '');


        const fields =
             if (!this.editorView) {
             this.state.config &&
                this.state.preloadContent =
            this.state.config.fields;
                    content;
                return;
            }


        if (fields && typeof fields === 'object') {
            const current =
            Object.keys(fields).forEach(name => {
                this.editorView.state.doc
                known.add(
                     .toString();
                     String(name).toUpperCase()
                );
            });
        }


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


    createSyntaxDecorations(state, Decoration) {
            this.editorView.dispatch({
        const decorations = [];
                changes: {
        const source = state.doc.toString();
                    from: 0,
        const knownVariables =
                    to:
             this.getKnownVariables();
                        this.editorView.state.doc
                            .length,
                    insert: content
                }
             });
        },


         const variablePattern =
         getEditorSelection() {
             /%%([A-Za-z0-9_-]+)%%/g;
             if (
                !this.editorView ||
                !this.editorView.state
            ) {
                return null;
            }


         let match;
            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();
            }
        },


         while (
         insertEditorText(
             (match = variablePattern.exec(source)) !== null
             text,
            selectionFrom,
            selectionTo,
            cursorPosition
         ) {
         ) {
             const name =
             if (!this.editorView) {
                 match[1].toUpperCase();
                 return;
            }


             const known =
             const view = this.editorView;
                 knownVariables.has(name);
            const selection =
                 view.state.selection.main;


             const classes = [
             const from =
                 'preload-editor-syntax-variable',
                 selectionFrom === undefined
                known
                     ? selection.from
                     ? 'preload-editor-syntax-variable-known'
                     : selectionFrom;
                     : 'preload-editor-syntax-variable-unknown'
            ];


             decorations.push(
             const to =
                 Decoration.mark({
                 selectionTo === undefined
                     class: classes.join(' ')
                     ? selection.to
                }).range(
                     : selectionTo;
                     match.index,
                    match.index + match[0].length
                )
            );
        }


        const conditionPattern =
            const cursor =
            /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                cursorPosition === undefined
                    ? from + text.length
                    : cursorPosition;


        while (
             view.dispatch({
             (match = conditionPattern.exec(source)) !== null
                changes: {
        ) {
                    from,
            const fullStart = match.index;
                    to,
            const fullEnd =
                    insert: text
                 match.index + match[0].length;
                },
                selection: {
                    anchor: cursor
                 }
            });


             decorations.push(
             view.focus();
                Decoration.mark({
        },
                    class:
                        'preload-editor-syntax-condition'
                }).range(
                    fullStart,
                    fullEnd
                )
            );


             const directiveStart =
        insertVariable(name) {
                 match.index +
             name =
                match[0].indexOf('@');
                 String(name || '')
                    .trim()
                    .toUpperCase();


             const directiveEnd =
            if (!name) {
                 directiveStart +
                return;
                match[1].length +
            }
                1;
 
             const selected =
                 this.getEditorSelection();


             decorations.push(
             if (!selected) {
                 Decoration.mark({
                 return;
                    class:
            }
                        'preload-editor-syntax-directive'
 
                }).range(
            const text =
                    directiveStart,
                '%%' + name + '%%';
                    directiveEnd
 
                 )
            this.insertEditorText(
                text,
                selected.from,
                 selected.to
             );
             );
        },
        wrapSelectionWithVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();
            if (!name) {
                return;
            }
            const selected =
                this.getEditorSelection();


             if (
             if (
                 match[1].toLowerCase() === 'if' ||
                 !selected ||
                 match[1].toLowerCase() === 'elseif'
                 selected.from === selected.to
             ) {
             ) {
                 const expression =
                 this.insertVariable(name);
                    match[2];
                return;
            }


                const expressionStart =
            const text =
                    match.index +
                '%%' +
                    match[0].indexOf(
                name +
                        expression
                '%%';
                    );


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


                 let expressionMatch;
            const replacement =
                 text +
                selectedText +
                text;


                while (
            this.insertEditorText(
                    (
                replacement,
                        expressionMatch =
                selected.from,
                            expressionPattern.exec(
                selected.to,
                                expression
                 selected.from +
                            )
                     replacement.length
                    ) !== null
            );
                 ) {
        },
                    const name =
                        expressionMatch[0]
                            .toUpperCase();
 
                     if (
                        !knownVariables.has(name)
                    ) {
                        continue;
                    }


                    const start =
        insertCondition(type, variable) {
                        expressionStart +
            type =
                        expressionMatch.index;
                String(type || '')
                    .toLowerCase();


                    const end =
            variable =
                        start +
                String(variable || '')
                        expressionMatch[0].length;
                    .trim()
                    .toUpperCase();


                    decorations.push(
            if (
                        Decoration.mark({
                type === 'if' ||
                            class:
                type === 'elseif'
                                'preload-editor-syntax-condition-variable'
            ) {
                        }).range(
                if (!variable) {
                            start,
                     return;
                            end
                        )
                     );
                 }
                 }
             }
             }
        }


        return Decoration.set(
            const selected =
            decorations,
                this.getEditorSelection();
            true
        );
    },


    async installSyntaxHighlighting(view) {
            if (!selected) {
        await mw.loader.using(
                return;
             'ext.CodeMirror.v6.lib'
             }
        );


        const require =
            let text = '';
             mw.loader.require;
             let cursorOffset = 0;


        const {
            if (type === 'if') {
            StateField,
                const opening =
            StateEffect,
                    '<!-- @if ' +
            EditorView,
                    variable +
            Decoration
                    ' -->';
        } = require(
            'ext.CodeMirror.v6.lib'
        );


        const syntaxField =
                const closing =
            StateField.define({
                    '<!-- @endif -->';
                create: state =>
                    this.createSyntaxDecorations(
                        state,
                        Decoration
                    ),


                 update: (decorations, transaction) => {
                 if (
                     if (
                    selected.from !==
                         !transaction.docChanged
                    selected.to
                    ) {
                ) {
                         return decorations;
                     text =
                    }
                        opening +
                        '\n' +
                         selected.text +
                        '\n' +
                         closing;


                     return this.createSyntaxDecorations(
                     cursorOffset =
                         transaction.state,
                        text.length;
                         Decoration
                } else {
                    );
                    text =
                },
                        opening +
                         '\n' +
                         '\n' +
                        closing;


                provide: field =>
                    cursorOffset =
                    EditorView.decorations.from(
                        opening.length + 1;
                        field
                }
                     )
            } else if (type === 'elseif') {
            });
                text =
                    '<!-- @elseif ' +
                     variable +
                    ' -->';


        view.dispatch({
                cursorOffset =
            effects:
                    text.length;
                 StateEffect.appendConfig.of(
            } else if (type === 'else') {
                     syntaxField
                 text =
                )
                     '<!-- @else -->';
        });


        this.editor.syntaxField =
                cursorOffset =
            syntaxField;
                    text.length;
    },
            } else if (type === 'endif') {
                text =
                    '<!-- @endif -->';


    async mountCodeMirror(textarea) {
                cursorOffset =
        return new Promise((resolve, reject) => {
                    text.length;
             let settled = false;
            } else {
                return;
             }


             const cleanup = () => {
             this.insertEditorText(
                 mw.hook('dev.CCM.ready').remove(
                 text,
                    onReady
                selected.from,
                 );
                 selected.to,
                 mw.hook('dev.CCM.load').remove(
                 selected.from +
                     onLoad
                     cursorOffset
                );
            );
            };
        },


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


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


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


                 const inputHandler = () => {
            if (
                     this.state.preloadContent =
                 fields &&
                         view.state.doc.toString();
                typeof fields === 'object'
            ) {
                Object.keys(fields)
                     .forEach(name => {
                         known.add(
                            String(name)
                                .toUpperCase()
                        );
                    });
            }


                    this.state.dirty = true;
            return known;
                    this.reanalyze();
        },


                    this.renderVariablesBySelector();
        createSyntaxDecorations(
                    this.renderAnalysisBySelector();
            state,
                    this.renderConfigPreviewBySelector();
            Decoration
                };
        ) {
            const decorations = [];
            const source =
                state.doc.toString();


                 view.dom.addEventListener(
            const knownVariables =
                    'input',
                 this.getKnownVariables();
                    inputHandler
                );


                 view.dom.classList.add(
            const variablePattern =
                    'preload-editor-codemirror'
                 /%%([A-Za-z0-9_-]+)%%/g;
                );


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


                 if (wrapper) {
            while (
                     wrapper.classList.add(
                 (
                         'preload-editor-codemirror-wrapper'
                     match =
                     );
                        variablePattern.exec(
                }
                            source
                         )
                ) !== null
            ) {
                const name =
                     match[1].toUpperCase();


                 this.editor = {
                 const known =
                     cm,
                     knownVariables.has(name);
                    view,
                    textarea,
                    inputHandler
                };


                 this.editorView = view;
                 const classes = [
                    'preload-editor-syntax-variable',
                    known
                        ? 'preload-editor-syntax-variable-known'
                        : 'preload-editor-syntax-variable-unknown'
                ];


                 this.state.preloadContent =
                 decorations.push(
                     view.state.doc.toString();
                    Decoration.mark({
                        class:
                            classes.join(' ')
                     }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
            }


                 try {
            const conditionPattern =
                    await this.installSyntaxHighlighting(
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                        view
                    );


                    resolve(view);
            while (
                 } catch (error) {
                 (
                     reject(error);
                     match =
                 }
                        conditionPattern.exec(
             };
                            source
                        )
                 ) !== null
             ) {
                const fullStart =
                    match.index;


            const onLoad = loader => {
                const fullEnd =
                if (
                     match.index +
                    settled ||
                     match[0].length;
                     typeof loader !== 'function'
                ) {
                     return;
                }


                 try {
                 decorations.push(
                     loader(textarea);
                     Decoration.mark({
                } catch (error) {
                        class:
                    settled = true;
                            'preload-editor-syntax-condition'
                    cleanup();
                    }).range(
                     reject(error);
                        fullStart,
                 }
                        fullEnd
            };
                     )
                 );


            mw.hook('dev.CCM.ready').add(onReady);
                const directiveStart =
            mw.hook('dev.CCM.load').add(onLoad);
                    match.index +
        });
                    match[0].indexOf('@');
    },


    // Main UI
                const directiveEnd =
                    directiveStart +
                    match[1].length +
                    1;


    init() {
                decorations.push(
        const root = $('#mw-content-text');
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveEnd
                    )
                );


        if (!root.length) {
                if (
            return;
                    match[1].toLowerCase() ===
        }
                        'if' ||
                    match[1].toLowerCase() ===
                        'elseif'
                ) {
                    const expression =
                        match[2];


        this.root =
                    const expressionStart =
            $('<div>')
                        match.index +
                .attr('id', 'preload-editor')
                        match[0].indexOf(
                .addClass('preload-editor');
                            expression
                        );


        root.append(this.root);
                    const expressionPattern =
        this.renderShell();
                        /[A-Za-z0-9_-]+/g;
    },


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


        const loader =
                    while (
            this.createSection('Preload');
                        (
                            expressionMatch =
                                expressionPattern
                                    .exec(
                                        expression
                                    )
                        ) !== null
                    ) {
                        const name =
                            expressionMatch[0]
                                .toUpperCase();


        const titleInput =
                        if (
            this.textInput(
                            !knownVariables.has(
                this.state.preloadTitle,
                                name
                'Template:Example/Preload'
                            )
            ).attr(
                        ) {
                'id',
                            continue;
                'preload-editor-title-input'
                        }
            );
 
                        const start =
                            expressionStart +
                            expressionMatch.index;
 
                        const end =
                            start +
                            expressionMatch[0].length;


        const loadButton =
                        decorations.push(
            this.createButton(
                            Decoration.mark({
                'Load',
                                class:
                'preload-editor-button-primary',
                                    'preload-editor-syntax-condition-variable'
                () => this.loadExisting(titleInput.val())
                            }).range(
             );
                                start,
                                end
                            )
                        );
                    }
                }
             }


        const newButton =
             return Decoration.set(
             this.createButton(
                 decorations,
                 'New',
                 true
                '',
                 () => this.startNew()
             );
             );
        },


         const sampleButton =
         async installSyntaxHighlighting(
             this.createButton(
            view
                 'Load Sample Page',
        ) {
                '',
             await mw.loader.using(
                () => this.loadSamplePage()
                 'ext.CodeMirror.v6.lib'
             );
             );


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


        loader.append(loadRow);
            const {
        this.root.append(loader);
                StateField,
                StateEffect,
                EditorView,
                Decoration
            } = require(
                'ext.CodeMirror.v6.lib'
            );


        this.status =
            const syntaxField =
            $('<div>')
                StateField.define({
                .addClass('preload-editor-status');
                    create: state =>
                        this.createSyntaxDecorations(
                            state,
                            Decoration
                        ),


        this.root.append(this.status);
                    update: (
                        decorations,
                        transaction
                    ) => {
                        if (
                            !transaction.docChanged
                        ) {
                            return decorations;
                        }


        this.workspace =
                        return this.createSyntaxDecorations(
            $('<div>')
                            transaction.state,
                .addClass('preload-editor-workspace');
                            Decoration
                        );
                    },


        this.root.append(this.workspace);
                    provide: field =>
        this.renderWelcome();
                        EditorView.decorations.from(
    },
                            field
                        )
                });


    renderWelcome() {
            view.dispatch({
        this.workspace.empty();
                effects:
                    StateEffect.appendConfig.of(
                        syntaxField
                    )
            });


        const section =
             this.editor.syntaxField =
             this.createSection('Get started');
                syntaxField;
        },


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


         const tips =
         getToolVariables() {
            $('<ul>')
            const variables = new Set([
                 .addClass('preload-editor-tips');
                 'PAGE'
            ]);


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


        section.append(tips);
            if (
        this.workspace.append(section);
                this.state.config &&
    },
                this.state.config.fields
            ) {
                Object.keys(
                    this.state.config.fields
                ).forEach(name => {
                    variables.add(
                        String(name)
                            .toUpperCase()
                    );
                });
            }
 
            return Array.from(variables);
        },


    setStatus(message, type) {
        createToolSelect(
        if (!this.status) {
            options,
            return;
            className
        }
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                    );


        this.status
             if (className) {
             .removeClass(
                 select.addClass(
                 'is-error',
                    className
                'is-success',
                 );
                 'is-warning',
             }
                'is-loading'
             );


        if (type) {
            options.forEach(option => {
            this.status.addClass('is-' + type);
                $('<option>')
        }
                    .attr(
                        'value',
                        option.value
                    )
                    .text(option.label)
                    .appendTo(select);
            });


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


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


    async loadExisting(title) {
            const variables =
        title = String(title || '').trim();
                this.getToolVariables();


        if (!title) {
             const currentVariable =
             mw.notify(
                 this.editorTools.variableSelect
                 'Enter a preload page title first.',
                    .val();
                { type: 'warn' }
            );
            return;
        }


        this.setStatus(
            const currentConditionVariable =
            'Loading preload...',
                this.editorTools.conditionVariableSelect
            'loading'
                    .val();
        );


        try {
            const populate = (
             const page = await this.getPage(title);
                select,
                current
             ) => {
                select.empty();


            if (!page) {
                variables.forEach(
                const suggestions =
                    name => {
                    await this.findSimilarPreloads(title);
                        $('<option>')
 
                            .attr(
                this.renderMissingPage(
                                'value',
                    title,
                                name
                     suggestions
                            )
                            .text(name)
                            .appendTo(
                                select
                            );
                     }
                 );
                 );


                 this.setStatus(
                 if (
                     'Preload not found.',
                    current &&
                     'warning'
                    variables.includes(
                 );
                        current
                    )
                ) {
                     select.val(
                        current
                    );
                } else if (
                     variables.length
                 ) {
                    select.val(
                        variables[0]
                    );
                }
            };


                return;
             populate(
             }
                this.editorTools
 
                    .variableSelect,
            await this.loadDefinition(
                 currentVariable
                page.title,
                 page.content
             );
             );


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


            this.setStatus(
         renderEditorTools() {
                error.message ||
            const tools =
                    'Could not load preload.',
                 $('<div>')
                'error'
                     .addClass(
            );
                         'preload-editor-tools'
         }
                    );
    },
 
    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 [];
             const variableGroup =
        }
    },
 
    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>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-suggestions'
                         'preload-editor-tool-group'
                     );
                     );


             suggestions.forEach(result => {
             $('<span>')
                 list.append(
                 .addClass(
                     this.createButton(
                     'preload-editor-tool-label'
                        result.title,
                )
                        'preload-editor-suggestion',
                .text('Variable')
                        () =>
                .appendTo(
                            this.loadExisting(
                     variableGroup
                                result.title
                            )
                     )
                 );
                 );
            });


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


        section.append(
            const insertVariable =
            this.createButton(
                this.createButton(
                'Create this preload',
                    'Insert',
                'preload-editor-button-primary',
                    'preload-editor-tool-button',
                () => this.startNew(title)
                    () => {
            )
                        this.insertVariable(
        );
                            variableSelect.val()
                        );
                    }
                );


        this.workspace.append(section);
            const wrapVariable =
    },
                this.createButton(
                    'Wrap selection',
                    'preload-editor-tool-button',
                    () => {
                        this.wrapSelectionWithVariable(
                            variableSelect.val()
                        );
                    }
                );


    async startNew(title) {
            variableGroup.append(
        title = String(title || '').trim();
                variableSelect,
                insertVariable,
                wrapVariable
            );


        this.state = {
            const conditionGroup =
            preloadTitle: title,
                $('<div>')
            preloadSource: '',
                    .addClass(
            preloadContent: this.defaults.preload,
                        'preload-editor-tool-group'
            config: this.emptyConfig(),
                    );
            variables: ['PAGE'],
            conditions: [],
            mode: 'new',
            dirty: true
        };


        this.renderEditor();
            $('<span>')
                .addClass(
                    'preload-editor-tool-label'
                )
                .text('Condition')
                .appendTo(
                    conditionGroup
                );


        this.setStatus(
            const conditionVariableSelect =
            'Creating a new preload.',
                this.createToolSelect(
            'success'
                    [],
        );
                    'preload-editor-condition-variable-select'
    },
                );
 
            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'
                );


    async loadSamplePage() {
            const insertCondition =
        const result =
                this.createButton(
             await this.createPromptDialog(
                    'Insert',
                 'Load Sample Page',
                    'preload-editor-tool-button',
                 'Enter the title of a normal wiki page to use as the starting point.'
                    () => {
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        );
                    }
                );
 
             conditionGroup.append(
                 conditionVariableSelect,
                conditionTypeSelect,
                 insertCondition
             );
             );


        if (!result) {
            tools.append(
             return;
                variableGroup,
        }
                conditionGroup
             );


        this.setStatus(
            this.editorTools = {
            'Loading sample page...',
                root: tools,
             'loading'
                variableSelect,
        );
                wrapVariable,
                conditionVariableSelect,
                conditionTypeSelect,
                insertCondition
             };


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


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


             const sample =
        updateEditorTools() {
                this.convertSampleToPreload(
             this.updateEditorToolVariables();
                    page.content
        },
                );


            this.state = {
        // CodeMirror mounting
                preloadTitle: '',
                preloadSource: '',
                preloadContent: sample,
                config: this.emptyConfig(),
                variables: ['PAGE'],
                conditions: [],
                mode: 'new',
                dirty: true
            };


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


            this.setStatus(
                    const cleanup = () => {
                'Sample page loaded.',
                        mw.hook(
                'success'
                            'dev.CCM.ready'
            );
                        ).remove(
        } catch (error) {
                            onReady
            console.error(
                        );
                'Preload Editor sample:',
                error
            );


            this.setStatus(
                        mw.hook(
                error.message ||
                            'dev.CCM.load'
                    'Could not load sample page.',
                        ).remove(
                'error'
                            onLoad
            );
                        );
        }
                    };
    },


    convertSampleToPreload(source) {
                    const onReady =
        let content =
                        async cm => {
            String(source || '').trim();
                            if (
                                settled ||
                                !cm ||
                                cm.textarea !==
                                    textarea ||
                                !cm.view
                            ) {
                                return;
                            }


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


        if (include) {
                            const view =
            content = include[1].trim();
                                cm.view;
        }
 
                            const inputHandler =
                                () => {
                                    this.state.preloadContent =
                                        view.state.doc
                                            .toString();


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


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


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


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


            $('<h2>')
                            view.dom.classList.add(
                .text(title)
                                'preload-editor-codemirror'
                .appendTo(box);
                            );


            $('<p>')
                            const wrapper =
                .text(message)
                                view.dom.closest(
                .appendTo(box);
                                    '.ext-codemirror-wrapper'
                                );


            const input = this.textInput();
                            if (wrapper) {
                                wrapper.classList.add(
                                    'preload-editor-codemirror-wrapper'
                                );
                            }


            const buttons =
                            this.editor = {
                $('<div>')
                                cm,
                    .addClass(
                                view,
                        'preload-editor-prompt-buttons'
                                textarea,
                    );
                                inputHandler
                            };


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


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


                        if (!value) {
                            try {
                            return;
                                await this.installSyntaxHighlighting(
                        }
                                    view
                                );


                        overlay.remove();
                                resolve(view);
                        resolve(value);
                            } catch (
                    }
                                error
                );
                            ) {
                                reject(error);
                            }
                        };


            buttons.append(cancel, confirm);
                    const onLoad =
            box.append(input, buttons);
                        loader => {
            overlay.append(box);
                            if (
            $(document.body).append(overlay);
                                settled ||
                                typeof loader !==
                                    'function'
                            ) {
                                return;
                            }


            input.trigger('focus');
                            try {
                                loader(textarea);
                            } catch (
                                error
                            ) {
                                settled = true;
                                cleanup();
                                reject(
                                    error
                                );
                            }
                        };


            input.on(
                    mw.hook(
                'keydown.preloadEditor',
                        'dev.CCM.ready'
                event => {
                     ).add(onReady);
                     if (event.key === 'Enter') {
                        confirm.trigger('click');
                    }


                     if (event.key === 'Escape') {
                     mw.hook(
                        cancel.trigger('click');
                        'dev.CCM.load'
                    }
                    ).add(onLoad);
                 }
                 }
             );
             );
         });
         },
    },


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


         let config = this.emptyConfig();
         init() {
            const root =
                $('#mw-content-text');


        const configTitle =
             if (!root.length) {
             title.replace(/\/+$/, '') +
                return;
             '/config.json';
             }


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


        const analysis =
            root.append(this.root);
             this.analyzePreload(content);
             this.renderShell();
        },


         config =
         renderShell() {
             this.prepareConfigForVariables(
             this.destroyEditor();
                config,
             this.root.empty();
                analysis
             );


        this.state = {
            const loader =
            preloadTitle: title,
                this.createSection(
            preloadSource: source,
                    'Preload'
            preloadContent: source,
                );
            config,
            variables: analysis.variables,
            conditions: analysis.conditions,
            mode: 'edit',
            dirty: false
        };


        this.renderEditor();
            const titleInput =
    },
                this.textInput(
 
                    this.state.preloadTitle,
    // Editor
                    'Template:Example/Preload'
 
                 ).attr(
    renderEditor() {
                    'id',
        this.destroyEditor();
                     'preload-editor-title-input'
        this.workspace.empty();
 
        const layout =
            $('<div>')
                 .addClass(
                     'preload-editor-layout'
                 );
                 );


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


        const right =
            const newButton =
            $('<div>')
                this.createButton(
                .addClass(
                    'New',
                     'preload-editor-right'
                     '',
                    () => this.startNew()
                 );
                 );


        const editorSection =
            const sampleButton =
            this.createSection(
                this.createButton(
                'Preload',
                    'Load Sample Page',
                'preload-editor-main-section'
                    '',
            );
                    () =>
 
                        this.loadSamplePage()
        const meta =
            $('<div>')
                .addClass(
                    'preload-editor-meta'
                 );
                 );


        $('<strong>')
            const loadRow =
            .text(
                $('<div>')
                this.state.preloadTitle ||
                    .addClass(
                'New preload'
                        'preload-editor-toolbar'
            )
                    )
            .appendTo(meta);
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    );


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


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


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


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


        editorSection.append(
            this.root.append(
             editorMount
                this.workspace
        );
             );


        left.append(editorSection);
            this.renderWelcome();
        },


         const analysisSection =
         renderWelcome() {
             this.createSection('Analysis');
             this.workspace.empty();


        const analysisContainer =
            const section =
            $('<div>')
                 this.createSection(
                 .addClass(
                     'Get started'
                     'preload-editor-analysis'
                 );
                 );


        analysisSection.append(
            $('<p>')
            analysisContainer
                .text(
        );
                    'Load an existing preload, create a new one, or start from a sample wiki page.'
 
                )
        const variablesSection =
                .appendTo(section);
            this.createSection('Variables');


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


        variablesSection.append(
            [
             variableContainer
                '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);
             });


        const configSection =
            section.append(tips);
             this.createSection(
             this.workspace.append(
                 'Generated configuration'
                 section
             );
             );
        },
        setStatus(
            message,
            type
        ) {
            if (!this.status) {
                return;
            }


        const configPreview =
             this.status
             $('<pre>')
                 .removeClass(
                 .addClass(
                     'is-error',
                     'preload-editor-config-preview'
                    'is-success',
                    'is-warning',
                    'is-loading'
                 );
                 );


        configSection.append(configPreview);
             if (type) {
 
                 this.status.addClass(
        const actions =
                     'is-' + type
             $('<div>')
                 .addClass(
                     'preload-editor-actions'
                 );
                 );
            }


        const save =
             this.status.text(
             this.createButton(
                 message || ''
                 'Save Preload + Config',
                'preload-editor-button-primary',
                () => this.saveAll()
             );
             );
        },


         const savePreload =
         // Load / create
            this.createButton(
                'Save Preload Only',
                '',
                () => this.savePreloadOnly()
            );


         actions.append(save, savePreload);
         async loadExisting(title) {
            title =
                String(title || '')
                    .trim();


        right.append(
            if (!title) {
            analysisSection,
                mw.notify(
            variablesSection,
                    'Enter a preload page title first.',
            configSection,
                    { type: 'warn' }
             actions
                );
        );
                return;
             }


        layout.append(left, right);
            this.setStatus(
        this.workspace.append(layout);
                'Loading preload...',
                'loading'
            );


        this.reanalyze();
            try {
        this.renderVariables(variableContainer);
                const page =
        this.renderAnalysis(analysisContainer);
                    await this.getPage(
        this.renderConfigPreview(configPreview);
                        title
                    );


        this.mountCodeMirror(textarea.get(0))
                if (!page) {
             .catch(error => {
                    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(
                 console.error(
                     'Preload Editor CodeMirror:',
                     'Preload Editor:',
                     error
                     error
                 );
                 );
Line 1,660: Line 1,937:
                 this.setStatus(
                 this.setStatus(
                     error.message ||
                     error.message ||
                         'Could not initialize CodeMirror.',
                         'Could not load preload.',
                     'error'
                     'error'
                 );
                 );
             });
             }
    },
        },


    reanalyze() {
        async findSimilarPreloads(
        const source = this.getEditorContent();
            title
        const content = this.extractIncludeonly(source);
        ) {
        const analysis = this.analyzePreload(content);
            let query =
                title
                    .replace(
                        /\/Preload$/i,
                        ''
                    )
                    .trim();


        this.state.preloadContent = source;
            if (!query) {
        this.state.variables = analysis.variables;
                query = title;
        this.state.conditions = analysis.conditions;
            }


        this.state.config =
             try {
             this.prepareConfigForVariables(
                 const results =
                 this.state.config ||
                     await this.searchPages(
                     this.emptyConfig(),
                        query
                analysis
                    );
            );


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


    renderAnalysis(container) {
                return [];
         container.empty();
            }
         },


         const analysis =
         renderMissingPage(
            this.analysis ||
             title,
            this.analyzePreload(
             suggestions
                this.extractIncludeonly(
         ) {
                    this.getEditorContent()
             this.workspace.empty();
                )
             );
 
        const variableCount =
             analysis.variables.length;
 
         const conditionCount =
             analysis.conditions.length;


        const summary =
            const section =
            $('<div>')
                 this.createSection(
                 .addClass(
                     'Preload not found'
                     'preload-editor-analysis-summary'
                 );
                 );


        summary.append(
             $('<p>')
             $('<span>').text(
                .text(
                variableCount +
                    'No preload exists at "' +
                ' variable' +
                    title +
                (variableCount === 1 ? '' : 's')
                    '".'
            ),
                 )
            $('<span>').text(
                 .appendTo(section);
                 conditionCount +
                ' condition marker' +
                 (conditionCount === 1 ? '' : 's')
            )
        );


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


        const validation =
                const list =
            this.validateConfig(
                    $('<div>')
                this.state.config,
                        .addClass(
                analysis
                            'preload-editor-suggestions'
            );
                        );


        if (validation.errors.length) {
                suggestions.forEach(
            const errors =
                    result => {
                $('<ul>')
                        list.append(
                    .addClass(
                            this.createButton(
                        'preload-editor-validation-errors'
                                result.title,
                     );
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                     }
                );


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


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


        if (validation.warnings.length) {
            this.workspace.append(
            const warnings =
                 section
                 $('<ul>')
            );
                    .addClass(
        },
                        'preload-editor-validation-warnings'
                    );


             validation.warnings.forEach(warning => {
        async startNew(title) {
                 $('<li>')
             title =
                     .text(warning)
                 String(title || '')
                    .appendTo(warnings);
                     .trim();
            });


             container.append(warnings);
             this.state = {
        }
                preloadTitle: title,
 
                preloadSource: '',
        if (
                preloadContent:
            !validation.errors.length &&
                    this.defaults.preload,
            !validation.warnings.length
                config:
        ) {
                    this.emptyConfig(),
            container.append(
                 variables: ['PAGE'],
                 $('<div>')
                conditions: [],
                    .addClass(
                mode: 'new',
                        'preload-editor-validation-success'
                dirty: true
                    )
            };
                    .text(
 
                        'Preload and configuration are in sync.'
             this.renderEditor();
                    )
             );
        }
    },


    renderAnalysisBySelector() {
             this.setStatus(
        const container =
                'Creating a new preload.',
             this.root &&
                 'success'
            this.root.find(
                 '.preload-editor-analysis'
             );
             );
        },


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


    renderVariables(container) {
            if (!result) {
        container.empty();
                return;
 
            }
        const analysis =
 
            this.analysis ||
             this.setStatus(
             this.analyzePreload(
                 'Loading sample page...',
                 this.extractIncludeonly(
                 'loading'
                    this.getEditorContent()
                 )
             );
             );


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


        const ordered = ['PAGE'];
                if (!page) {
                    throw new Error(
                        'Sample page not found: ' +
                        result
                    );
                }


        variables.forEach(name => {
                 const sample =
            if (
                    this.convertSampleToPreload(
                 name !== 'PAGE' &&
                        page.content
                !ordered.includes(name)
                    );
            ) {
                ordered.push(name);
            }
        });


        Object.keys(
                this.state = {
            this.state.config.fields || {}
                    preloadTitle: '',
        ).forEach(name => {
                    preloadSource: '',
            const upper = name.toUpperCase();
                    preloadContent:
                        sample,
                    config:
                        this.emptyConfig(),
                    variables: ['PAGE'],
                    conditions: [],
                    mode: 'new',
                    dirty: true
                };


            if (!ordered.includes(upper)) {
                this.reanalyze();
                 ordered.push(upper);
                 this.renderEditor();
            }
        });


        ordered.forEach(name => {
                this.setStatus(
             const field =
                    'Sample page loaded.',
                 this.state.config.fields[name] ||
                    'success'
                 this.createDefaultField(
                );
                     name,
             } catch (
                     analysis
                 error
            ) {
                 console.error(
                     'Preload Editor sample:',
                     error
                 );
                 );


            this.state.config.fields[name] = field;
                 this.setStatus(
 
                     error.message ||
            container.append(
                        'Could not load sample page.',
                 this.createVariableCard(
                     'error'
                     name,
                 );
                    field,
             }
                     analysis
         },
                 )
             );
         });
    },


    renderVariablesBySelector() {
         convertSampleToPreload(
         const container =
             source
            this.root &&
         ) {
            this.root.find(
             let content =
                '.preload-editor-variables'
                String(source || '')
             );
                     .trim();
 
         if (container && container.length) {
             this.renderVariables(container);
        }
    },
 
    createVariableCard(name, field, analysis) {
        const card =
            $('<div>')
                .addClass(
                     'preload-editor-variable'
                )
                .attr(
                    'data-variable',
                    name
                );


        const header =
            const include =
            $('<div>')
                 content.match(
                 .addClass(
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                     'preload-editor-variable-header'
                 );
                 );


        header.append(
             if (include) {
             $('<h3>').text(name)
                content =
        );
                    include[1].trim();
            }


        if (name === 'PAGE') {
            content =
            header.append(
                content
                $('<span>')
                    .replace(
                    .addClass(
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                         'preload-editor-badge'
                         ''
                     )
                     )
                     .text('AUTOMATIC')
                     .trim();
 
            return (
                '<includeonly>\n' +
                content +
                '\n</includeonly>\n' +
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
             );
             );
         } else if (
         },
             !analysis.variables.includes(name)
 
        createPromptDialog(
             title,
            message
         ) {
         ) {
             header.append(
             return new Promise(
                 $('<span>')
                 resolve => {
                    .addClass(
                    const overlay =
                        'preload-editor-badge is-warning'
                        $('<div>')
                    )
                            .addClass(
                    .text('UNUSED')
                                'preload-editor-prompt-overlay'
            );
                            );
        }


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


        const label =
                    $('<h2>')
            this.textInput(field.label || '');
                        .text(title)
                        .appendTo(box);


        label.on(
                    $('<p>')
            'input.preloadEditor',
                        .text(message)
            () => {
                        .appendTo(box);
                field.label = label.val();
                this.markDirty();
                this.renderConfigPreviewBySelector();
            }
        );


        card.append(
                    const input =
            this.fieldRow('Label', label)
                        this.textInput();
        );


        const description =
                    const buttons =
            this.textarea(
                        $('<div>')
                field.description || ''
                            .addClass(
            )
                                'preload-editor-prompt-buttons'
                .addClass(
                            );
                    'preload-editor-small-textarea'
                );


        description.on(
                    const cancel =
            'input.preloadEditor',
                        this.createButton(
            () => {
                            'Cancel',
                field.description = description.val();
                            '',
                this.markDirty();
                            () => {
                this.renderConfigPreviewBySelector();
                                overlay.remove();
            }
                                resolve(
        );
                                    null
                                );
                            }
                        );


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


        const types = [
                                if (!value) {
            ['text', 'Text'],
                                    return;
            ['textarea', 'Textarea'],
                                }
            ['number', 'Number'],
            ['boolean', 'Boolean'],
            ['select', 'Select'],
            ['multiselect', 'Multi-select'],
            ['page', 'Page'],
            ['user', 'User'],
            ['date', 'Date']
        ];


        const typeSelect =
                                overlay.remove();
            this.select(
                                resolve(
                types.map(
                                    value
                    ([value, label]) => ({
                                );
                        value,
                            }
                         label
                         );
                    })
                ),
                field.type || 'text'
            );


        typeSelect.on(
                    buttons.append(
            'change.preloadEditor',
                        cancel,
            () => {
                        confirm
                field.type = typeSelect.val();
                    );
                this.markDirty();


                card.replaceWith(
                     box.append(
                     this.createVariableCard(
                         input,
                         name,
                         buttons
                         field,
                    );
                        analysis
 
                     )
                    overlay.append(box);
                );
                     $(document.body).append(
                        overlay
                    );


                this.renderConfigPreviewBySelector();
                    input.trigger(
            }
                        'focus'
        );
                    );


        card.append(
                    input.on(
            this.fieldRow(
                        'keydown.preloadEditor',
                'Type',
                        event => {
                typeSelect
                            if (
            )
                                event.key ===
        );
                                'Enter'
                            ) {
                                confirm.trigger(
                                    'click'
                                );
                            }


        if (name === 'PAGE') {
                            if (
            field.type = 'page';
                                event.key ===
                                'Escape'
                            ) {
                                cancel.trigger(
                                    'click'
                                );
                            }
                        }
                    );
                }
            );
        },


             const required =
        async loadDefinition(
                 this.checkbox(
            title,
                     field.required !== false
            source
        ) {
             const content =
                 this.extractIncludeonly(
                     source
                 );
                 );


             required.on(
             let config =
                'change.preloadEditor',
                 this.emptyConfig();
                 () => {
                    field.required =
                        required.prop('checked');


                     this.markDirty();
            const configTitle =
                     this.renderConfigPreviewBySelector();
                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
                    );
                 }
                 }
             );
             }


             card.append(
             const analysis =
                 this.fieldRow(
                 this.analyzePreload(
                     'Required',
                     content
                    required
                 );
                 )
            );


             const namespace =
             config =
                 this.select(
                 this.prepareConfigForVariables(
                     [
                     config,
                        {
                     analysis
                            value: 'Main',
                 );
                            label: 'Main'
                        },
                        {
                            value: 'Template',
                            label: 'Template'
                        },
                        {
                            value: 'File',
                            label: 'File'
                        },
                        {
                            value: 'Category',
                            label: 'Category'
                        },
                        {
                            value: 'User',
                            label: 'User'
                        }
                    ],
                     field.namespace || 'Main'
                 );


             namespace.on(
             this.state = {
                 'change.preloadEditor',
                preloadTitle: title,
                 () => {
                preloadSource: source,
                     field.namespace =
                 preloadContent: source,
                        namespace.val();
                config,
                variables:
                    analysis.variables,
                 conditions:
                     analysis.conditions,
                mode: 'edit',
                dirty: false
            };


                    this.markDirty();
            this.renderEditor();
                    this.renderConfigPreviewBySelector();
        },
                }
            );


            card.append(
        // Editor
                this.fieldRow(
                    'Namespace',
                    namespace
                )
            );


             const allowNew =
        renderEditor() {
                this.checkbox(
             this.destroyEditor();
                    field['allow-new'] === true
            this.workspace.empty();
                );


             allowNew.on(
             const layout =
                 'change.preloadEditor',
                 $('<div>')
                () => {
                    .addClass(
                    field['allow-new'] =
                        'preload-editor-layout'
                        allowNew.prop('checked');
                    );


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


             card.append(
             const right =
                this.fieldRow(
                $('<div>')
                    'Allow new page',
                    .addClass(
                     allowNew
                        'preload-editor-right'
                )
                     );
            );


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


        const required =
            const meta =
            this.checkbox(
                $('<div>')
                field.required === true
                    .addClass(
            );
                        'preload-editor-meta'
                    );


        required.on(
            $('<strong>')
            'change.preloadEditor',
                 .text(
            () => {
                     this.state.preloadTitle ||
                 field.required =
                    'New preload'
                     required.prop('checked');
                )
                .appendTo(meta);


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


        card.append(
            editorSection.append(
            this.fieldRow(
                 meta
                'Required',
                 required
            )
        );
 
        const placeholder =
            this.textInput(
                field.placeholder || ''
             );
             );


        placeholder.on(
             const editorTools =
             'input.preloadEditor',
                 this.renderEditorTools();
            () => {
                 const value = placeholder.val();


                if (value) {
            editorSection.append(
                    field.placeholder = value;
                 editorTools
                 } else {
            );
                    delete field.placeholder;
                }


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


        card.append(
            const editorMount =
            this.fieldRow(
                $('<div>')
                'Placeholder',
                    .addClass(
                placeholder
                        'preload-editor-codemirror-mount'
            )
                    )
        );
                    .append(textarea);


        const defaultInput =
             editorSection.append(
             this.textInput(
                 editorMount
                 field.default === undefined
                    ? ''
                    : field.default
             );
             );


        defaultInput.on(
            left.append(
            'input.preloadEditor',
                editorSection
             () => {
             );
                const value = defaultInput.val();


                 if (value) {
            const analysisSection =
                     field.default = value;
                 this.createSection(
                 } else {
                     'Analysis'
                    delete field.default;
                 );
                }


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


        card.append(
            analysisSection.append(
            this.fieldRow(
                 analysisContainer
                '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 variablesSection =
             const maxRows =
                 this.createSection(
                 this.textInput(
                     'Variables'
                    field.maxRows || ''
                ).attr(
                    'type',
                     'number'
                 );
                 );


             maxRows.on(
             const variableContainer =
                 'input.preloadEditor',
                 $('<div>')
                () => {
                     .addClass(
                     const value =
                        'preload-editor-variables'
                        parseInt(
                    );
                            maxRows.val(),
                            10
                        );


                    if (
            variablesSection.append(
                        Number.isFinite(value) &&
                 variableContainer
                        value > 0
                    ) {
                        field.maxRows = value;
                    } else {
                        delete field.maxRows;
                    }
 
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                 }
             );
             );


             card.append(
             const configSection =
                 this.fieldRow(
                this.createSection(
                    'Maximum rows',
                    'Generated configuration'
                     maxRows
                 );
                 )
 
            const configPreview =
                $('<pre>')
                    .addClass(
                        'preload-editor-config-preview'
                     );
 
            configSection.append(
                 configPreview
             );
             );
        }


        if (
            const actions =
            field.type === 'select' ||
                $('<div>')
             field.type === 'multiselect'
                    .addClass(
        ) {
                        'preload-editor-actions'
             this.renderSelectConfiguration(
                    );
                 card,
 
                 field
             const save =
                this.createButton(
                    'Save Preload + Config',
                    'preload-editor-button-primary',
                    () => this.saveAll()
                );
 
            const savePreload =
                this.createButton(
                    'Save Preload Only',
                    '',
                    () =>
                        this.savePreloadOnly()
                );
 
             actions.append(
                 save,
                 savePreload
             );
             );
        }


        return card;
            right.append(
    },
                analysisSection,
                variablesSection,
                configSection,
                actions
            );


    renderSelectConfiguration(card, field) {
             layout.append(
        const source =
                 left,
             this.textInput(
                 right
                 field.source || '',
                 'category:Example'
             );
             );


        source.on(
            this.workspace.append(
            'input.preloadEditor',
                layout
             () => {
             );
                const value = source.val();


                if (value) {
            this.reanalyze();
                    field.source = value;
            this.renderVariables(
                 } else {
                variableContainer
                    delete field.source;
            );
                }
            this.renderAnalysis(
                 analysisContainer
            );
            this.renderConfigPreview(
                configPreview
            );
            this.updateEditorTools();


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


        card.append(
                    this.setStatus(
            this.fieldRow(
                        error.message ||
                'Source',
                            'Could not initialize CodeMirror.',
                source,
                        'error'
                 'Currently supported source format: category:Category Name'
                    );
             )
                 }
         );
             );
         },


         const sourcePrefix =
         reanalyze() {
            this.textInput(
            const source =
                field['source-prefix'] || ''
                this.getEditorContent();
            );


        sourcePrefix.on(
             const content =
             'input.preloadEditor',
                 this.extractIncludeonly(
            () => {
                     source
                 const value =
                );
                     sourcePrefix.val();


                 if (value) {
            const analysis =
                     field['source-prefix'] = value;
                 this.analyzePreload(
                 } else {
                     content
                    delete field['source-prefix'];
                 );
                }


                this.markDirty();
            this.state.preloadContent =
                 this.renderConfigPreviewBySelector();
                 source;
            }
        );


        card.append(
             this.state.variables =
             this.fieldRow(
                 analysis.variables;
                'Source prefix',
                 sourcePrefix,
                'Only source values beginning with this text are included.'
            )
        );


        const excludeCategory =
             this.state.conditions =
             this.textInput(
                 analysis.conditions;
                field.exclude &&
                 field.exclude.category
                    ? field.exclude.category
                    : ''
            );


        excludeCategory.on(
            this.state.config =
            'input.preloadEditor',
                this.prepareConfigForVariables(
            () => {
                    this.state.config ||
                 const value =
                        this.emptyConfig(),
                    excludeCategory.val();
                    analysis
                 );


                if (value) {
            this.analysis =
                    field.exclude =
                analysis;
                        Object.assign(
        },
                            {},
                            field.exclude || {},
                            { category: value }
                        );
                } else if (field.exclude) {
                    delete field.exclude.category;


                    if (
        renderAnalysis(
                        !Object.keys(
            container
                            field.exclude
        ) {
                        ).length
            container.empty();
                    ) {
                        delete field.exclude;
                    }
                }


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


        card.append(
             const variableCount =
             this.fieldRow(
                 analysis.variables.length;
                 'Exclude category',
                excludeCategory
            )
        );


        card.append(
             const conditionCount =
             this.createTransformControls(
                 analysis.conditions.length;
                 field,
                'value',
                'Value transformation'
            ),
            this.createTransformControls(
                field,
                'label',
                'Label transformation'
            )
        );


        const optionsValue =
            const summary =
            Array.isArray(field.options)
                $('<div>')
                ? field.options
                     .addClass(
                     .map(option => {
                         'preload-editor-analysis-summary'
                         if (
                    );
                            typeof option === 'string'
                        ) {
                            return option;
                        }


                        return (
            summary.append(
                            String(
                $('<span>').text(
                                option.value || ''
                    variableCount +
                            ) +
                    ' variable' +
                            ' | ' +
                    (
                            String(
                        variableCount === 1
                                option.label ||
                            ? ''
                                option.value ||
                            : 's'
                                ''
                    )
                            )
                ),
                        );
                $('<span>').text(
                     })
                     conditionCount +
                     .join('\n')
                    ' condition marker' +
                : '';
                     (
                        conditionCount === 1
                            ? ''
                            : 's'
                    )
                )
            );


        const options =
            container.append(
             this.textarea(optionsValue)
                summary
                .addClass(
            );
                    'preload-editor-small-textarea'
 
            if (
                analysis.conditionVariables
                    .length
             ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis
                                .conditionVariables
                                .join(', ')
                        )
                 );
                 );
            }


        options.on(
             const validation =
             'input.preloadEditor',
                 this.validateConfig(
            () => {
                     this.state.config,
                 const lines =
                    analysis
                     options.val()
                );
                        .split('\n')
                        .map(line => line.trim())
                        .filter(Boolean);


                if (!lines.length) {
            if (
                    delete field.options;
                validation.errors.length
                 } else {
            ) {
                     field.options =
                 const errors =
                         lines.map(line => {
                     $('<ul>')
                             const parts =
                         .addClass(
                                line.split(
                             'preload-editor-validation-errors'
                                    /\s*\|\s*/
                        );
                                );


                             if (parts.length === 1) {
                validation.errors.forEach(
                                 return parts[0];
                    error => {
                            }
                        $('<li>')
                             .text(error)
                            .appendTo(
                                 errors
                            );
                    }
                );


                            return {
                container.append(
                                value: parts[0],
                    errors
                                label:
                 );
                                    parts
                                        .slice(1)
                                        .join(' | ')
                            };
                        });
                 }
 
                this.markDirty();
                this.renderConfigPreviewBySelector();
             }
             }
        );


        card.append(
            if (
             this.fieldRow(
                validation.warnings.length
                 'Manual options',
             ) {
                options,
                 const warnings =
                'One per line. Optional label format: value | label. Leave empty when using a source.'
                    $('<ul>')
            )
                        .addClass(
        );
                            'preload-editor-validation-warnings'
    },
                        );


    createTransformControls(field, property, title) {
                validation.warnings.forEach(
        const wrapper =
                    warning => {
            $('<div>')
                        $('<li>')
                .addClass(
                            .text(warning)
                     'preload-editor-transform'
                            .appendTo(
                                warnings
                            );
                     }
                 );
                 );


        $('<h4>')
                container.append(
            .text(title)
                    warnings
             .appendTo(wrapper);
                );
             }


        const existing =
            if (
            field[property] &&
                !validation.errors.length &&
             typeof field[property] === 'object'
                !validation.warnings.length
                 ? field[property]
             ) {
                : {};
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                 );
            }
        },


         const transform =
         renderAnalysisBySelector() {
            this.select(
            const container =
                 [
                this.root &&
                     {
                 this.root.find(
                        value: '',
                     '.preload-editor-analysis'
                        label: 'No transformation'
                 );
                    },
                    {
                        value: 'remove-prefix',
                        label: 'Remove prefix'
                    },
                    {
                        value: 'remove-suffix',
                        label: 'Remove suffix'
                    },
                    {
                        value: 'replace',
                        label: 'Replace'
                    }
                ],
                 existing.transform || ''
            );


        const prefix =
            if (
             this.textInput(
                container &&
                 existing.prefix || ''
                container.length
             );
             ) {
                this.renderAnalysis(
                    container
                 );
             }
        },


         const suffix =
         renderVariables(
             this.textInput(
             container
                existing.suffix || ''
        ) {
             );
             container.empty();


        const from =
            const analysis =
            this.textInput(
                this.analysis ||
                existing.from || ''
                this.analyzePreload(
            );
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );


        const to =
            const variables =
            this.textInput(
                analysis.variables.slice();
                existing.to || ''
            );


        const controls =
            const ordered = [
            $('<div>')
                 'PAGE'
                 .addClass(
            ];
                    'preload-editor-transform-controls'
                );


        const prefixRow =
             variables.forEach(
             this.fieldRow(
                 name => {
                 'Prefix',
                    if (
                 prefix
                        name !== 'PAGE' &&
                        !ordered.includes(
                            name
                        )
                    ) {
                        ordered.push(name);
                    }
                 }
             );
             );


        const suffixRow =
            Object.keys(
             this.fieldRow(
                this.state.config.fields || {}
                 'Suffix',
             ).forEach(
                suffix
                 name => {
            );
                    const upper =
                        name.toUpperCase();


        const fromRow =
                    if (
            this.fieldRow(
                        !ordered.includes(
                'From',
                            upper
                 from
                        )
                    ) {
                        ordered.push(
                            upper
                        );
                    }
                 }
             );
             );


        const toRow =
            ordered.forEach(
            this.fieldRow(
                name => {
                'To',
                    const field =
                 to
                        this.state.config
                            .fields[name] ||
                        this.createDefaultField(
                            name,
                            analysis
                        );
 
                    this.state.config
                        .fields[name] =
                        field;
 
                    container.append(
                        this.createVariableCard(
                            name,
                            field,
                            analysis
                        )
                    );
                 }
             );
             );
        },


         const renderInputs = () => {
         renderVariablesBySelector() {
             const type = transform.val();
             const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variables'
                );


             prefixRow.toggle(
             if (
                 type === 'remove-prefix'
                 container &&
             );
                container.length
             ) {
                this.renderVariables(
                    container
                );
            }
        },


             suffixRow.toggle(
        createVariableCard(
                type === 'remove-suffix'
            name,
            );
            field,
            analysis
        ) {
             const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );


             fromRow.toggle(
             const header =
                type === 'replace'
                $('<div>')
            );
                    .addClass(
                        'preload-editor-variable-header'
                    );


             toRow.toggle(
             header.append(
                 type === 'replace'
                 $('<h3>').text(name)
             );
             );
        };


        const update = () => {
             if (name === 'PAGE') {
            const type = transform.val();
                 header.append(
            let object = null;
                    $('<span>')
 
                        .addClass(
             if (type === 'remove-prefix') {
                            'preload-editor-badge'
                 if (prefix.val()) {
                         )
                    object = {
                         .text(
                         transform: type,
                            'AUTOMATIC'
                         prefix: prefix.val()
                        )
                    };
                );
                }
             } else if (
             } else if (type === 'remove-suffix') {
                 !analysis.variables
                 if (suffix.val()) {
                    .includes(name)
                    object = {
            ) {
                        transform: type,
                header.append(
                        suffix: suffix.val()
                     $('<span>')
                     };
                        .addClass(
                }
                            'preload-editor-badge is-warning'
            } else if (type === 'replace') {
                         )
                if (from.val()) {
                         .text(
                    object = {
                            'UNUSED'
                         transform: type,
                         )
                         from: from.val(),
                );
                         to: to.val()
                    };
                }
             }
             }


             if (object) {
             card.append(header);
                field[property] = object;
            } else {
                delete field[property];
            }


             this.markDirty();
             const label =
            this.renderConfigPreviewBySelector();
                this.textInput(
        };
                    field.label || ''
                );


        transform.on(
             label.on(
             'change.preloadEditor',
            () => {
                update();
                renderInputs();
            }
        );
 
        [
            prefix,
            suffix,
            from,
            to
        ].forEach(input => {
            input.on(
                 'input.preloadEditor',
                 'input.preloadEditor',
                 update
                 () => {
            );
                    field.label =
        });
                        label.val();


        controls.append(
                    this.markDirty();
            this.fieldRow(
                'Transformation',
                transform
            ),
            prefixRow,
            suffixRow,
            fromRow,
            toRow
        );


        renderInputs();
                    this.renderConfigPreviewBySelector();
                }
            );


        wrapper.append(controls);
            card.append(
        return wrapper;
                this.fieldRow(
    },
                    'Label',
 
                     label
    // Config output
 
    getCleanConfig() {
        const config =
            JSON.parse(
                JSON.stringify(
                     this.state.config
                 )
                 )
             );
             );


        const fields = config.fields || {};
            const description =
        const orderedFields = {};
                this.textarea(
                    field.description || ''
                )
                    .addClass(
                        'preload-editor-small-textarea'
                    );


        if (fields.PAGE) {
            description.on(
            orderedFields.PAGE = fields.PAGE;
                'input.preloadEditor',
        }
                () => {
                    field.description =
                        description.val();


        Object.keys(fields)
                     this.markDirty();
            .filter(
                name =>
                     name.toUpperCase() !== 'PAGE'
            )
            .forEach(name => {
                orderedFields[name] = fields[name];
            });


        config.fields = orderedFields;
                    this.renderConfigPreviewBySelector();
        return config;
                }
    },
            );


    getConfigJSON() {
            card.append(
        return JSON.stringify(
                this.fieldRow(
            this.getCleanConfig(),
                    'Description',
            null,
                    description
             4
                )
        );
             );
    },


    renderConfigPreview(container) {
            const types = [
        if (!container) {
                ['text', 'Text'],
             return;
                ['textarea', 'Textarea'],
        }
                ['number', 'Number'],
                ['boolean', 'Boolean'],
                ['select', 'Select'],
                [
                    'multiselect',
                    'Multi-select'
                ],
                ['page', 'Page'],
                ['user', 'User'],
                ['date', 'Date']
             ];


        container.text(
            const typeSelect =
            this.getConfigJSON()
                this.select(
        );
                    types.map(
    },
                        ([value, label]) => ({
                            value,
                            label
                        })
                    ),
                    field.type ||
                        'text'
                );
 
            typeSelect.on(
                'change.preloadEditor',
                () => {
                    field.type =
                        typeSelect.val();
 
                    this.markDirty();
 
                    card.replaceWith(
                        this.createVariableCard(
                            name,
                            field,
                            analysis
                        )
                    );


    renderConfigPreviewBySelector() {
                    this.renderConfigPreviewBySelector();
        const preview =
                 }
            this.root &&
            this.root.find(
                 '.preload-editor-config-preview'
             );
             );


        if (preview && preview.length) {
            card.append(
             this.renderConfigPreview(preview);
                this.fieldRow(
        }
                    'Type',
                    typeSelect
                )
             );


        this.renderAnalysisBySelector();
            if (name === 'PAGE') {
    },
                field.type = 'page';


    markDirty() {
                const required =
        this.state.dirty = true;
                    this.checkbox(
    },
                        field.required !== false
                    );


    // Save
                required.on(
                    'change.preloadEditor',
                    () => {
                        field.required =
                            required.prop(
                                'checked'
                            );


    async savePreloadOnly() {
                        this.markDirty();
        let title =
            String(
                this.state.preloadTitle || ''
            ).trim();


        if (!title) {
                        this.renderConfigPreviewBySelector();
            const result =
                     }
                await this.createPromptDialog(
                     'Preload title',
                    'Enter the full title for the preload.'
                 );
                 );


            if (!result) {
                card.append(
                 return;
                    this.fieldRow(
            }
                        'Required',
                        required
                    )
                 );


            title = result;
                const namespace =
            this.state.preloadTitle = title;
                    this.select(
        }
                        [
                            {
                                value: 'Main',
                                label: 'Main'
                            },
                            {
                                value: 'Template',
                                label:
                                    'Template'
                            },
                            {
                                value: 'File',
                                label: 'File'
                            },
                            {
                                value:
                                    'Category',
                                label:
                                    'Category'
                            },
                            {
                                value: 'User',
                                label: 'User'
                            }
                        ],
                        field.namespace ||
                            'Main'
                    );


        this.state.preloadContent =
                namespace.on(
            this.getEditorContent();
                    'change.preloadEditor',
                    () => {
                        field.namespace =
                            namespace.val();


        const validation =
                        this.markDirty();
            this.validateBeforeSave();


        if (validation.errors.length) {
                        this.renderConfigPreviewBySelector();
            this.showValidationErrors(validation);
                    }
            return;
                );
        }


        this.setStatus(
                card.append(
            'Saving preload...',
                    this.fieldRow(
            'loading'
                        'Namespace',
        );
                        namespace
                    )
                );


        try {
                const allowNew =
            const exists =
                    this.checkbox(
                await this.pageExists(title);
                        field['allow-new'] ===
                            true
                    );


            await this.savePage(
                allowNew.on(
                title,
                    'change.preloadEditor',
                this.state.preloadContent,
                    () => {
                exists
                        field['allow-new'] =
                    ? 'Updated preload with Preload Editor'
                            allowNew.prop(
                    : 'Created preload with Preload Editor',
                                'checked'
                !exists
                            );
            );


            this.state.mode = 'edit';
                        this.markDirty();
            this.state.dirty = false;


            this.setStatus(
                        this.renderConfigPreviewBySelector();
                'Preload saved successfully.',
                    }
                 'success'
                 );
            );


            mw.notify(
                 card.append(
                'Preload saved.',
                    this.fieldRow(
                 { type: 'success' }
                        'Allow new page',
            );
                        allowNew
        } catch (error) {
                     )
            console.error(
                'Preload Editor save:',
                error
            );
 
            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 card;
                 return;
             }
             }


             title = result;
             const required =
            this.state.preloadTitle = title;
                this.checkbox(
        }
                    field.required === true
                );


        this.state.preloadContent =
            required.on(
            this.getEditorContent();
                'change.preloadEditor',
                () => {
                    field.required =
                        required.prop(
                            'checked'
                        );


        const validation =
                    this.markDirty();
            this.validateBeforeSave();


        if (validation.errors.length) {
                    this.renderConfigPreviewBySelector();
            this.showValidationErrors(validation);
                }
             return;
             );
        }


        const configTitle =
            card.append(
            title.replace(/\/+$/, '') +
                this.fieldRow(
             '/config.json';
                    'Required',
                    required
                )
             );


        this.setStatus(
            const placeholder =
            'Saving preload and config...',
                this.textInput(
            'loading'
                    field.placeholder || ''
        );
                );


        try {
            placeholder.on(
            const preloadExists =
                'input.preloadEditor',
                await this.pageExists(title);
                () => {
                    const value =
                        placeholder.val();


            await this.savePage(
                    if (value) {
                title,
                        field.placeholder =
                this.state.preloadContent,
                            value;
                preloadExists
                     } else {
                     ? 'Updated preload with Preload Editor'
                        delete field.placeholder;
                     : 'Created preload with Preload Editor',
                     }
                !preloadExists
            );


            const configExists =
                    this.markDirty();
                await this.pageExists(configTitle);


            await this.savePage(
                    this.renderConfigPreviewBySelector();
                configTitle,
                 }
                this.getConfigJSON(),
                configExists
                    ? 'Updated preload config with Preload Editor'
                    : 'Created preload config with Preload Editor',
                 !configExists
             );
             );


             this.state.mode = 'edit';
             card.append(
            this.state.dirty = false;
                this.fieldRow(
            this.state.preloadTitle = title;
                    'Placeholder',
 
                    placeholder
            this.setStatus(
                 )
                'Preload and config saved successfully.',
                 'success'
             );
             );


             mw.notify(
             const defaultInput =
                'Preload and config saved.',
                this.textInput(
                { type: 'success' }
                    field.default === undefined
            );
                        ? ''
        } catch (error) {
                        : field.default
            console.error(
                 );
                 'Preload Editor save:',
                error
            );


             this.setStatus(
             defaultInput.on(
                 error.message ||
                 'input.preloadEditor',
                    'Could not save preload/config.',
                 () => {
                 'error'
                    const value =
            );
                        defaultInput.val();


            mw.notify(
                    if (value) {
                error.message ||
                        field.default =
                     'Could not save preload/config.',
                            value;
                { type: 'error' }
                     } else {
            );
                        delete field.default;
        }
                    }
    },


    validateBeforeSave() {
                    this.markDirty();
        const errors = [];
        const warnings = [];


        const title =
                    this.renderConfigPreviewBySelector();
            String(
                 }
                this.state.preloadTitle || ''
            ).trim();
 
        if (!title) {
            errors.push(
                 'A preload title is required.'
             );
             );
        }


        if (
            card.append(
            title &&
                this.fieldRow(
            !/\/Preload$/i.test(title)
                    'Default',
        ) {
                    defaultInput,
            warnings.push(
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                'The title does not end with "/Preload".'
                )
             );
             );
        }


        const content =
            if (
            this.getEditorContent();
                field.type ===
                'textarea'
            ) {
                const maxRows =
                    this.textInput(
                        field.maxRows || ''
                    ).attr(
                        'type',
                        'number'
                    );


        this.state.preloadContent = content;
                maxRows.on(
                    'input.preloadEditor',
                    () => {
                        const value =
                            parseInt(
                                maxRows.val(),
                                10
                            );


        if (!content.trim()) {
                        if (
            errors.push(
                            Number.isFinite(
                'The preload cannot be empty.'
                                value
            );
                            ) &&
        }
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }


        if (!/<includeonly>/i.test(content)) {
                        this.markDirty();
            warnings.push(
                'The preload does not contain an <includeonly> section.'
            );
        }


        if (!/<noinclude>/i.test(content)) {
                        this.renderConfigPreviewBySelector();
            warnings.push(
                    }
                 'The preload does not contain a <noinclude> section.'
                 );
            );
        }


        const analysis =
                card.append(
            this.analyzePreload(
                    this.fieldRow(
                this.extractIncludeonly(content)
                        'Maximum rows',
            );
                        maxRows
                    )
                );
            }


        const configValidation =
            if (
             this.validateConfig(
                field.type === 'select' ||
                this.state.config,
                field.type ===
                 analysis
                    'multiselect'
             );
             ) {
                this.renderSelectConfiguration(
                    card,
                    field
                 );
             }


        errors.push(
             return card;
             ...configValidation.errors
         },
         );


         warnings.push(
         renderSelectConfiguration(
             ...configValidation.warnings
             card,
        );
            field
        ) {
            const source =
                this.textInput(
                    field.source || '',
                    'category:Example'
                );


        let depth = 0;
            source.on(
                'input.preloadEditor',
                () => {
                    const value =
                        source.val();


        analysis.conditions.forEach(condition => {
                    if (value) {
            if (condition.type === 'if') {
                        field.source =
                depth++;
                            value;
            } else if (
                    } else {
                condition.type === 'endif'
                        delete field.source;
            ) {
                    }
                depth--;


                if (depth < 0) {
                     this.markDirty();
                     errors.push(
                        'Found @endif without a matching @if.'
                    );


                     depth = 0;
                     this.renderConfigPreviewBySelector();
                 }
                 }
             } else if (
             );
                condition.type === 'elseif' ||
                condition.type === 'else'
            ) {
                if (depth === 0) {
                    errors.push(
                        '@' +
                        condition.type +
                        ' exists outside an @if block.'
                    );
                }
            }
        });


        if (depth > 0) {
            card.append(
            errors.push(
                this.fieldRow(
                'One or more @if blocks are missing @endif.'
                    'Source',
                    source,
                    'Currently supported source format: category:Category Name'
                )
             );
             );
        }


        return {
            const sourcePrefix =
            errors,
                this.textInput(
            warnings,
                    field['source-prefix'] ||
            valid: errors.length === 0
                        ''
        };
                );
    },


    showValidationErrors(validation) {
            sourcePrefix.on(
        this.renderAnalysisBySelector();
                'input.preloadEditor',
                () => {
                    const value =
                        sourcePrefix.val();


        this.setStatus(
                    if (value) {
            validation.errors.join(' '),
                        field[
            'error'
                            'source-prefix'
        );
                        ] = value;
                    } else {
                        delete field[
                            'source-prefix'
                        ];
                    }


        mw.notify(
                    this.markDirty();
            validation.errors.join('\n'),
            { type: 'error' }
        );
    }
};


mw.PreloadEditor = PreloadEditor;
                    this.renderConfigPreviewBySelector();
                }
            );


$(function () {
            card.append(
    const title =
                this.fieldRow(
        mw.config.get('wgPageName');
                    'Source prefix',
                    sourcePrefix,
                    'Only source values beginning with this text are included.'
                )
            );
 
            const excludeCategory =
                this.textInput(
                    field.exclude &&
                    field.exclude.category
                        ? 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();


    if (
                    this.renderConfigPreviewBySelector();
         title !==
                }
         'Petit_Planet:Preload_Editor'
            );
    ) {
 
        return;
            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'
                ) {
                    if (prefix.val()) {
                        object = {
                            transform: type,
                            prefix:
                                prefix.val()
                        };
                    }
                } else if (
                    type ===
                    'remove-suffix'
                ) {
                    if (suffix.val()) {
                        object = {
                            transform: type,
                            suffix:
                                suffix.val()
                        };
                    }
                } else if (
                    type ===
                    'replace'
                ) {
                    if (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 = {};
 
            if (fields.PAGE) {
                orderedFields.PAGE =
                    fields.PAGE;
            }
 
            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) {
                return;
            }
 
            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;
        },
 
        // 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;
            }
 
            this.state.preloadContent =
                this.getEditorContent();
 
            const validation =
                this.validateBeforeSave();
 
            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );
                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;
 
                this.setStatus(
                    'Preload saved successfully.',
                    'success'
                );
 
                mw.notify(
                    'Preload saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );
 
                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;
            }
 
            this.state.preloadContent =
                this.getEditorContent();
 
            const validation =
                this.validateBeforeSave();
 
            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );
                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;
 
                this.setStatus(
                    'Preload and config saved successfully.',
                    'success'
                );
 
                mw.notify(
                    'Preload and config saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );
 
                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
                    )
                );
 
            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();
 
            this.setStatus(
                validation.errors.join(
                    ' '
                ),
                'error'
            );
 
            mw.notify(
                validation.errors.join(
                    '\n'
                ),
                {
                    type: 'error'
                }
            );
        }
    };
 
    mw.PreloadEditor =
        PreloadEditor;
 
    $(function () {
        const title =
            mw.config.get(
                'wgPageName'
            );
 
        if (
            title !==
            'Petit_Planet:Preload_Editor'
        ) {
            return;
        }


    PreloadEditor.init();
        PreloadEditor.init();
});
    });


}(mediaWiki, jQuery));
}(mediaWiki, jQuery));

Revision as of 08:32, 21 September 2026

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


    const PreloadEditor = {
        version: '0.2.3',

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

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

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

        // API

        async getPage(title) {
            const api = new mw.Api();

            const response =
                await 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 api = new mw.Api();

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

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

        async savePage(
            title,
            content,
            summary,
            createOnly
        ) {
            const api = new mw.Api();

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

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

            return 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 = [];
            const conditions =
                this.findConditions(content);

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

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

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

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

            return variables;
        },

        analyzePreload(content) {
            const variables =
                this.findVariables(content);

            const conditions =
                this.findConditions(content);

            const conditionVariables =
                this.findConditionVariables(
                    content
                );

            return {
                variables,
                conditions,
                conditionVariables
            };
        },

        // Config

        emptyConfig() {
            return {
                settings: {
                    incomplete: 'add-stub'
                },
                fields: {
                    PAGE: {
                        label: 'Page Name',
                        description:
                            'Name of the page.',
                        type: 'page',
                        required: true,
                        namespace: 'Main',
                        'allow-new': true
                    }
                }
            };
        },

        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 = {};
            }

            if (!config.fields.PAGE) {
                config.fields.PAGE = {
                    label: 'Page Name',
                    description:
                        'Name of the page.',
                    type: 'page',
                    required: true,
                    namespace: 'Main',
                    'allow-new': true
                };
            }

            return config;
        },

        createDefaultField(name, analysis) {
            const isConditionVariable =
                analysis.conditionVariables.includes(
                    name
                );

            return {
                label:
                    this.humanizeName(name),
                description: '',
                type:
                    isConditionVariable
                        ? '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 = {};

            fields.PAGE =
                existing.PAGE || {
                    label: 'Page Name',
                    description:
                        'Name of the page.',
                    type: 'page',
                    required: true,
                    namespace: 'Main',
                    'allow-new': true
                };

            analysis.variables.forEach(name => {
                if (name === 'PAGE') {
                    return;
                }

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

            Object.keys(existing).forEach(
                name => {
                    const upper =
                        name.toUpperCase();

                    if (!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)
            ) {
                errors.push(
                    '"fields" must be an object.'
                );

                return {
                    valid: false,
                    errors,
                    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 === 0,
                errors,
                warnings
            };
        },

        // Transformation helpers

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

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

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

                case 'replace':
                    return from === ''
                        ? null
                        : {
                            transform:
                                'replace',
                            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 === undefined ||
                        value === null
                            ? ''
                            : value,
                    placeholder:
                        placeholder || ''
                })
                .addClass(
                    'preload-editor-input'
                );
        },

        textarea(value) {
            return $('<textarea>')
                .val(
                    value === undefined ||
                    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() {
            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();
        },

        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
            );
        },

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

            if (!name) {
                return;
            }

            const selected =
                this.getEditorSelection();

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

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

            const selectedText =
                selected.text;

            const replacement =
                text +
                selectedText +
                text;

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

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

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

            if (
                type === 'if' ||
                type === 'elseif'
            ) {
                if (!variable) {
                    return;
                }
            }

            const selected =
                this.getEditorSelection();

            if (!selected) {
                return;
            }

            let text = '';
            let cursorOffset = 0;

            if (type === 'if') {
                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;
                }
            } else if (type === 'elseif') {
                text =
                    '<!-- @elseif ' +
                    variable +
                    ' -->';

                cursorOffset =
                    text.length;
            } else if (type === 'else') {
                text =
                    '<!-- @else -->';

                cursorOffset =
                    text.length;
            } else if (type === 'endif') {
                text =
                    '<!-- @endif -->';

                cursorOffset =
                    text.length;
            } else {
                return;
            }

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

        // 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);

                const classes = [
                    'preload-editor-syntax-variable',
                    known
                        ? 'preload-editor-syntax-variable-known'
                        : 'preload-editor-syntax-variable-unknown'
                ];

                decorations.push(
                    Decoration.mark({
                        class:
                            classes.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
            ) {
                const fullStart =
                    match.index;

                const fullEnd =
                    match.index +
                    match[0].length;

                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                    }).range(
                        fullStart,
                        fullEnd
                    )
                );

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

                const directiveEnd =
                    directiveStart +
                    match[1].length +
                    1;

                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveEnd
                    )
                );

                if (
                    match[1].toLowerCase() ===
                        'if' ||
                    match[1].toLowerCase() ===
                        'elseif'
                ) {
                    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;

                        const end =
                            start +
                            expressionMatch[0].length;

                        decorations.push(
                            Decoration.mark({
                                class:
                                    'preload-editor-syntax-condition-variable'
                            }).range(
                                start,
                                end
                            )
                        );
                    }
                }
            }

            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
                    ) => {
                        if (
                            !transaction.docChanged
                        ) {
                            return decorations;
                        }

                        return this.createSyntaxDecorations(
                            transaction.state,
                            Decoration
                        );
                    },

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

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

            this.editor.syntaxField =
                syntaxField;
        },

        // Editor tools

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

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

            if (
                this.state.config &&
                this.state.config.fields
            ) {
                Object.keys(
                    this.state.config.fields
                ).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 currentVariable =
                this.editorTools.variableSelect
                    .val();

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

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

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

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

            populate(
                this.editorTools
                    .variableSelect,
                currentVariable
            );

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

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

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

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

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

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

            const wrapVariable =
                this.createButton(
                    'Wrap selection',
                    'preload-editor-tool-button',
                    () => {
                        this.wrapSelectionWithVariable(
                            variableSelect.val()
                        );
                    }
                );

            variableGroup.append(
                variableSelect,
                insertVariable,
                wrapVariable
            );

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

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

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

            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 insertCondition =
                this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () => {
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        );
                    }
                );

            conditionGroup.append(
                conditionVariableSelect,
                conditionTypeSelect,
                insertCondition
            );

            tools.append(
                variableGroup,
                conditionGroup
            );

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

            this.updateEditorToolVariables();

            return tools;
        },

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

        // CodeMirror mounting

        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;

                                    this.reanalyze();

                                    this.renderVariablesBySelector();
                                    this.renderAnalysisBySelector();
                                    this.renderConfigPreviewBySelector();
                                    this.updateEditorTools();
                                };

                            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
                                );

                                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();
        },

        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(
                    '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()
                );

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

            loader.append(loadRow);
            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: [],
                mode: 'new',
                dirty: true
            };

            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: [],
                    mode: 'new',
                    dirty: true
                };

                this.reanalyze();
                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,
                mode: 'edit',
                dirty: false
            };

            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
            );

            const editorTools =
                this.renderEditorTools();

            editorSection.append(
                editorTools
            );

            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
            );

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

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

            analysisSection.append(
                analysisContainer
            );

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

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

            variablesSection.append(
                variableContainer
            );

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

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

            configSection.append(
                configPreview
            );

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

            const save =
                this.createButton(
                    'Save Preload + Config',
                    'preload-editor-button-primary',
                    () => this.saveAll()
                );

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

            actions.append(
                save,
                savePreload
            );

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

            layout.append(
                left,
                right
            );

            this.workspace.append(
                layout
            );

            this.reanalyze();
            this.renderVariables(
                variableContainer
            );
            this.renderAnalysis(
                analysisContainer
            );
            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'
                    );
                }
            );
        },

        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;
        },

        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 summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );

            summary.append(
                $('<span>').text(
                    variableCount +
                    ' variable' +
                    (
                        variableCount === 1
                            ? ''
                            : 's'
                    )
                ),
                $('<span>').text(
                    conditionCount +
                    ' condition marker' +
                    (
                        conditionCount === 1
                            ? ''
                            : 's'
                    )
                )
            );

            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
                );
            }
        },

        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);
                    }
                }
            );

            Object.keys(
                this.state.config.fields || {}
            ).forEach(
                name => {
                    const upper =
                        name.toUpperCase();

                    if (
                        !ordered.includes(
                            upper
                        )
                    ) {
                        ordered.push(
                            upper
                        );
                    }
                }
            );

            ordered.forEach(
                name => {
                    const field =
                        this.state.config
                            .fields[name] ||
                        this.createDefaultField(
                            name,
                            analysis
                        );

                    this.state.config
                        .fields[name] =
                        field;

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

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

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

        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 (name === 'PAGE') {
                header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge'
                        )
                        .text(
                            'AUTOMATIC'
                        )
                );
            } else 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();

                    card.replaceWith(
                        this.createVariableCard(
                            name,
                            field,
                            analysis
                        )
                    );

                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow(
                    'Type',
                    typeSelect
                )
            );

            if (name === 'PAGE') {
                field.type = 'page';

                const required =
                    this.checkbox(
                        field.required !== false
                    );

                required.on(
                    'change.preloadEditor',
                    () => {
                        field.required =
                            required.prop(
                                'checked'
                            );

                        this.markDirty();

                        this.renderConfigPreviewBySelector();
                    }
                );

                card.append(
                    this.fieldRow(
                        'Required',
                        required
                    )
                );

                const namespace =
                    this.select(
                        [
                            {
                                value: 'Main',
                                label: 'Main'
                            },
                            {
                                value: 'Template',
                                label:
                                    'Template'
                            },
                            {
                                value: 'File',
                                label: 'File'
                            },
                            {
                                value:
                                    'Category',
                                label:
                                    'Category'
                            },
                            {
                                value: 'User',
                                label: 'User'
                            }
                        ],
                        field.namespace ||
                            'Main'
                    );

                namespace.on(
                    'change.preloadEditor',
                    () => {
                        field.namespace =
                            namespace.val();

                        this.markDirty();

                        this.renderConfigPreviewBySelector();
                    }
                );

                card.append(
                    this.fieldRow(
                        'Namespace',
                        namespace
                    )
                );

                const allowNew =
                    this.checkbox(
                        field['allow-new'] ===
                            true
                    );

                allowNew.on(
                    'change.preloadEditor',
                    () => {
                        field['allow-new'] =
                            allowNew.prop(
                                'checked'
                            );

                        this.markDirty();

                        this.renderConfigPreviewBySelector();
                    }
                );

                card.append(
                    this.fieldRow(
                        'Allow new page',
                        allowNew
                    )
                );

                return card;
            }

            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
                        ? 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'
                ) {
                    if (prefix.val()) {
                        object = {
                            transform: type,
                            prefix:
                                prefix.val()
                        };
                    }
                } else if (
                    type ===
                    'remove-suffix'
                ) {
                    if (suffix.val()) {
                        object = {
                            transform: type,
                            suffix:
                                suffix.val()
                        };
                    }
                } else if (
                    type ===
                    'replace'
                ) {
                    if (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 = {};

            if (fields.PAGE) {
                orderedFields.PAGE =
                    fields.PAGE;
            }

            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) {
                return;
            }

            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;
        },

        // 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;
            }

            this.state.preloadContent =
                this.getEditorContent();

            const validation =
                this.validateBeforeSave();

            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );
                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;

                this.setStatus(
                    'Preload saved successfully.',
                    'success'
                );

                mw.notify(
                    'Preload saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                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;
            }

            this.state.preloadContent =
                this.getEditorContent();

            const validation =
                this.validateBeforeSave();

            if (
                validation.errors.length
            ) {
                this.showValidationErrors(
                    validation
                );
                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;

                this.setStatus(
                    'Preload and config saved successfully.',
                    'success'
                );

                mw.notify(
                    'Preload and config saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                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
                    )
                );

            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();

            this.setStatus(
                validation.errors.join(
                    ' '
                ),
                'error'
            );

            mw.notify(
                validation.errors.join(
                    '\n'
                ),
                {
                    type: 'error'
                }
            );
        }
    };

    mw.PreloadEditor =
        PreloadEditor;

    $(function () {
        const title =
            mw.config.get(
                'wgPageName'
            );

        if (
            title !==
            'Petit_Planet:Preload_Editor'
        ) {
            return;
        }

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));