Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
If you make an edit, a temporary account will be created for you.

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

MediaWiki interface page
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 = {
        version: '0.2.0',


        state: {
const PreloadEditor = {
            preloadTitle: '',
    version: '0.2.0',
            preloadSource: '',
 
            preloadContent: '',
    state: {
            config: null,
        preloadTitle: '',
            variables: [],
        preloadSource: '',
            conditions: [],
        preloadContent: '',
            mode: 'idle',
        config: null,
            dirty: false
        variables: [],
        },
        conditions: [],
        mode: 'idle',
        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,


        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 = await api.get({
                action: 'query',
            action: 'query',
                prop: 'revisions',
            prop: 'revisions',
                titles: title,
            titles: title,
                rvprop: 'content',
            rvprop: 'content',
                rvslots: 'main',
            rvslots: 'main',
                formatversion: 2
            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 =
        const content =
                page.revisions &&
            page.revisions &&
                page.revisions[0] &&
            page.revisions[0] &&
                page.revisions[0].slots &&
            page.revisions[0].slots &&
                page.revisions[0].slots.main &&
            page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;
            page.revisions[0].slots.main.content;


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


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


        async searchPages(query) {
    async searchPages(query) {
            const api = new mw.Api();
        const api = new mw.Api();


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


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


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


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


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


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


        // Preload parsing
    // Preload parsing


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


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


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


            while ((match = pattern.exec(content)) !== null) {
        while ((match = pattern.exec(content)) !== null) {
                const name = match[1].toUpperCase();
            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;
        let match;


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


            return conditions;
        return conditions;
        },
    },


        findConditionVariables(content) {
    findConditionVariables(content) {
            const variables = [];
        const variables = [];
            const conditions = this.findConditions(content);
        const conditions = this.findConditions(content);


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


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


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


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


            return variables;
        return variables;
        },
    },


        analyzePreload(content) {
    analyzePreload(content) {
            const variables = this.findVariables(content);
        const variables = this.findVariables(content);
            const conditions = this.findConditions(content);
        const conditions = this.findConditions(content);
            const conditionVariables =
        const conditionVariables =
                this.findConditionVariables(content);
            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',
                        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',
                     label: 'Page Name',
                     description: 'Name of the page.',
                     description: 'Name of the page.',
Line 254: Line 217:
                     namespace: 'Main',
                     namespace: 'Main',
                     'allow-new': true
                     'allow-new': true
                 };
                 }
             }
             }
        };
    },


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


         createDefaultField(name, analysis) {
         if (
             const isConditionVariable =
             !config.settings ||
                analysis.conditionVariables.includes(name);
            typeof config.settings !== 'object' ||
            Array.isArray(config.settings)
        ) {
            config.settings = {};
        }


             return {
        if (
                label: this.humanizeName(name),
            !config.fields ||
                description: '',
             typeof config.fields !== 'object' ||
                type: isConditionVariable ? 'boolean' : 'text'
            Array.isArray(config.fields)
             };
        ) {
         },
             config.fields = {};
         }


         humanizeName(name) {
         if (!config.fields.PAGE) {
            return String(name || '')
             config.fields.PAGE = {
                .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',
                 label: 'Page Name',
                 description: 'Name of the page.',
                 description: 'Name of the page.',
Line 294: Line 256:
                 'allow-new': true
                 '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());
    },


            analysis.variables.forEach(name => {
    prepareConfigForVariables(config, analysis) {
                if (name === 'PAGE') {
        config = this.normalizeConfig(config);
                    return;
                }


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


            Object.keys(existing).forEach(name => {
        fields.PAGE = existing.PAGE || {
                const upper = name.toUpperCase();
            label: 'Page Name',
            description: 'Name of the page.',
            type: 'page',
            required: true,
            namespace: 'Main',
            'allow-new': true
        };


                if (!fields[upper]) {
        analysis.variables.forEach(name => {
                    fields[upper] = existing[name];
            if (name === 'PAGE') {
                }
                return;
             });
             }


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


         validateConfig(config, analysis) {
         Object.keys(existing).forEach(name => {
             const errors = [];
             const upper = name.toUpperCase();
            const warnings = [];


             if (
             if (!fields[upper]) {
                !config ||
                 fields[upper] = existing[name];
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                 return {
                    valid: false,
                    errors: ['Config must be an object.'],
                    warnings: []
                };
             }
             }
        });
        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
            };
        }


             if (
        const configured =
                !config.fields ||
             Object.keys(config.fields).map(
                 typeof config.fields !== 'object' ||
                 name => name.toUpperCase()
                Array.isArray(config.fields)
             );
             ) {
                errors.push('"fields" must be an object.');


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


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


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


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


            if (!configured.includes('PAGE')) {
                if (
                errors.push('PAGE must always be configured.');
                    (
                        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.'
                    );
                }
             }
             }
        );


             Object.entries(config.fields).forEach(
        return {
                ([name, field]) => {
             valid: errors.length === 0,
                    if (
            errors,
                        !field ||
            warnings
                        typeof field !== 'object' ||
        };
                        Array.isArray(field)
    },
                    ) {
 
                        errors.push(
    // Transformation helpers
                            'Field "' +
 
                            name +
    transformationObject(
                            '" must be an object.'
        transform,
                        );
        prefix,
                        return;
        suffix,
                    }
        from,
        to
    ) {
        if (!transform) {
            return null;
        }


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


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


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


        // Transformation helpers
             default:
 
        transformationObject(
             transform,
            prefix,
            suffix,
            from,
            to
        ) {
            if (!transform) {
                 return null;
                 return null;
            }
        }
    },


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


                case 'remove-suffix':
    createSection(title, className) {
                    return suffix
        const section =
                        ? {
            $('<section>')
                            transform: 'remove-suffix',
                .addClass('preload-editor-section');
                            suffix
                        }
                        : null;


                case 'replace':
        if (className) {
                    return from === ''
            section.addClass(className);
                        ? null
        }
                        : {
                            transform: 'replace',
                            from,
                            to: to || ''
                        };


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


         // UI helpers
         return section;
    },


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


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


            $('<h2>')
        if (callback) {
                .addClass('preload-editor-section-title')
            button.on(
                 .text(title)
                'click.preloadEditor',
                .appendTo(section);
                 callback
            );
        }


            return section;
        return button;
        },
    },


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


             if (className) {
        $('<label>')
                button.addClass(className);
             .addClass('preload-editor-label')
             }
            .text(label)
             .appendTo(row);


            if (callback) {
        if (description) {
                 button.on(
            $('<div>')
                    'click.preloadEditor',
                 .addClass('preload-editor-help')
                    callback
                .text(description)
                 );
                 .appendTo(row);
            }
        }


            return button;
        row.append(input);
        },
        return row;
    },


        fieldRow(label, input, description) {
    textInput(value, placeholder) {
            const row =
        return $('<input>')
                $('<div>')
            .attr({
                    .addClass('preload-editor-field');
                type: 'text',
 
                value:
            $('<label>')
                    value === undefined ||
                .addClass('preload-editor-label')
                    value === null
                .text(label)
                        ? ''
                .appendTo(row);
                        : value,
                placeholder: placeholder || ''
            })
            .addClass('preload-editor-input');
    },


            if (description) {
    textarea(value) {
                $('<div>')
        return $('<textarea>')
                     .addClass('preload-editor-help')
            .val(
                    .text(description)
                value === undefined ||
                    .appendTo(row);
                value === null
            }
                     ? ''
                    : value
            )
            .addClass('preload-editor-textarea');
    },


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


        textInput(value, placeholder) {
    select(options, value) {
             return $('<input>')
        const select =
                .attr({
             $('<select>')
                    type: 'text',
                 .addClass('preload-editor-select');
                    value:
                        value === undefined ||
                        value === null
                            ? ''
                            : value,
                    placeholder: placeholder || ''
                })
                 .addClass('preload-editor-input');
        },


         textarea(value) {
         options.forEach(option => {
             return $('<textarea>')
             $('<option>')
                 .val(
                 .attr('value', option.value)
                     value === undefined ||
                .text(option.label)
                    value === null
                .prop(
                        ? ''
                    'selected',
                        : value
                     String(option.value) ===
                        String(
                            value === undefined
                                ? ''
                                : value
                        )
                 )
                 )
                 .addClass('preload-editor-textarea');
                 .appendTo(select);
         },
         });


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


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


            options.forEach(option => {
    destroyEditor() {
                $('<option>')
        if (!this.editor) {
                    .attr('value', option.value)
            this.editorView = null;
                    .text(option.label)
            return;
                    .prop(
        }
                        'selected',
                        String(option.value) ===
                            String(
                                value === undefined
                                    ? ''
                                    : value
                            )
                    )
                    .appendTo(select);
            });


             return select;
        const {
         },
            view,
             inputHandler
         } = this.editor;


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


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


            const {
        this.editor = null;
                view,
        this.editorView = null;
                inputHandler
    },
            } = this.editor;


            if (view && inputHandler) {
    getEditorView() {
                view.dom.removeEventListener(
        return this.editorView;
                    'input',
    },
                    inputHandler
                );
            }


            if (view) {
    getEditorContent() {
                view.destroy();
        if (
            }
            this.editorView &&
            this.editorView.state
        ) {
            return this.editorView.state.doc.toString();
        }


            this.editor = null;
        return this.state.preloadContent || '';
            this.editorView = null;
    },
        },


        getEditorView() {
    setEditorContent(content) {
            return this.editorView;
        content = String(content || '');
        },


         getEditorContent() {
         if (!this.editorView) {
            if (
            this.state.preloadContent = content;
                this.editorView &&
             return;
                this.editorView.state
        }
            ) {
                return this.editorView.state.doc.toString();
             }


             return this.state.preloadContent || '';
        const current =
        },
             this.editorView.state.doc.toString();


         setEditorContent(content) {
         if (current === content) {
             content = String(content || '');
             return;
        }


            if (!this.editorView) {
        this.editorView.dispatch({
                 this.state.preloadContent = content;
            changes: {
                 return;
                 from: 0,
                to: this.editorView.state.doc.length,
                 insert: content
             }
             }
        });
    },
    // Preload syntax highlighting


            const current =
    getKnownVariables() {
                this.editorView.state.doc.toString();
        const known = new Set(['PAGE']);


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


             this.editorView.dispatch({
        if (fields && typeof fields === 'object') {
                 changes: {
             Object.keys(fields).forEach(name => {
                     from: 0,
                 known.add(
                    to: this.editorView.state.doc.length,
                     String(name).toUpperCase()
                    insert: content
                 );
                 }
             });
             });
         },
         }


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


                const cleanup = () => {
    createSyntaxDecorations(state, Decoration) {
                    mw.hook('dev.CCM.ready').remove(
        const decorations = [];
                        onReady
        const source = state.doc.toString();
                     );
        const knownVariables =
                     mw.hook('dev.CCM.load').remove(
            this.getKnownVariables();
                        onLoad
 
                     );
        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;


                const onReady = cm => {
        while (
                    if (
            (match = conditionPattern.exec(source)) !== null
                        settled ||
        ) {
                        !cm ||
            const fullStart = match.index;
                        cm.textarea !== textarea ||
            const fullEnd =
                        !cm.view
                match.index + match[0].length;
                    ) {
                        return;
                    }


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


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


                    const inputHandler = () => {
            const directiveEnd =
                        this.state.preloadContent =
                directiveStart +
                            view.state.doc.toString();
                match[1].length +
                1;


                        this.state.dirty = true;
            decorations.push(
                         this.reanalyze();
                Decoration.mark({
                    class:
                         'preload-editor-syntax-directive'
                }).range(
                    directiveStart,
                    directiveEnd
                )
            );


                        this.renderVariablesBySelector();
            if (
                        this.renderAnalysisBySelector();
                match[1].toLowerCase() === 'if' ||
                        this.renderConfigPreviewBySelector();
                match[1].toLowerCase() === 'elseif'
                     };
            ) {
                const expression =
                     match[2];


                     view.dom.addEventListener(
                const expressionStart =
                         'input',
                     match.index +
                        inputHandler
                    match[0].indexOf(
                         expression
                     );
                     );


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


                     const wrapper =
                while (
                        view.dom.closest(
                     (
                             '.ext-codemirror-wrapper'
                        expressionMatch =
                         );
                            expressionPattern.exec(
                                expression
                             )
                    ) !== null
                ) {
                    const name =
                         expressionMatch[0]
                            .toUpperCase();


                     if (wrapper) {
                     if (
                         wrapper.classList.add(
                         !knownVariables.has(name)
                            'preload-editor-codemirror-wrapper'
                    ) {
                         );
                         continue;
                     }
                     }


                     this.editor = {
                     const start =
                         cm,
                        expressionStart +
                         view,
                        expressionMatch.index;
                         textarea,
 
                         inputHandler
                    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'
        );


                    this.editorView = view;
        const require =
            mw.loader.require;


                    this.state.preloadContent =
        const {
                        view.state.doc.toString();
            StateField,
            StateEffect,
            EditorView,
            Decoration
        } = require(
            'ext.CodeMirror.v6.lib'
        );


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


                 const onLoad = loader => {
                 update: (decorations, transaction) => {
                     if (
                     if (
                         settled ||
                         !transaction.docChanged
                        typeof loader !== 'function'
                     ) {
                     ) {
                         return;
                         return decorations;
                     }
                     }


                     try {
                     return this.createSyntaxDecorations(
                        loader(textarea);
                         transaction.state,
                    } catch (error) {
                         Decoration
                         settled = true;
                    );
                         cleanup();
                 },
                        reject(error);
                    }
                 };


                 mw.hook('dev.CCM.ready').add(onReady);
                 provide: field =>
                mw.hook('dev.CCM.load').add(onLoad);
                    EditorView.decorations.from(
                        field
                    )
             });
             });
        },


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


         init() {
         this.editor.syntaxField =
             const root = $('#mw-content-text');
             syntaxField;
    },


             if (!root.length) {
    async mountCodeMirror(textarea) {
                 return;
        return new Promise((resolve, reject) => {
             }
             let settled = false;
 
            const cleanup = () => {
                mw.hook('dev.CCM.ready').remove(
                    onReady
                );
                mw.hook('dev.CCM.load').remove(
                    onLoad
                 );
             };


             this.root =
             const onReady = async cm => {
                 $('<div>')
                 if (
                     .attr('id', 'preload-editor')
                     settled ||
                     .addClass('preload-editor');
                    !cm ||
                    cm.textarea !== textarea ||
                     !cm.view
                ) {
                    return;
                }


            root.append(this.root);
                settled = true;
            this.renderShell();
                cleanup();
        },


        renderShell() {
                const view = cm.view;
            this.destroyEditor();
            this.root.empty();


            const loader =
                const inputHandler = () => {
                this.createSection('Preload');
                    this.state.preloadContent =
                        view.state.doc.toString();


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


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


            const newButton =
                 view.dom.addEventListener(
                 this.createButton(
                     'input',
                     'New',
                     inputHandler
                    '',
                     () => this.startNew()
                 );
                 );


            const sampleButton =
                 view.dom.classList.add(
                 this.createButton(
                     'preload-editor-codemirror'
                     'Load Sample Page',
                    '',
                    () => this.loadSamplePage()
                 );
                 );


            const loadRow =
                const wrapper =
                $('<div>')
                     view.dom.closest(
                     .addClass('preload-editor-toolbar')
                        '.ext-codemirror-wrapper'
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                     );
                     );


            loader.append(loadRow);
                if (wrapper) {
            this.root.append(loader);
                    wrapper.classList.add(
                        'preload-editor-codemirror-wrapper'
                    );
                }


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


            this.root.append(this.status);
                this.editorView = view;


            this.workspace =
                this.state.preloadContent =
                $('<div>')
                     view.state.doc.toString();
                     .addClass('preload-editor-workspace');


            this.root.append(this.workspace);
                try {
            this.renderWelcome();
                    await this.installSyntaxHighlighting(
        },
                        view
                    );


        renderWelcome() {
                    resolve(view);
            this.workspace.empty();
                } catch (error) {
                    reject(error);
                }
            };


             const section =
             const onLoad = loader => {
                 this.createSection('Get started');
                 if (
                    settled ||
                    typeof loader !== 'function'
                ) {
                    return;
                }


            $('<p>')
                try {
                 .text(
                    loader(textarea);
                     'Load an existing preload, create a new one, or start from a sample wiki page.'
                 } catch (error) {
                )
                     settled = true;
                .appendTo(section);
                    cleanup();
                    reject(error);
                }
            };


             const tips =
             mw.hook('dev.CCM.ready').add(onReady);
                $('<ul>')
            mw.hook('dev.CCM.load').add(onLoad);
                    .addClass('preload-editor-tips');
        });
    },


            [
    // Main UI
                '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);
    init() {
            this.workspace.append(section);
        const root = $('#mw-content-text');
        },


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


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


            if (type) {
        root.append(this.root);
                this.status.addClass('is-' + type);
        this.renderShell();
            }
    },


            this.status.text(message || '');
    renderShell() {
         },
        this.destroyEditor();
         this.root.empty();


         // Load / create
         const loader =
            this.createSection('Preload');


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


             if (!title) {
        const loadButton =
                 mw.notify(
             this.createButton(
                    'Enter a preload page title first.',
                 'Load',
                    { type: 'warn' }
                'preload-editor-button-primary',
                 );
                 () => this.loadExisting(titleInput.val())
                return;
            );
            }


             this.setStatus(
        const newButton =
                 'Loading preload...',
             this.createButton(
                 'loading'
                 'New',
                 '',
                () => this.startNew()
             );
             );


             try {
        const sampleButton =
                 const page = await this.getPage(title);
             this.createButton(
                'Load Sample Page',
                '',
                 () => this.loadSamplePage()
            );


                 if (!page) {
        const loadRow =
                     const suggestions =
            $('<div>')
                        await this.findSimilarPreloads(title);
                 .addClass('preload-editor-toolbar')
                .append(
                    titleInput,
                    loadButton,
                     newButton,
                    sampleButton
                );


                    this.renderMissingPage(
        loader.append(loadRow);
                        title,
        this.root.append(loader);
                        suggestions
                    );


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


                    return;
        this.root.append(this.status);
                }


                await this.loadDefinition(
        this.workspace =
                    page.title,
            $('<div>')
                    page.content
                .addClass('preload-editor-workspace');
                );


                this.setStatus(
        this.root.append(this.workspace);
                    'Preload loaded.',
        this.renderWelcome();
                    'success'
    },
                );
            } catch (error) {
                console.error(
                    'Preload Editor:',
                    error
                );
 
                this.setStatus(
                    error.message ||
                        'Could not load preload.',
                    'error'
                );
            }
        },


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


             if (!query) {
        const section =
                query = title;
             this.createSection('Get started');
            }


             try {
        $('<p>')
                 const results =
             .text(
                    await this.searchPages(query);
                 'Load an existing preload, create a new one, or start from a sample wiki page.'
            )
            .appendTo(section);


                return results.filter(
        const tips =
                    result =>
            $('<ul>')
                        /\/Preload$/i.test(
                 .addClass('preload-editor-tips');
                            result.title
                        )
                );
            } catch (error) {
                 console.error(
                    'Preload Editor search:',
                    error
                );


                return [];
        [
             }
            '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);
         });


         renderMissingPage(title, suggestions) {
         section.append(tips);
            this.workspace.empty();
        this.workspace.append(section);
    },


            const section =
    setStatus(message, type) {
                this.createSection(
        if (!this.status) {
                    'Preload not found'
            return;
                );
        }


             $('<p>')
        this.status
                 .text(
             .removeClass(
                    'No preload exists at "' +
                'is-error',
                    title +
                 'is-success',
                    '".'
                'is-warning',
                 )
                 'is-loading'
                .appendTo(section);
            );


            if (suggestions.length) {
        if (type) {
                $('<h3>')
            this.status.addClass('is-' + type);
                    .text('Similar preloads')
        }
                    .appendTo(section);


                const list =
        this.status.text(message || '');
                    $('<div>')
    },
                        .addClass(
                            'preload-editor-suggestions'
                        );


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


                section.append(list);
    async loadExisting(title) {
            }
        title = String(title || '').trim();


             section.append(
        if (!title) {
                 this.createButton(
             mw.notify(
                    'Create this preload',
                 'Enter a preload page title first.',
                    'preload-editor-button-primary',
                { type: 'warn' }
                    () => this.startNew(title)
                )
             );
             );
            return;
        }


             this.workspace.append(section);
        this.setStatus(
        },
             'Loading preload...',
            'loading'
        );


         async startNew(title) {
         try {
             title = String(title || '').trim();
             const page = await this.getPage(title);


             this.state = {
             if (!page) {
                 preloadTitle: title,
                 const suggestions =
                preloadSource: '',
                    await this.findSimilarPreloads(title);
                preloadContent: this.defaults.preload,
                config: this.emptyConfig(),
                variables: ['PAGE'],
                conditions: [],
                mode: 'new',
                dirty: true
            };


            this.renderEditor();
                this.renderMissingPage(
                    title,
                    suggestions
                );


            this.setStatus(
                this.setStatus(
                'Creating a new preload.',
                    'Preload not found.',
                'success'
                     'warning'
            );
        },
 
        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;
                 return;
             }
             }
            await this.loadDefinition(
                page.title,
                page.content
            );


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


             try {
             this.setStatus(
                 const page =
                 error.message ||
                     await this.getPage(result);
                     'Could not load preload.',
                'error'
            );
        }
    },


                if (!page) {
    async findSimilarPreloads(title) {
                    throw new Error(
        let query =
                        'Sample page not found: ' +
            title
                        result
                .replace(/\/Preload$/i, '')
                    );
                .trim();
                }


                const sample =
        if (!query) {
                    this.convertSampleToPreload(
            query = title;
                        page.content
        }
                    );


                this.state = {
        try {
                    preloadTitle: '',
            const results =
                    preloadSource: '',
                await this.searchPages(query);
                    preloadContent: sample,
                    config: this.emptyConfig(),
                    variables: ['PAGE'],
                    conditions: [],
                    mode: 'new',
                    dirty: true
                };


                this.reanalyze();
             return results.filter(
                this.renderEditor();
                 result =>
 
                     /\/Preload$/i.test(
                this.setStatus(
                        result.title
                    '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'
             );
             );
         },
         } catch (error) {
            console.error(
                'Preload Editor search:',
                error
            );


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


                const box =
    renderMissingPage(title, suggestions) {
                    $('<div>')
        this.workspace.empty();
                        .addClass(
                            'preload-editor-prompt'
                        );


                 $('<h2>')
        const section =
                    .text(title)
            this.createSection(
                    .appendTo(box);
                 'Preload not found'
            );


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


                 const input = this.textInput();
        if (suggestions.length) {
            $('<h3>')
                .text('Similar preloads')
                 .appendTo(section);


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


                 const cancel =
            suggestions.forEach(result => {
                 list.append(
                     this.createButton(
                     this.createButton(
                         'Cancel',
                         result.title,
                         '',
                         'preload-editor-suggestion',
                         () => {
                         () =>
                             overlay.remove();
                             this.loadExisting(
                            resolve(null);
                                result.title
                        }
                            )
                    );
                    )
                );
            });


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


                            if (!value) {
        section.append(
                                return;
            this.createButton(
                            }
                'Create this preload',
                'preload-editor-button-primary',
                () => this.startNew(title)
            )
        );


                            overlay.remove();
        this.workspace.append(section);
                            resolve(value);
    },
                        }
                    );


                buttons.append(cancel, confirm);
    async startNew(title) {
                box.append(input, buttons);
        title = String(title || '').trim();
                overlay.append(box);
                $(document.body).append(overlay);


                input.trigger('focus');
        this.state = {
            preloadTitle: title,
            preloadSource: '',
            preloadContent: this.defaults.preload,
            config: this.emptyConfig(),
            variables: ['PAGE'],
            conditions: [],
            mode: 'new',
            dirty: true
        };


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


                        if (event.key === 'Escape') {
        this.setStatus(
                            cancel.trigger('click');
            'Creating a new preload.',
                        }
            'success'
                    }
        );
                );
    },
            });
        },


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


            let config = this.emptyConfig();
        if (!result) {
            return;
        }


            const configTitle =
        this.setStatus(
                title.replace(/\/+$/, '') +
            'Loading sample page...',
                '/config.json';
            'loading'
        );


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


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


             const analysis =
             const sample =
                 this.analyzePreload(content);
                 this.convertSampleToPreload(
 
                    page.content
            config =
                this.prepareConfigForVariables(
                    config,
                    analysis
                 );
                 );


             this.state = {
             this.state = {
                 preloadTitle: title,
                 preloadTitle: '',
                 preloadSource: source,
                 preloadSource: '',
                 preloadContent: source,
                 preloadContent: sample,
                 config,
                 config: this.emptyConfig(),
                 variables: analysis.variables,
                 variables: ['PAGE'],
                 conditions: analysis.conditions,
                 conditions: [],
                 mode: 'edit',
                 mode: 'new',
                 dirty: false
                 dirty: true
             };
             };


            this.reanalyze();
             this.renderEditor();
             this.renderEditor();
        },


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


         renderEditor() {
            this.setStatus(
             this.destroyEditor();
                error.message ||
            this.workspace.empty();
                    'Could not load sample page.',
                'error'
            );
         }
    },
 
    convertSampleToPreload(source) {
        let content =
             String(source || '').trim();


             const layout =
        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>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-layout'
                         'preload-editor-prompt-overlay'
                     );
                     );


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


             const right =
            $('<h2>')
                .text(title)
                .appendTo(box);
 
            $('<p>')
                .text(message)
                .appendTo(box);
 
             const input = this.textInput();
 
            const buttons =
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-right'
                         'preload-editor-prompt-buttons'
                     );
                     );


             const editorSection =
             const cancel =
                 this.createSection(
                 this.createButton(
                     'Preload',
                     'Cancel',
                     'preload-editor-main-section'
                     '',
                    () => {
                        overlay.remove();
                        resolve(null);
                    }
                 );
                 );


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


            $('<strong>')
                        if (!value) {
                .text(
                            return;
                    this.state.preloadTitle ||
                        }
                    'New preload'
                )
                .appendTo(meta);


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


             editorSection.append(meta);
             buttons.append(cancel, confirm);
            box.append(input, buttons);
            overlay.append(box);
            $(document.body).append(overlay);


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


             const editorMount =
             input.on(
                 $('<div>')
                 'keydown.preloadEditor',
                     .addClass(
                event => {
                        'preload-editor-codemirror-mount'
                     if (event.key === 'Enter') {
                    )
                        confirm.trigger('click');
                    .append(textarea);
                    }


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


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


            const analysisSection =
        let config = this.emptyConfig();
                this.createSection('Analysis');


            const analysisContainer =
        const configTitle =
                $('<div>')
            title.replace(/\/+$/, '') +
                    .addClass(
            '/config.json';
                        'preload-editor-analysis'
                    );


             analysisSection.append(
        const configPage =
                analysisContainer
             await this.getPage(configTitle);
            );


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


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


             variablesSection.append(
        config =
                 variableContainer
             this.prepareConfigForVariables(
                 config,
                analysis
             );
             );


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


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


            configSection.append(configPreview);
    // Editor


            const actions =
    renderEditor() {
                $('<div>')
        this.destroyEditor();
                    .addClass(
        this.workspace.empty();
                        'preload-editor-actions'
                    );


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


            const savePreload =
        const left =
                 this.createButton(
            $('<div>')
                     'Save Preload Only',
                 .addClass(
                    '',
                     'preload-editor-left'
                    () => this.savePreloadOnly()
                 );
                 );


             actions.append(save, savePreload);
        const right =
             $('<div>')
                .addClass(
                    'preload-editor-right'
                );


             right.append(
        const editorSection =
                 analysisSection,
             this.createSection(
                variablesSection,
                 'Preload',
                configSection,
                 'preload-editor-main-section'
                 actions
             );
             );


             layout.append(left, right);
        const meta =
            this.workspace.append(layout);
             $('<div>')
                .addClass(
                    'preload-editor-meta'
                );


            this.reanalyze();
        $('<strong>')
             this.renderVariables(variableContainer);
             .text(
            this.renderAnalysis(analysisContainer);
                this.state.preloadTitle ||
             this.renderConfigPreview(configPreview);
                'New preload'
            )
             .appendTo(meta);


            this.mountCodeMirror(textarea.get(0))
        if (this.state.mode === 'new') {
                 .catch(error => {
            $('<span>')
                     console.error(
                 .addClass(
                        'Preload Editor CodeMirror:',
                     'preload-editor-badge'
                        error
                )
                    );
                .text('NEW')
                .appendTo(meta);
        }


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


         reanalyze() {
         const textarea =
             const source = this.getEditorContent();
             this.textarea(
            const content = this.extractIncludeonly(source);
                this.state.preloadContent
            const analysis = this.analyzePreload(content);
            )
                .attr(
                    'id',
                    'preload-editor-content'
                )
                .addClass(
                    'preload-editor-content'
                );


            this.state.preloadContent = source;
        const editorMount =
             this.state.variables = analysis.variables;
             $('<div>')
            this.state.conditions = analysis.conditions;
                .addClass(
                    'preload-editor-codemirror-mount'
                )
                .append(textarea);


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


            this.analysis = analysis;
        left.append(editorSection);
        },


         renderAnalysis(container) {
         const analysisSection =
             container.empty();
             this.createSection('Analysis');


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


             const variableCount =
        analysisSection.append(
                analysis.variables.length;
             analysisContainer
        );


            const conditionCount =
        const variablesSection =
                analysis.conditions.length;
            this.createSection('Variables');


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


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


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


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


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


             if (validation.errors.length) {
        const save =
                 const errors =
             this.createButton(
                    $('<ul>')
                 'Save Preload + Config',
                        .addClass(
                'preload-editor-button-primary',
                            'preload-editor-validation-errors'
                () => this.saveAll()
                        );
            );


                validation.errors.forEach(error => {
        const savePreload =
                    $('<li>')
            this.createButton(
                        .text(error)
                'Save Preload Only',
                        .appendTo(errors);
                '',
                });
                () => this.savePreloadOnly()
            );


                container.append(errors);
        actions.append(save, savePreload);
            }


            if (validation.warnings.length) {
        right.append(
                const warnings =
            analysisSection,
                    $('<ul>')
            variablesSection,
                        .addClass(
            configSection,
                            'preload-editor-validation-warnings'
            actions
                        );
        );


                validation.warnings.forEach(warning => {
        layout.append(left, right);
                    $('<li>')
        this.workspace.append(layout);
                        .text(warning)
                        .appendTo(warnings);
                });


                container.append(warnings);
        this.reanalyze();
            }
        this.renderVariables(variableContainer);
        this.renderAnalysis(analysisContainer);
        this.renderConfigPreview(configPreview);


            if (
        this.mountCodeMirror(textarea.get(0))
                !validation.errors.length &&
            .catch(error => {
                !validation.warnings.length
                 console.error(
            ) {
                     'Preload Editor CodeMirror:',
                 container.append(
                    error
                     $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                 );
                 );
            }
        },


        renderAnalysisBySelector() {
                 this.setStatus(
            const container =
                    error.message ||
                 this.root &&
                        'Could not initialize CodeMirror.',
                this.root.find(
                     'error'
                     '.preload-editor-analysis'
                 );
                 );
            });
    },


            if (container && container.length) {
    reanalyze() {
                this.renderAnalysis(container);
        const source = this.getEditorContent();
            }
        const content = this.extractIncludeonly(source);
         },
         const analysis = this.analyzePreload(content);


         renderVariables(container) {
         this.state.preloadContent = source;
            container.empty();
        this.state.variables = analysis.variables;
        this.state.conditions = analysis.conditions;


            const analysis =
        this.state.config =
                this.analysis ||
            this.prepareConfigForVariables(
                 this.analyzePreload(
                 this.state.config ||
                     this.extractIncludeonly(
                     this.emptyConfig(),
                        this.getEditorContent()
                analysis
                    )
            );
                );


            const variables =
        this.analysis = analysis;
                analysis.variables.slice();
    },


            const ordered = ['PAGE'];
    renderAnalysis(container) {
        container.empty();


             variables.forEach(name => {
        const analysis =
                 if (
             this.analysis ||
                     name !== 'PAGE' &&
            this.analyzePreload(
                    !ordered.includes(name)
                 this.extractIncludeonly(
                 ) {
                     this.getEditorContent()
                    ordered.push(name);
                 )
                }
             );
             });


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


                if (!ordered.includes(upper)) {
        const conditionCount =
                    ordered.push(upper);
            analysis.conditions.length;
                }
            });


            ordered.forEach(name => {
        const summary =
                const field =
            $('<div>')
                    this.state.config.fields[name] ||
                 .addClass(
                    this.createDefaultField(
                     'preload-editor-analysis-summary'
                        name,
                        analysis
                    );
 
                this.state.config.fields[name] = field;
 
                 container.append(
                     this.createVariableCard(
                        name,
                        field,
                        analysis
                    )
                 );
                 );
            });
        },


         renderVariablesBySelector() {
         summary.append(
            const container =
            $('<span>').text(
                 this.root &&
                variableCount +
                 this.root.find(
                 ' variable' +
                    '.preload-editor-variables'
                 (variableCount === 1 ? '' : 's')
                 );
            ),
            $('<span>').text(
                conditionCount +
                ' condition marker' +
                 (conditionCount === 1 ? '' : 's')
            )
        );


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


         createVariableCard(name, field, analysis) {
         if (analysis.conditionVariables.length) {
             const card =
             container.append(
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-variable'
                         'preload-editor-analysis-note'
                     )
                     )
                     .attr(
                     .text(
                         'data-variable',
                         'Condition variables: ' +
                         name
                         analysis.conditionVariables.join(', ')
                     );
                     )
            );
        }
 
        const validation =
            this.validateConfig(
                this.state.config,
                analysis
            );


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


             header.append(
             validation.errors.forEach(error => {
                 $('<h3>').text(name)
                 $('<li>')
             );
                    .text(error)
                    .appendTo(errors);
            });
 
             container.append(errors);
        }


            if (name === 'PAGE') {
        if (validation.warnings.length) {
                header.append(
             const warnings =
                    $('<span>')
                 $('<ul>')
                        .addClass(
                    .addClass(
                            'preload-editor-badge'
                        'preload-editor-validation-warnings'
                        )
                    );
                        .text('AUTOMATIC')
                );
             } else if (
                !analysis.variables.includes(name)
            ) {
                 header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text('UNUSED')
                );
            }


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


             const label =
             container.append(warnings);
                this.textInput(field.label || '');
        }


             label.on(
        if (
                'input.preloadEditor',
             !validation.errors.length &&
                () => {
            !validation.warnings.length
                    field.label = label.val();
        ) {
                     this.markDirty();
            container.append(
                     this.renderConfigPreviewBySelector();
                $('<div>')
                }
                     .addClass(
                        'preload-editor-validation-success'
                    )
                     .text(
                        'Preload and configuration are in sync.'
                    )
             );
             );
        }
    },


             card.append(
    renderAnalysisBySelector() {
                this.fieldRow('Label', label)
        const container =
             this.root &&
            this.root.find(
                '.preload-editor-analysis'
             );
             );


            const description =
        if (container && container.length) {
                this.textarea(
            this.renderAnalysis(container);
                    field.description || ''
        }
                )
    },
                    .addClass(
                        'preload-editor-small-textarea'
                    );


            description.on(
    renderVariables(container) {
                'input.preloadEditor',
        container.empty();
                () => {
                    field.description = description.val();
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                }
            );


             card.append(
        const analysis =
                 this.fieldRow(
            this.analysis ||
                     'Description',
             this.analyzePreload(
                    description
                 this.extractIncludeonly(
                     this.getEditorContent()
                 )
                 )
             );
             );


            const types = [
        const variables =
                ['text', 'Text'],
            analysis.variables.slice();
                ['textarea', 'Textarea'],
 
                ['number', 'Number'],
        const ordered = ['PAGE'];
                ['boolean', 'Boolean'],
 
                ['select', 'Select'],
        variables.forEach(name => {
                 ['multiselect', 'Multi-select'],
            if (
                 ['page', 'Page'],
                 name !== 'PAGE' &&
                ['user', 'User'],
                 !ordered.includes(name)
                 ['date', 'Date']
            ) {
             ];
                 ordered.push(name);
             }
        });


             const typeSelect =
        Object.keys(
                this.select(
             this.state.config.fields || {}
                    types.map(
        ).forEach(name => {
                        ([value, label]) => ({
            const upper = name.toUpperCase();
                            value,
                            label
                        })
                    ),
                    field.type || 'text'
                );


             typeSelect.on(
             if (!ordered.includes(upper)) {
                'change.preloadEditor',
                ordered.push(upper);
                () => {
            }
                    field.type = typeSelect.val();
        });
                    this.markDirty();


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


                    this.renderConfigPreviewBySelector();
            this.state.config.fields[name] = field;
                }
            );


             card.append(
             container.append(
                 this.fieldRow(
                 this.createVariableCard(
                     'Type',
                     name,
                     typeSelect
                     field,
                    analysis
                 )
                 )
             );
             );
        });
    },


            if (name === 'PAGE') {
    renderVariablesBySelector() {
                 field.type = 'page';
        const container =
            this.root &&
            this.root.find(
                 '.preload-editor-variables'
            );


                const required =
        if (container && container.length) {
                    this.checkbox(
            this.renderVariables(container);
                        field.required !== false
        }
                    );
    },


                 required.on(
    createVariableCard(name, field, analysis) {
                     'change.preloadEditor',
        const card =
                    () => {
            $('<div>')
                        field.required =
                 .addClass(
                            required.prop('checked');
                     'preload-editor-variable'
                )
                .attr(
                    'data-variable',
                    name
                );


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


                card.append(
        header.append(
                     this.fieldRow(
            $('<h3>').text(name)
                         'Required',
        );
                         required
 
        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 || '');


                const namespace =
        label.on(
                    this.select(
            'input.preloadEditor',
                        [
            () => {
                            {
                field.label = label.val();
                                value: 'Main',
                this.markDirty();
                                label: 'Main'
                this.renderConfigPreviewBySelector();
                            },
            }
                            {
        );
                                value: 'Template',
                                label: 'Template'
                            },
                            {
                                value: 'File',
                                label: 'File'
                            },
                            {
                                value: 'Category',
                                label: 'Category'
                            },
                            {
                                value: 'User',
                                label: 'User'
                            }
                        ],
                        field.namespace || 'Main'
                    );


                namespace.on(
        card.append(
                    'change.preloadEditor',
            this.fieldRow('Label', label)
                    () => {
        );
                        field.namespace =
                            namespace.val();


                        this.markDirty();
        const description =
                        this.renderConfigPreviewBySelector();
            this.textarea(
                     }
                field.description || ''
            )
                .addClass(
                     'preload-editor-small-textarea'
                 );
                 );


                 card.append(
        description.on(
                    this.fieldRow(
            'input.preloadEditor',
                        'Namespace',
            () => {
                        namespace
                 field.description = description.val();
                    )
                this.markDirty();
                );
                this.renderConfigPreviewBySelector();
            }
        );
 
        card.append(
            this.fieldRow(
                'Description',
                description
            )
        );


                const allowNew =
        const types = [
                    this.checkbox(
            ['text', 'Text'],
                        field['allow-new'] === true
            ['textarea', 'Textarea'],
                    );
            ['number', 'Number'],
            ['boolean', 'Boolean'],
            ['select', 'Select'],
            ['multiselect', 'Multi-select'],
            ['page', 'Page'],
            ['user', 'User'],
            ['date', 'Date']
        ];


                allowNew.on(
        const typeSelect =
                    'change.preloadEditor',
            this.select(
                     () => {
                types.map(
                         field['allow-new'] =
                     ([value, label]) => ({
                            allowNew.prop('checked');
                         value,
                        label
                    })
                ),
                field.type || 'text'
            );


                        this.markDirty();
        typeSelect.on(
                        this.renderConfigPreviewBySelector();
            'change.preloadEditor',
                    }
            () => {
                 );
                field.type = typeSelect.val();
                 this.markDirty();


                 card.append(
                 card.replaceWith(
                     this.fieldRow(
                     this.createVariableCard(
                         'Allow new page',
                         name,
                         allowNew
                         field,
                        analysis
                     )
                     )
                 );
                 );


                 return card;
                 this.renderConfigPreviewBySelector();
             }
             }
        );
        card.append(
            this.fieldRow(
                'Type',
                typeSelect
            )
        );
        if (name === 'PAGE') {
            field.type = 'page';


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


Line 1,897: Line 2,026:
             );
             );


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


             placeholder.on(
             namespace.on(
                 'input.preloadEditor',
                 'change.preloadEditor',
                 () => {
                 () => {
                     const value = placeholder.val();
                     field.namespace =
 
                         namespace.val();
                    if (value) {
                        field.placeholder = value;
                    } else {
                         delete field.placeholder;
                    }


                     this.markDirty();
                     this.markDirty();
Line 1,920: Line 2,066:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Placeholder',
                     'Namespace',
                     placeholder
                     namespace
                 )
                 )
             );
             );


             const defaultInput =
             const allowNew =
                 this.textInput(
                 this.checkbox(
                     field.default === undefined
                     field['allow-new'] === true
                        ? ''
                        : field.default
                 );
                 );


             defaultInput.on(
             allowNew.on(
                 'input.preloadEditor',
                 'change.preloadEditor',
                 () => {
                 () => {
                     const value = defaultInput.val();
                     field['allow-new'] =
 
                         allowNew.prop('checked');
                    if (value) {
                        field.default = value;
                    } else {
                         delete field.default;
                    }


                     this.markDirty();
                     this.markDirty();
Line 1,950: Line 2,089:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Default',
                     'Allow new page',
                     defaultInput,
                     allowNew
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                 )
                 )
             );
             );


             if (field.type === 'textarea') {
             return card;
                 const maxRows =
        }
                     this.textInput(
 
                        field.maxRows || ''
        const required =
                    ).attr(
            this.checkbox(
                        'type',
                field.required === true
                        'number'
            );
                    );
 
        required.on(
            'change.preloadEditor',
            () => {
                 field.required =
                     required.prop('checked');
 
                this.markDirty();
                this.renderConfigPreviewBySelector();
            }
        );


                maxRows.on(
        card.append(
                    'input.preloadEditor',
            this.fieldRow(
                    () => {
                'Required',
                        const value =
                required
                            parseInt(
            )
                                maxRows.val(),
        );
                                10
                            );


                        if (
        const placeholder =
                            Number.isFinite(value) &&
            this.textInput(
                            value > 0
                field.placeholder || ''
                        ) {
            );
                            field.maxRows = value;
                        } else {
                            delete field.maxRows;
                        }


                        this.markDirty();
        placeholder.on(
                        this.renderConfigPreviewBySelector();
            'input.preloadEditor',
                    }
            () => {
                 );
                 const value = placeholder.val();


                 card.append(
                 if (value) {
                     this.fieldRow(
                     field.placeholder = value;
                        'Maximum rows',
                } else {
                        maxRows
                     delete field.placeholder;
                     )
                 }
                 );
            }


            if (
                 this.markDirty();
                 field.type === 'select' ||
                 this.renderConfigPreviewBySelector();
                field.type === 'multiselect'
            ) {
                 this.renderSelectConfiguration(
                    card,
                    field
                );
             }
             }
        );


             return card;
        card.append(
         },
            this.fieldRow(
                'Placeholder',
                placeholder
             )
         );


         renderSelectConfiguration(card, field) {
         const defaultInput =
            const source =
            this.textInput(
                this.textInput(
                field.default === undefined
                    field.source || '',
                    ? ''
                     'category:Example'
                     : field.default
                );
            );


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


                    if (value) {
                if (value) {
                        field.source = value;
                    field.default = value;
                    } else {
                } else {
                        delete field.source;
                    delete field.default;
                    }
                }


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


            card.append(
        card.append(
                this.fieldRow(
            this.fieldRow(
                    'Source',
                'Default',
                    source,
                defaultInput,
                    'Currently supported source format: category:Category Name'
                'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            )
            );
        );


             const sourcePrefix =
        if (field.type === 'textarea') {
             const maxRows =
                 this.textInput(
                 this.textInput(
                     field['source-prefix'] || ''
                     field.maxRows || ''
                ).attr(
                    'type',
                    'number'
                 );
                 );


             sourcePrefix.on(
             maxRows.on(
                 'input.preloadEditor',
                 'input.preloadEditor',
                 () => {
                 () => {
                     const value =
                     const value =
                         sourcePrefix.val();
                         parseInt(
                            maxRows.val(),
                            10
                        );


                     if (value) {
                     if (
                         field['source-prefix'] = value;
                        Number.isFinite(value) &&
                        value > 0
                    ) {
                         field.maxRows = value;
                     } else {
                     } else {
                         delete field['source-prefix'];
                         delete field.maxRows;
                     }
                     }


Line 2,064: Line 2,213:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Source prefix',
                     'Maximum rows',
                     sourcePrefix,
                     maxRows
                    'Only source values beginning with this text are included.'
                 )
                 )
             );
             );
        }


            const excludeCategory =
        if (
                this.textInput(
            field.type === 'select' ||
                    field.exclude &&
            field.type === 'multiselect'
                    field.exclude.category
        ) {
                        ? field.exclude.category
            this.renderSelectConfiguration(
                        : ''
                card,
                 );
                 field
            );
        }


            excludeCategory.on(
        return card;
                'input.preloadEditor',
    },
                () => {
                    const value =
                        excludeCategory.val();


                    if (value) {
    renderSelectConfiguration(card, field) {
                        field.exclude =
        const source =
                            Object.assign(
            this.textInput(
                                {},
                field.source || '',
                                field.exclude || {},
                'category:Example'
                                { category: value }
            );
                            );
                    } else if (field.exclude) {
                        delete field.exclude.category;


                        if (
        source.on(
                            !Object.keys(
            'input.preloadEditor',
                                field.exclude
            () => {
                            ).length
                const value = source.val();
                        ) {
                            delete field.exclude;
                        }
                    }


                     this.markDirty();
                if (value) {
                     this.renderConfigPreviewBySelector();
                     field.source = value;
                } else {
                     delete field.source;
                 }
                 }
            );


             card.append(
                this.markDirty();
                this.fieldRow(
                this.renderConfigPreviewBySelector();
                    'Exclude category',
             }
                    excludeCategory
        );
                 )
 
        card.append(
            this.fieldRow(
                'Source',
                source,
                 'Currently supported source format: category:Category Name'
            )
        );
 
        const sourcePrefix =
            this.textInput(
                field['source-prefix'] || ''
             );
             );


            card.append(
        sourcePrefix.on(
                this.createTransformControls(
            'input.preloadEditor',
                    field,
            () => {
                    'value',
                 const value =
                    'Value transformation'
                    sourcePrefix.val();
                 ),
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );


            const optionsValue =
                 if (value) {
                 Array.isArray(field.options)
                     field['source-prefix'] = value;
                     ? field.options
                } else {
                        .map(option => {
                    delete field['source-prefix'];
                            if (
                }
                                typeof option === 'string'
                            ) {
                                return option;
                            }


                            return (
                this.markDirty();
                                String(
                this.renderConfigPreviewBySelector();
                                    option.value || ''
            }
                                ) +
        );
                                ' | ' +
                                String(
                                    option.label ||
                                    option.value ||
                                    ''
                                )
                            );
                        })
                        .join('\n')
                    : '';


             const options =
        card.append(
                this.textarea(optionsValue)
             this.fieldRow(
                    .addClass(
                'Source prefix',
                        'preload-editor-small-textarea'
                sourcePrefix,
                    );
                'Only source values beginning with this text are included.'
            )
        );


             options.on(
        const excludeCategory =
                 'input.preloadEditor',
             this.textInput(
                 () => {
                 field.exclude &&
                     const lines =
                 field.exclude.category
                        options.val()
                     ? field.exclude.category
                            .split('\n')
                    : ''
                            .map(line => line.trim())
            );
                            .filter(Boolean);


                    if (!lines.length) {
        excludeCategory.on(
                        delete field.options;
            'input.preloadEditor',
                    } else {
            () => {
                        field.options =
                const value =
                            lines.map(line => {
                    excludeCategory.val();
                                const parts =
                                    line.split(
                                        /\s*\|\s*/
                                    );


                                if (parts.length === 1) {
                if (value) {
                                    return parts[0];
                    field.exclude =
                                }
                        Object.assign(
                            {},
                            field.exclude || {},
                            { category: value }
                        );
                } else if (field.exclude) {
                    delete field.exclude.category;


                                return {
                    if (
                                    value: parts[0],
                        !Object.keys(
                                    label:
                            field.exclude
                                        parts
                        ).length
                                            .slice(1)
                    ) {
                                            .join(' | ')
                        delete field.exclude;
                                };
                            });
                     }
                     }
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                 }
                 }
            );


            card.append(
                this.markDirty();
                 this.fieldRow(
                 this.renderConfigPreviewBySelector();
                    'Manual options',
             }
                    options,
        );
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                )
             );
        },


         createTransformControls(field, property, title) {
         card.append(
             const wrapper =
             this.fieldRow(
                 $('<div>')
                 'Exclude category',
                    .addClass(
                excludeCategory
                        'preload-editor-transform'
            )
                    );
        );


             $('<h4>')
        card.append(
                .text(title)
             this.createTransformControls(
                .appendTo(wrapper);
                field,
                'value',
                'Value transformation'
            ),
            this.createTransformControls(
                field,
                'label',
                'Label transformation'
            )
        );


            const existing =
        const optionsValue =
                 field[property] &&
            Array.isArray(field.options)
                typeof field[property] === 'object'
                 ? field.options
                    ? field[property]
                    .map(option => {
                    : {};
                        if (
                            typeof option === 'string'
                        ) {
                            return option;
                        }


            const transform =
                        return (
                this.select(
                            String(
                    [
                                option.value || ''
                        {
                             ) +
                            value: '',
                             ' | ' +
                             label: 'No transformation'
                             String(
                        },
                                option.label ||
                        {
                                option.value ||
                             value: 'remove-prefix',
                                ''
                             label: 'Remove prefix'
                             )
                        },
                         );
                        {
                     })
                            value: 'remove-suffix',
                     .join('\n')
                            label: 'Remove suffix'
                 : '';
                        },
                        {
                             value: 'replace',
                            label: 'Replace'
                         }
                     ],
                     existing.transform || ''
                 );


            const prefix =
        const options =
                 this.textInput(
            this.textarea(optionsValue)
                     existing.prefix || ''
                 .addClass(
                     'preload-editor-small-textarea'
                 );
                 );


             const suffix =
        options.on(
                this.textInput(
             'input.preloadEditor',
                    existing.suffix || ''
            () => {
                );
                const lines =
                    options.val()
                        .split('\n')
                        .map(line => line.trim())
                        .filter(Boolean);


            const from =
                if (!lines.length) {
                this.textInput(
                    delete field.options;
                    existing.from || ''
                } else {
                );
                    field.options =
                        lines.map(line => {
                            const parts =
                                line.split(
                                    /\s*\|\s*/
                                );


            const to =
                            if (parts.length === 1) {
                this.textInput(
                                return parts[0];
                    existing.to || ''
                            }
                );


            const controls =
                            return {
                $('<div>')
                                value: parts[0],
                    .addClass(
                                label:
                        'preload-editor-transform-controls'
                                    parts
                    );
                                        .slice(1)
                                        .join(' | ')
                            };
                        });
                }


            const prefixRow =
                this.markDirty();
                 this.fieldRow(
                 this.renderConfigPreviewBySelector();
                    'Prefix',
            }
                    prefix
        );
                );


             const suffixRow =
        card.append(
                this.fieldRow(
             this.fieldRow(
                    'Suffix',
                'Manual options',
                    suffix
                options,
                 );
                 'One per line. Optional label format: value | label. Leave empty when using a source.'
            )
        );
    },


            const fromRow =
    createTransformControls(field, property, title) {
                 this.fieldRow(
        const wrapper =
                     'From',
            $('<div>')
                    from
                 .addClass(
                     'preload-editor-transform'
                 );
                 );


            const toRow =
        $('<h4>')
                this.fieldRow(
            .text(title)
                    'To',
            .appendTo(wrapper);
                    to
                );


             const renderInputs = () => {
        const existing =
                 const type = transform.val();
             field[property] &&
            typeof field[property] === 'object'
                ? field[property]
                 : {};


                prefixRow.toggle(
        const transform =
                     type === 'remove-prefix'
            this.select(
                 );
                [
                    {
                        value: '',
                        label: 'No transformation'
                     },
                    {
                        value: 'remove-prefix',
                        label: 'Remove prefix'
                    },
                    {
                        value: 'remove-suffix',
                        label: 'Remove suffix'
                    },
                    {
                        value: 'replace',
                        label: 'Replace'
                    }
                 ],
                existing.transform || ''
            );


                suffixRow.toggle(
        const prefix =
                    type === 'remove-suffix'
            this.textInput(
                );
                existing.prefix || ''
            );


                fromRow.toggle(
        const suffix =
                    type === 'replace'
            this.textInput(
                );
                existing.suffix || ''
            );


                toRow.toggle(
        const from =
                    type === 'replace'
            this.textInput(
                );
                existing.from || ''
            };
            );


            const update = () => {
        const to =
                 const type = transform.val();
            this.textInput(
                let object = null;
                 existing.to || ''
            );


                if (type === 'remove-prefix') {
        const controls =
                    if (prefix.val()) {
            $('<div>')
                        object = {
                .addClass(
                            transform: type,
                     'preload-editor-transform-controls'
                            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) {
        const prefixRow =
                    field[property] = object;
            this.fieldRow(
                 } else {
                'Prefix',
                    delete field[property];
                 prefix
                }
            );


                this.markDirty();
        const suffixRow =
                 this.renderConfigPreviewBySelector();
            this.fieldRow(
             };
                 'Suffix',
                suffix
             );


             transform.on(
        const fromRow =
                 'change.preloadEditor',
             this.fieldRow(
                 () => {
                 'From',
                    update();
                 from
                    renderInputs();
                }
             );
             );


             [
        const toRow =
                prefix,
             this.fieldRow(
                suffix,
                 'To',
                 from,
                 to
                 to
             ].forEach(input => {
             );
                input.on(
 
                    'input.preloadEditor',
        const renderInputs = () => {
                    update
            const type = transform.val();
                );
            });


             controls.append(
             prefixRow.toggle(
                 this.fieldRow(
                 type === 'remove-prefix'
                    'Transformation',
                    transform
                ),
                prefixRow,
                suffixRow,
                fromRow,
                toRow
             );
             );


             renderInputs();
             suffixRow.toggle(
                type === 'remove-suffix'
            );


             wrapper.append(controls);
             fromRow.toggle(
             return wrapper;
                type === 'replace'
        },
             );


         // Config output
            toRow.toggle(
                type === 'replace'
            );
         };


         getCleanConfig() {
         const update = () => {
             const config =
             const type = transform.val();
                JSON.parse(
            let object = null;
                    JSON.stringify(
                        this.state.config
                    )
                );


             const fields = config.fields || {};
             if (type === 'remove-prefix') {
             const orderedFields = {};
                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 (fields.PAGE) {
             if (object) {
                 orderedFields.PAGE = fields.PAGE;
                 field[property] = object;
            } else {
                delete field[property];
             }
             }


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


            config.fields = orderedFields;
        transform.on(
             return config;
             'change.preloadEditor',
        },
            () => {
 
                 update();
        getConfigJSON() {
                 renderInputs();
            return JSON.stringify(
                 this.getCleanConfig(),
                 null,
                4
            );
        },
 
        renderConfigPreview(container) {
            if (!container) {
                return;
             }
             }
        );


             container.text(
        [
                 this.getConfigJSON()
            prefix,
            suffix,
            from,
             to
        ].forEach(input => {
            input.on(
                 'input.preloadEditor',
                update
             );
             );
         },
         });


         renderConfigPreviewBySelector() {
         controls.append(
             const preview =
             this.fieldRow(
                this.root &&
                 'Transformation',
                 this.root.find(
                 transform
                    '.preload-editor-config-preview'
            ),
                 );
            prefixRow,
            suffixRow,
            fromRow,
            toRow
        );


            if (preview && preview.length) {
        renderInputs();
                this.renderConfigPreview(preview);
            }


            this.renderAnalysisBySelector();
        wrapper.append(controls);
         },
         return wrapper;
    },


        markDirty() {
    // Config output
            this.state.dirty = true;
        },


         // Save
    getCleanConfig() {
         const config =
            JSON.parse(
                JSON.stringify(
                    this.state.config
                )
            );


         async savePreloadOnly() {
         const fields = config.fields || {};
            let title =
        const orderedFields = {};
                String(
                    this.state.preloadTitle || ''
                ).trim();


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


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


                title = result;
        config.fields = orderedFields;
                this.state.preloadTitle = title;
        return config;
            }
    },


            this.state.preloadContent =
    getConfigJSON() {
                this.getEditorContent();
        return JSON.stringify(
            this.getCleanConfig(),
            null,
            4
        );
    },


            const validation =
    renderConfigPreview(container) {
                this.validateBeforeSave();
        if (!container) {
            return;
        }


            if (validation.errors.length) {
        container.text(
                this.showValidationErrors(validation);
            this.getConfigJSON()
                return;
        );
            }
    },


             this.setStatus(
    renderConfigPreviewBySelector() {
                'Saving preload...',
        const preview =
                 'loading'
             this.root &&
            this.root.find(
                 '.preload-editor-config-preview'
             );
             );


            try {
        if (preview && preview.length) {
                const exists =
            this.renderConfigPreview(preview);
                    await this.pageExists(title);
        }


                await this.savePage(
        this.renderAnalysisBySelector();
                    title,
    },
                    this.state.preloadContent,
                    exists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !exists
                );


                this.state.mode = 'edit';
    markDirty() {
                this.state.dirty = false;
        this.state.dirty = true;
    },


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


                mw.notify(
    async savePreloadOnly() {
                    'Preload saved.',
        let title =
                    { type: 'success' }
             String(
                );
                 this.state.preloadTitle || ''
             } catch (error) {
            ).trim();
                 console.error(
                    'Preload Editor save:',
                    error
                );


                 this.setStatus(
        if (!title) {
                     error.message ||
            const result =
                        'Could not save preload.',
                 await this.createPromptDialog(
                     'error'
                     'Preload title',
                     'Enter the full title for the preload.'
                 );
                 );


                mw.notify(
            if (!result) {
                    error.message ||
                 return;
                        'Could not save preload.',
                    { type: 'error' }
                 );
             }
             }
        },


        async saveAll() {
            title = result;
             let title =
             this.state.preloadTitle = title;
                String(
        }
                    this.state.preloadTitle || ''
 
                ).trim();
        this.state.preloadContent =
            this.getEditorContent();
 
        const validation =
            this.validateBeforeSave();


            if (!title) {
        if (validation.errors.length) {
                const result =
            this.showValidationErrors(validation);
                    await this.createPromptDialog(
            return;
                        'Preload title',
        }
                        'Enter the full title for the preload.'
                    );


                if (!result) {
        this.setStatus(
                    return;
            'Saving preload...',
                }
            'loading'
        );


                title = result;
        try {
                 this.state.preloadTitle = title;
            const exists =
            }
                 await this.pageExists(title);


             this.state.preloadContent =
             await this.savePage(
                 this.getEditorContent();
                title,
                this.state.preloadContent,
                 exists
                    ? 'Updated preload with Preload Editor'
                    : 'Created preload with Preload Editor',
                !exists
            );


             const validation =
             this.state.mode = 'edit';
                this.validateBeforeSave();
            this.state.dirty = false;


             if (validation.errors.length) {
             this.setStatus(
                 this.showValidationErrors(validation);
                 'Preload saved successfully.',
                 return;
                 'success'
             }
             );


             const configTitle =
             mw.notify(
                 title.replace(/\/+$/, '') +
                 'Preload saved.',
                 '/config.json';
                { type: 'success' }
            );
        } catch (error) {
            console.error(
                 'Preload Editor save:',
                error
            );


             this.setStatus(
             this.setStatus(
                 'Saving preload and config...',
                 error.message ||
                 'loading'
                    'Could not save preload.',
                'error'
            );
 
            mw.notify(
                error.message ||
                    'Could not save preload.',
                 { type: 'error' }
             );
             );
        }
    },


            try {
    async saveAll() {
                const preloadExists =
        let title =
                    await this.pageExists(title);
            String(
                this.state.preloadTitle || ''
            ).trim();


                 await this.savePage(
        if (!title) {
                     title,
            const result =
                     this.state.preloadContent,
                 await this.createPromptDialog(
                    preloadExists
                     'Preload title',
                        ? 'Updated preload with Preload Editor'
                     'Enter the full title for the preload.'
                        : 'Created preload with Preload Editor',
                    !preloadExists
                 );
                 );


                const configExists =
            if (!result) {
                    await this.pageExists(configTitle);
                return;
            }


                await this.savePage(
            title = result;
                    configTitle,
            this.state.preloadTitle = title;
                    this.getConfigJSON(),
        }
                    configExists
 
                        ? 'Updated preload config with Preload Editor'
        this.state.preloadContent =
                        : 'Created preload config with Preload Editor',
            this.getEditorContent();
                    !configExists
 
                );
        const validation =
            this.validateBeforeSave();
 
        if (validation.errors.length) {
            this.showValidationErrors(validation);
            return;
        }


                this.state.mode = 'edit';
        const configTitle =
                this.state.dirty = false;
            title.replace(/\/+$/, '') +
                this.state.preloadTitle = title;
            '/config.json';


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


                mw.notify(
        try {
                    'Preload and config saved.',
             const preloadExists =
                    { type: 'success' }
                 await this.pageExists(title);
                );
             } catch (error) {
                 console.error(
                    'Preload Editor save:',
                    error
                );


                this.setStatus(
            await this.savePage(
                    error.message ||
                title,
                        'Could not save preload/config.',
                this.state.preloadContent,
                     'error'
                preloadExists
                 );
                    ? 'Updated preload with Preload Editor'
                     : 'Created preload with Preload Editor',
                 !preloadExists
            );


                 mw.notify(
            const configExists =
                    error.message ||
                 await this.pageExists(configTitle);
                        'Could not save preload/config.',
                    { type: 'error' }
                );
            }
        },


        validateBeforeSave() {
            await this.savePage(
            const errors = [];
                configTitle,
             const warnings = [];
                this.getConfigJSON(),
                configExists
                    ? 'Updated preload config with Preload Editor'
                    : 'Created preload config with Preload Editor',
                !configExists
             );


             const title =
             this.state.mode = 'edit';
                String(
            this.state.dirty = false;
                    this.state.preloadTitle || ''
            this.state.preloadTitle = title;
                ).trim();


             if (!title) {
             this.setStatus(
                 errors.push(
                 'Preload and config saved successfully.',
                    'A preload title is required.'
                'success'
                );
            );
            }


             if (
             mw.notify(
                 title &&
                 'Preload and config saved.',
                 !/\/Preload$/i.test(title)
                 { type: 'success' }
            ) {
            );
                warnings.push(
        } catch (error) {
                    'The title does not end with "/Preload".'
            console.error(
                 );
                'Preload Editor save:',
            }
                 error
            );


             const content =
             this.setStatus(
                 this.getEditorContent();
                 error.message ||
                    'Could not save preload/config.',
                'error'
            );


             this.state.preloadContent = content;
             mw.notify(
                error.message ||
                    'Could not save preload/config.',
                { type: 'error' }
            );
        }
    },
 
    validateBeforeSave() {
        const errors = [];
        const warnings = [];


             if (!content.trim()) {
        const title =
                 errors.push(
             String(
                    'The preload cannot be empty.'
                 this.state.preloadTitle || ''
                );
            ).trim();
            }


            if (!/<includeonly>/i.test(content)) {
        if (!title) {
                warnings.push(
            errors.push(
                    'The preload does not contain an <includeonly> section.'
                'A preload title is required.'
                );
            );
            }
        }


            if (!/<noinclude>/i.test(content)) {
        if (
                warnings.push(
            title &&
                    'The preload does not contain a <noinclude> section.'
            !/\/Preload$/i.test(title)
                );
        ) {
            }
            warnings.push(
                'The title does not end with "/Preload".'
            );
        }


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


            const configValidation =
        this.state.preloadContent = content;
                this.validateConfig(
                    this.state.config,
                    analysis
                );


        if (!content.trim()) {
             errors.push(
             errors.push(
                 ...configValidation.errors
                 '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(
             warnings.push(
                 ...configValidation.warnings
                 'The preload does not contain a <noinclude> section.'
             );
             );
        }


             let depth = 0;
        const analysis =
             this.analyzePreload(
                this.extractIncludeonly(content)
            );


             analysis.conditions.forEach(condition => {
        const configValidation =
                 if (condition.type === 'if') {
             this.validateConfig(
                    depth++;
                 this.state.config,
                 } else if (
                 analysis
                    condition.type === 'endif'
            );
                ) {
                    depth--;


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


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


            if (depth > 0) {
        let depth = 0;
                errors.push(
                    'One or more @if blocks are missing @endif.'
                );
            }


             return {
        analysis.conditions.forEach(condition => {
                 errors,
             if (condition.type === 'if') {
                warnings,
                 depth++;
                 valid: errors.length === 0
            } else if (
             };
                 condition.type === 'endif'
        },
             ) {
                depth--;


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


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


            mw.notify(
        if (depth > 0) {
                validation.errors.join('\n'),
            errors.push(
                 { type: 'error' }
                 'One or more @if blocks are missing @endif.'
             );
             );
         }
         }
    };


     mw.PreloadEditor = PreloadEditor;
        return {
            errors,
            warnings,
            valid: errors.length === 0
        };
     },
 
    showValidationErrors(validation) {
        this.renderAnalysisBySelector();


     $(function () {
        this.setStatus(
        const title =
            validation.errors.join(' '),
            mw.config.get('wgPageName');
            'error'
        );
 
        mw.notify(
            validation.errors.join('\n'),
            { type: 'error' }
        );
     }
};
 
mw.PreloadEditor = PreloadEditor;
 
$(function () {
    const title =
        mw.config.get('wgPageName');


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


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


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