Jump to content

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

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


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


         state: {
         state: {
Line 15: Line 15:
             dirty: false
             dirty: false
         },
         },
        editor: null,
        editorView: null,
        analysis: null,
        root: null,
        workspace: null,
        status: null,


         defaults: {
         defaults: {
Line 20: Line 27:
                 '<includeonly>\n' +
                 '<includeonly>\n' +
                 '<!-- preload here -->\n' +
                 '<!-- preload here -->\n' +
                 '</includeonly>' +
                 '</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) {
Line 67: Line 72:


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


Line 99: Line 103:
                 title: title,
                 title: title,
                 text: content,
                 text: content,
                 summary:
                 summary: summary || 'Updated with Preload Editor',
                    summary ||
                    'Updated with Preload Editor',
                 formatversion: 2
                 formatversion: 2
             };
             };
Line 109: Line 111:
             }
             }


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


         /* =========================================================
         // Preload parsing
        * PRELOAD PARSING
        * ========================================================= */


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


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


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


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


                 if (!variables.includes(name)) {
                 if (!variables.includes(name)) {
Line 157: Line 146:
             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 (
             while ((match = pattern.exec(content)) !== null) {
                (match = pattern.exec(content)) !== null
            ) {
                 conditions.push({
                 conditions.push({
                     type:
                     type: match[1].toLowerCase(),
                        match[1].toLowerCase(),
                     expression: match[2].trim(),
                     expression:
                     index: match.index
                        match[2].trim(),
                     index:
                        match.index
                 });
                 });
             }
             }
Line 178: Line 161:
         findConditionVariables(content) {
         findConditionVariables(content) {
             const variables = [];
             const variables = [];
             const conditions =
             const conditions = this.findConditions(content);
                this.findConditions(content);


             conditions.forEach(condition => {
             conditions.forEach(condition => {
Line 195: Line 177:


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


                     if (
                     if (!variables.includes(upper)) {
                        !variables.includes(upper)
                    ) {
                         variables.push(upper);
                         variables.push(upper);
                     }
                     }
Line 210: Line 189:


         analyzePreload(content) {
         analyzePreload(content) {
             const variables =
             const variables = this.findVariables(content);
                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,
                 variables,
                 conditions: conditions,
                 conditions,
                 conditionVariables:
                 conditionVariables
                    conditionVariables
             };
             };
         },
         },


         /* =========================================================
         // Config
        * CONFIG
        * ========================================================= */


         emptyConfig() {
         emptyConfig() {
Line 239: Line 211:
                     PAGE: {
                     PAGE: {
                         label: 'Page Name',
                         label: 'Page Name',
                         description:
                         description: 'Name of the page.',
                            'Name of the page.',
                         type: 'page',
                         type: 'page',
                         required: true,
                         required: true,
Line 256: Line 227:
                 Array.isArray(config)
                 Array.isArray(config)
             ) {
             ) {
                 config =
                 config = this.emptyConfig();
                    this.emptyConfig();
             }
             }


Line 276: Line 246:
             }
             }


            /*
            * PAGE is always present.
            *
            * Existing PAGE configuration is preserved.
            */
             if (!config.fields.PAGE) {
             if (!config.fields.PAGE) {
                 config.fields.PAGE = {
                 config.fields.PAGE = {
                     label: 'Page Name',
                     label: 'Page Name',
                     description:
                     description: 'Name of the page.',
                        'Name of the page.',
                     type: 'page',
                     type: 'page',
                     required: true,
                     required: true,
Line 298: Line 262:
         createDefaultField(name, analysis) {
         createDefaultField(name, analysis) {
             const isConditionVariable =
             const isConditionVariable =
                 analysis.conditionVariables.includes(
                 analysis.conditionVariables.includes(name);
                    name
                );
 
            let type = 'text';
 
            /*
            * Variables used directly as conditions
            * are most commonly booleans.
            *
            * This is only a suggestion; the editor
            * can change it.
            */
            if (isConditionVariable) {
                type = 'boolean';
            }


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


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


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


            /*
             fields.PAGE = existing.PAGE || {
            * PAGE is deliberately first.
                label: 'Page Name',
            */
                description: 'Name of the page.',
             fields.PAGE =
                type: 'page',
                existing.PAGE || {
                required: true,
                    label: 'Page Name',
                namespace: 'Main',
                    description:
                'allow-new': true
                        'Name of the page.',
            };
                    type: 'page',
 
                    required: true,
             analysis.variables.forEach(name => {
                    namespace: 'Main',
                    'allow-new': true
                };
 
             analysis.variables.forEach(name => {
                 if (name === 'PAGE') {
                 if (name === 'PAGE') {
                     return;
                     return;
Line 370: Line 302:
                 fields[name] =
                 fields[name] =
                     existing[name] ||
                     existing[name] ||
                     this.createDefaultField(
                     this.createDefaultField(name, analysis);
                        name,
                        analysis
                    );
             });
             });


            /*
            * Preserve fields that exist in the config
            * but are no longer present in the preload.
            *
            * They are shown as unused rather than deleted.
            */
             Object.keys(existing).forEach(name => {
             Object.keys(existing).forEach(name => {
                 const upper =
                 const upper = name.toUpperCase();
                    name.toUpperCase();


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


             config.fields = fields;
             config.fields = fields;
             return config;
             return config;
         },
         },
Line 408: Line 328:
                 return {
                 return {
                     valid: false,
                     valid: false,
                     errors: [
                     errors: ['Config must be an object.'],
                        'Config must be an object.'
                    ],
                     warnings: []
                     warnings: []
                 };
                 };
Line 420: Line 338:
                 Array.isArray(config.fields)
                 Array.isArray(config.fields)
             ) {
             ) {
                 errors.push(
                 errors.push('"fields" must be an object.');
                    '"fields" must be an object.'
                );


                 return {
                 return {
                     valid: false,
                     valid: false,
                     errors: errors,
                     errors,
                     warnings: warnings
                     warnings
                 };
                 };
             }
             }


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


             analysis.variables.forEach(name => {
             analysis.variables.forEach(name => {
                 if (
                 if (!configured.includes(name)) {
                    !configured.includes(name)
                ) {
                     errors.push(
                     errors.push(
                         'Variable "' +
                         'Variable "' +
Line 450: Line 363:


             configured.forEach(name => {
             configured.forEach(name => {
                 if (
                 if (!analysis.variables.includes(name)) {
                    !analysis.variables.includes(
                        name
                    )
                ) {
                     warnings.push(
                     warnings.push(
                         'Field "' +
                         'Field "' +
Line 463: Line 372:
             });
             });


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


             Object.entries(
             Object.entries(config.fields).forEach(
                config.fields
            ).forEach(
                 ([name, field]) => {
                 ([name, field]) => {
                     if (
                     if (
Line 485: Line 388:
                             '" must be an object.'
                             '" must be an object.'
                         );
                         );
                         return;
                         return;
                     }
                     }
Line 503: Line 405:
                         ) &&
                         ) &&
                         !field.source &&
                         !field.source &&
                         !Array.isArray(
                         !Array.isArray(field.options)
                            field.options
                        )
                     ) {
                     ) {
                         warnings.push(
                         warnings.push(
Line 519: Line 419:


             return {
             return {
                 valid:
                 valid: errors.length === 0,
                    errors.length === 0,
                 errors,
                 errors: errors,
                 warnings
                 warnings: warnings
             };
             };
         },
         },


         /* =========================================================
         // Transformation helpers
        * TRANSFORMATION HELPERS
        * ========================================================= */


         transformationObject(
         transformationObject(
Line 543: Line 440:
             switch (transform) {
             switch (transform) {
                 case 'remove-prefix':
                 case 'remove-prefix':
                     if (!prefix) {
                     return prefix
                         return null;
                         ? {
                    }
                            transform: 'remove-prefix',
 
                            prefix
                    return {
                        }
                        transform:
                        : null;
                            'remove-prefix',
                        prefix: prefix
                    };


                 case 'remove-suffix':
                 case 'remove-suffix':
                     if (!suffix) {
                     return suffix
                         return null;
                         ? {
                    }
                            transform: 'remove-suffix',
 
                            suffix
                    return {
                        }
                        transform:
                        : null;
                            'remove-suffix',
                        suffix: suffix
                    };


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


                 default:
                 default:
Line 580: Line 469:
         },
         },


         /* =========================================================
         // UI helpers
        * UI HELPERS
        * ========================================================= */


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


             if (className) {
             if (className) {
Line 596: Line 481:


             $('<h2>')
             $('<h2>')
                 .addClass(
                 .addClass('preload-editor-section-title')
                    'preload-editor-section-title'
                )
                 .text(title)
                 .text(title)
                 .appendTo(section);
                 .appendTo(section);
Line 605: Line 488:
         },
         },


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


Line 632: Line 509:
         },
         },


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


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


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


             row.append(input);
             row.append(input);
             return row;
             return row;
         },
         },
Line 675: Line 539:
                             ? ''
                             ? ''
                             : value,
                             : value,
                     placeholder:
                     placeholder: placeholder || ''
                        placeholder || ''
                 })
                 })
                 .addClass(
                 .addClass('preload-editor-input');
                    'preload-editor-input'
                );
         },
         },


Line 691: Line 552:
                         : value
                         : value
                 )
                 )
                 .addClass(
                 .addClass('preload-editor-textarea');
                    'preload-editor-textarea'
                );
         },
         },


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


Line 713: Line 565:
             const select =
             const select =
                 $('<select>')
                 $('<select>')
                     .addClass(
                     .addClass('preload-editor-select');
                        'preload-editor-select'
                    );


             options.forEach(option => {
             options.forEach(option => {
                 $('<option>')
                 $('<option>')
                     .attr(
                     .attr('value', option.value)
                        'value',
                     .text(option.label)
                        option.value
                    )
                     .text(option.label)
                     .prop(
                     .prop(
                         'selected',
                         'selected',
                         String(
                         String(option.value) ===
                            option.value
                        ) ===
                             String(
                             String(
                                 value === undefined
                                 value === undefined
Line 741: Line 586:
         },
         },


         /* =========================================================
         // CodeMirror
        * MAIN UI
        * ========================================================= */


         init() {
         destroyEditor() {
    const root =
            if (!this.editor) {
        $('#mw-content-text');
                this.editorView = null;
                return;
    if (!root.length) {
            }
        return;
    }
    /*
    * Keep the wiki page itself intact.
    * The editor gets its own mount point.
    */
    this.root =
        $('<div>')
            .attr(
                'id',
                'preload-editor'
            )
            .addClass(
                'preload-editor'
            );
    root.append(
        this.root
    );
    this.renderShell();
},


renderShell() {
            const {
    this.root.empty();
                view,
                inputHandler
    /*
            } = this.editor;
    * Preload loader.
    */
    const loader =
        this.createSection(
            'Preload'
        );
    const titleInput =
        this.textInput(
            this.state.preloadTitle,
            'Template:Example/Preload'
        )
            .attr(
                'id',
                'preload-editor-title-input'
            );
    const loadButton =
        this.createButton(
            'Load',
            'preload-editor-button-primary',
            () =>
                this.loadExisting(
                    titleInput.val()
                )
        );
    const newButton =
        this.createButton(
            'New',
            '',
            () =>
                this.startNew()
        );
    const sampleButton =
        this.createButton(
            'Load Sample Page',
            '',
            () =>
                this.loadSamplePage()
        );
    const loadRow =
        $('<div>')
            .addClass(
                'preload-editor-toolbar'
            );
    loadRow.append(
        titleInput,
        loadButton,
        newButton,
        sampleButton
    );
    loader.append(
        loadRow
    );
    this.root.append(
        loader
    );
    /*
    * Status area.
    */
    this.status =
        $('<div>')
            .addClass(
                'preload-editor-status'
            );
    this.root.append(
        this.status
    );
    /*
    * Workspace.
    */
    this.workspace =
        $('<div>')
            .addClass(
                'preload-editor-workspace'
            );
    this.root.append(
        this.workspace
    );
    /*
    * Initially show instructions.
    */
    this.renderWelcome();
},


        renderWelcome() {
            if (view && inputHandler) {
            this.workspace.empty();
                view.dom.removeEventListener(
                    'input',
                    inputHandler
                );
            }


             const section =
             if (view) {
                 this.createSection(
                 view.destroy();
                    'Get started'
            }
                );


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


            const tips =
        getEditorView() {
                $('<ul>')
            return this.editorView;
                    .addClass(
        },
                        'preload-editor-tips'
                    );


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


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


            this.workspace.append(
        setEditorContent(content) {
                section
             content = String(content || '');
             );
        },


        setStatus(message, type) {
             if (!this.editorView) {
             if (!this.status) {
                this.state.preloadContent = content;
                 return;
                 return;
             }
             }


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


             if (type) {
             if (current === content) {
                 this.status.addClass(
                 return;
                    'is-' + type
                );
             }
             }


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


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


        async loadExisting(title) {
                const cleanup = () => {
            title =
                    mw.hook('dev.CCM.ready').remove(
                String(title || '').trim();
                        onReady
                    );
                    mw.hook('dev.CCM.load').remove(
                        onLoad
                    );
                };


            if (!title) {
                const onReady = cm => {
                mw.notify(
                    if (
                    'Enter a preload page title first.',
                        settled ||
                     {
                        !cm ||
                         type: 'warn'
                        cm.textarea !== textarea ||
                        !cm.view
                     ) {
                         return;
                     }
                     }
                );


                return;
                    settled = true;
            }
                    cleanup();
 
                    const view = cm.view;


            this.setStatus(
                    const inputHandler = () => {
                'Loading preload...',
                        this.state.preloadContent =
                'loading'
                            view.state.doc.toString();
            );


            try {
                        this.state.dirty = true;
                const page =
                        this.reanalyze();
                    await this.getPage(title);


                if (!page) {
                        this.renderVariablesBySelector();
                    const suggestions =
                         this.renderAnalysisBySelector();
                         await this.findSimilarPreloads(
                         this.renderConfigPreviewBySelector();
                            title
                    };
                         );


                     this.renderMissingPage(
                     view.dom.addEventListener(
                         title,
                         'input',
                         suggestions
                         inputHandler
                     );
                     );


                     this.setStatus(
                     view.dom.classList.add(
                         'Preload not found.',
                         'preload-editor-codemirror'
                        'warning'
                     );
                     );


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


                await this.loadDefinition(
                    if (wrapper) {
                    page.title,
                        wrapper.classList.add(
                    page.content
                            'preload-editor-codemirror-wrapper'
                );
                        );
                    }


                this.setStatus(
                    this.editor = {
                    'Preload loaded.',
                        cm,
                    'success'
                        view,
                );
                        textarea,
            } catch (error) {
                        inputHandler
                console.error(
                     };
                     'Preload Editor:',
                    error
                );


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


        async findSimilarPreloads(title) {
                     this.state.preloadContent =
            let query =
                         view.state.doc.toString();
                title
                     .replace(
                         /\/Preload$/i,
                        ''
                    )
                    .trim();


            if (!query) {
                    resolve(view);
                 query = title;
                 };
            }


            try {
                 const onLoad = loader => {
                 const results =
                     if (
                     await this.searchPages(
                         settled ||
                         query
                        typeof loader !== 'function'
                     );
                     ) {
                        return;
                    }


                return results.filter(
                     try {
                     result =>
                         loader(textarea);
                         /\/Preload$/i.test(
                    } catch (error) {
                            result.title
                        settled = true;
                        )
                        cleanup();
                );
                        reject(error);
            } catch (error) {
                     }
                console.error(
                 };
                    'Preload Editor search:',
                     error
                 );


                 return [];
                 mw.hook('dev.CCM.ready').add(onReady);
             }
                mw.hook('dev.CCM.load').add(onLoad);
             });
         },
         },


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


             const section =
        init() {
                this.createSection(
             const root = $('#mw-content-text');
                    'Preload not found'
                );


             $('<p>')
             if (!root.length) {
                .text(
                return;
                    'No preload exists at "' +
            }
                    title +
 
                    '".'
            this.root =
                )
                $('<div>')
                .appendTo(section);
                    .attr('id', 'preload-editor')
                    .addClass('preload-editor');


             if (suggestions.length) {
             root.append(this.root);
                $('<h3>')
            this.renderShell();
                    .text(
        },
                        'Similar preloads'
                    )
                    .appendTo(section);


                const list =
        renderShell() {
                    $('<div>')
            this.destroyEditor();
                        .addClass(
            this.root.empty();
                            'preload-editor-suggestions'
                        );


                suggestions.forEach(
            const loader =
                    result => {
                this.createSection('Preload');
                        const button =
                            this.createButton(
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            );


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


                section.append(list);
             const loadButton =
             }
 
            section.append(
                 this.createButton(
                 this.createButton(
                     'Create this preload',
                     'Load',
                     'preload-editor-button-primary',
                     'preload-editor-button-primary',
                     () =>
                     () => this.loadExisting(titleInput.val())
                        this.startNew(title)
                 );
                 )
            );


             this.workspace.append(
             const newButton =
                 section
                this.createButton(
            );
                    'New',
        },
                    '',
                    () => this.startNew()
                 );


        async startNew(title) {
             const sampleButton =
             title =
                 this.createButton(
                 String(
                     'Load Sample Page',
                     title ||
                     '',
                     ''
                    () => this.loadSamplePage()
                ).trim();
                );


             this.state = {
             const loadRow =
                 preloadTitle: title,
                 $('<div>')
                preloadSource: '',
                     .addClass('preload-editor-toolbar')
                preloadContent:
                     .append(
                     this.defaults.preload,
                        titleInput,
                config:
                        loadButton,
                     this.emptyConfig(),
                        newButton,
                variables: ['PAGE'],
                        sampleButton
                conditions: [],
                    );
                mode: 'new',
                dirty: true
            };


             this.renderEditor();
            loader.append(loadRow);
             this.root.append(loader);


             this.setStatus(
             this.status =
                 'Creating a new preload.',
                 $('<div>')
                'success'
                    .addClass('preload-editor-status');
            );
        },


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


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


             if (!result) {
             this.root.append(this.workspace);
                return;
            this.renderWelcome();
            }
        },


             this.setStatus(
        renderWelcome() {
                'Loading sample page...',
             this.workspace.empty();
                'loading'
            );


             try {
             const section =
                const page =
                this.createSection('Get started');
                    await this.getPage(
                        result
                    );


                if (!page) {
            $('<p>')
                    throw new Error(
                .text(
                        'Sample page not found: ' +
                    'Load an existing preload, create a new one, or start from a sample wiki page.'
                        result
                )
                    );
                .appendTo(section);
                }


                const sample =
            const tips =
                     this.convertSampleToPreload(
                $('<ul>')
                        page.content
                     .addClass('preload-editor-tips');
                    );


                 this.state = {
            [
                    preloadTitle: '',
                 'Existing preload: loads the preload and its config.json if available.',
                    preloadSource: '',
                'New preload: starts with the standard Preload Template structure.',
                    preloadContent:
                'Sample page: loads normal wiki page content so you can turn it into a preload.'
                        sample,
            ].forEach(text => {
                    config:
                $('<li>')
                        this.emptyConfig(),
                     .text(text)
                    variables:
                     .appendTo(tips);
                        ['PAGE'],
            });
                     conditions: [],
                     mode: 'new',
                    dirty: true
                };


                this.reanalyze();
            section.append(tips);
            this.workspace.append(section);
        },


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


                 this.setStatus(
            this.status
                     'Sample page loaded.',
                 .removeClass(
                     'success'
                     'is-error',
                );
                     'is-success',
            } catch (error) {
                     'is-warning',
                console.error(
                     'is-loading'
                     'Preload Editor sample:',
                     error
                 );
                 );


                 this.setStatus(
            if (type) {
                    error.message ||
                 this.status.addClass('is-' + type);
                        'Could not load sample page.',
                    'error'
                );
             }
             }
            this.status.text(message || '');
         },
         },


         convertSampleToPreload(source) {
         // Load / create
             let content =
 
                String(source || '').trim();
        async loadExisting(title) {
             title = String(title || '').trim();


             /*
             if (!title) {
            * If the sample itself contains includeonly,
                mw.notify(
            * use only its included content.
                    'Enter a preload page title first.',
            *
                     { type: 'warn' }
            * Otherwise use the complete page.
            */
            const include =
                content.match(
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                 );
                 );
 
                 return;
            if (include) {
                 content =
                    include[1].trim();
             }
             }


             /*
             this.setStatus(
            * Remove documentation/noinclude blocks
                 'Loading preload...',
            * from a normal sample page.
                 'loading'
            */
            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) {
             try {
             return new Promise(resolve => {
                 const page = await this.getPage(title);
                 const overlay =
                    $('<div>')
                        .addClass(
                            'preload-editor-prompt-overlay'
                        );


                 const box =
                 if (!page) {
                    $('<div>')
                    const suggestions =
                         .addClass(
                         await this.findSimilarPreloads(title);
                            'preload-editor-prompt'
                        );


                $('<h2>')
                     this.renderMissingPage(
                     .text(title)
                        title,
                     .appendTo(box);
                        suggestions
                     );


                $('<p>')
                    this.setStatus(
                    .text(message)
                        'Preload not found.',
                     .appendTo(box);
                        'warning'
                     );


                const input =
                     return;
                     this.textInput();
                }


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


                 const cancel =
                 this.setStatus(
                    this.createButton(
                    'Preload loaded.',
                        'Cancel',
                    'success'
                        '',
                );
                        () => {
            } catch (error) {
                            overlay.remove();
                console.error(
                            resolve(null);
                    'Preload Editor:',
                        }
                     error
                    );
 
                const confirm =
                    this.createButton(
                        'Load',
                        'preload-editor-button-primary',
                        () => {
                            const value =
                                String(
                                    input.val() ||
                                    ''
                                ).trim();
 
                            if (!value) {
                                return;
                            }
 
                            overlay.remove();
                            resolve(value);
                        }
                    );
 
                buttons.append(
                    cancel,
                     confirm
                 );
                 );


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


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


                 $(document.body).append(
            if (!query) {
                     overlay
                 query = title;
            }
 
            try {
                const results =
                    await this.searchPages(query);
 
                return results.filter(
                    result =>
                        /\/Preload$/i.test(
                            result.title
                        )
                );
            } catch (error) {
                console.error(
                     'Preload Editor search:',
                    error
                 );
                 );


                 input.trigger('focus');
                 return [];
            }
        },


                input.on(
        renderMissingPage(title, suggestions) {
                    'keydown.preloadEditor',
            this.workspace.empty();
                    event => {
                        if (
                            event.key ===
                            'Enter'
                        ) {
                            confirm.trigger(
                                'click'
                            );
                        }


                        if (
            const section =
                            event.key ===
                this.createSection(
                            'Escape'
                    'Preload not found'
                        ) {
                            cancel.trigger(
                                'click'
                            );
                        }
                    }
                 );
                 );
            });
        },


        async loadDefinition(
            $('<p>')
            title,
                .text(
            source
                    'No preload exists at "' +
        ) {
                    title +
            const content =
                    '".'
                 this.extractIncludeonly(
                )
                    source
                 .appendTo(section);
                );


             let config =
             if (suggestions.length) {
                 this.emptyConfig();
                 $('<h3>')
                    .text('Similar preloads')
                    .appendTo(section);


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


            try {
                suggestions.forEach(result => {
                const configPage =
                    list.append(
                    await this.getPage(
                        this.createButton(
                         configTitle
                            result.title,
                            'preload-editor-suggestion',
                            () =>
                                this.loadExisting(
                                    result.title
                                )
                         )
                     );
                     );
                });


                 if (configPage) {
                 section.append(list);
                    try {
                        config =
                            JSON.parse(
                                configPage.content
                            );
                    } catch (error) {
                        throw new Error(
                            'The existing config.json contains invalid JSON: ' +
                            error.message
                        );
                    }
                }
            } catch (error) {
                /*
                * A missing config is allowed.
                * Other errors are re-thrown.
                */
                if (
                    error &&
                    error.message &&
                    error.message.indexOf(
                        'invalid JSON'
                    ) !== -1
                ) {
                    throw error;
                }
             }
             }


             const analysis =
             section.append(
                 this.analyzePreload(
                 this.createButton(
                     content
                     'Create this preload',
                 );
                    'preload-editor-button-primary',
                    () => this.startNew(title)
                 )
            );
 
            this.workspace.append(section);
        },


             config =
        async startNew(title) {
                this.prepareConfigForVariables(
             title = String(title || '').trim();
                    config,
                    analysis
                );


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


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


         /* =========================================================
         async loadSamplePage() {
        * EDITOR
             const result =
        * ========================================================= */
                 await this.createPromptDialog(
 
                     'Load Sample Page',
        renderEditor() {
                     'Enter the title of a normal wiki page to use as the starting point.'
            this.workspace.empty();
 
             const editor =
                 this.createSection(
                     'Preload editor',
                     'preload-editor-main-section'
                 );
                 );


             const meta =
             if (!result) {
                $('<div>')
                return;
                    .addClass(
            }
                        'preload-editor-meta'
                    );


             const title =
             this.setStatus(
                 this.state.preloadTitle ||
                 'Loading sample page...',
                 'New preload';
                 'loading'
            );


             $('<strong>')
             try {
                 .text(title)
                 const page =
                .appendTo(meta);
                    await this.getPage(result);


            if (
                if (!page) {
                this.state.mode === 'new'
                     throw new Error(
            ) {
                         'Sample page not found: ' +
                $('<span>')
                        result
                     .addClass(
                     );
                         'preload-editor-badge'
                }
                    )
                    .text('NEW')
                     .appendTo(meta);
            }


            editor.append(meta);
                const sample =
                    this.convertSampleToPreload(
                        page.content
                    );


            const textarea =
                 this.state = {
                 this.textarea(
                     preloadTitle: '',
                    this.state.preloadContent
                    preloadSource: '',
                )
                     preloadContent: sample,
                     .attr(
                     config: this.emptyConfig(),
                        'id',
                    variables: ['PAGE'],
                        'preload-editor-content'
                    conditions: [],
                     )
                    mode: 'new',
                     .addClass(
                     dirty: true
                        'preload-editor-content'
                };
                     );


            textarea.on(
                 this.reanalyze();
                 'input.preloadEditor',
                this.renderEditor();
                () => {
                    this.state.preloadContent =
                        textarea.val();


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


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


                    this.renderVariables(
        convertSampleToPreload(source) {
                        variableContainer
            let content =
                     );
                String(source || '').trim();
 
            const include =
                content.match(
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                );


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


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


             editor.append(
             return (
                 this.fieldRow(
                 '<includeonly>\n' +
                    'Preload wikitext',
                content +
                    textarea,
                '\n</includeonly>\n' +
                    'This is the actual preload. Variables use %%VARIABLE%% syntax.'
                 '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
                 )
             );
             );
        },


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


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


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


            /*
                $('<p>')
            * Variables.
                    .text(message)
            */
                    .appendTo(box);
            const variablesSection =
                this.createSection(
                    'Variables'
                );


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


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


            /*
                const cancel =
            * Config preview.
                    this.createButton(
            */
                        'Cancel',
            const configSection =
                        '',
                this.createSection(
                        () => {
                    'Generated configuration'
                            overlay.remove();
                );
                            resolve(null);
 
                         }
            const configPreview =
                $('<pre>')
                    .addClass(
                         'preload-editor-config-preview'
                     );
                     );


            configSection.append(
                const confirm =
                configPreview
                    this.createButton(
            );
                        'Load',
 
                        'preload-editor-button-primary',
            /*
                        () => {
            * Actions.
                            const value =
            */
                                String(
            const actions =
                                    input.val() || ''
                $('<div>')
                                ).trim();
                    .addClass(
 
                         'preload-editor-actions'
                            if (!value) {
                                return;
                            }
 
                            overlay.remove();
                            resolve(value);
                         }
                     );
                     );


            const save =
                 buttons.append(cancel, confirm);
                 this.createButton(
                box.append(input, buttons);
                    'Save Preload + Config',
                overlay.append(box);
                    'preload-editor-button-primary',
                 $(document.body).append(overlay);
                    () =>
                        this.saveAll()
                 );


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


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


            this.workspace.append(
                        if (event.key === 'Escape') {
                 editor,
                            cancel.trigger('click');
                analysisSection,
                        }
                variablesSection,
                    }
                configSection,
                 );
                 actions
            });
            );
        },
 
        async loadDefinition(title, source) {
            const content =
                 this.extractIncludeonly(source);


             this.reanalyze();
             let config = this.emptyConfig();


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


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


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


        reanalyze() {
             const analysis =
             const analysis =
                 this.analyzePreload(
                 this.analyzePreload(content);
                    this.state.preloadContent
                );


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


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


         renderAnalysis(container) {
         // Editor
            container.empty();
 
            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.state.preloadContent
                );


             const variableCount =
        renderEditor() {
                analysis.variables.length;
             this.destroyEditor();
 
             this.workspace.empty();
             const conditionCount =
                analysis.conditions.length;


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


             summary.append(
             const left =
                 $('<span>')
                 $('<div>')
                     .text(
                     .addClass(
                        variableCount +
                         'preload-editor-left'
                         ' variable' +
                    );
                        (
 
                            variableCount === 1
            const right =
                                ? ''
                $('<div>')
                                : 's'
                    .addClass(
                        )
                        'preload-editor-right'
                     )
                     );
            );


             summary.append(
             const editorSection =
                 $('<span>')
                 this.createSection(
                     .text(
                     'Preload',
                        conditionCount +
                    'preload-editor-main-section'
                        ' condition marker' +
                );
                        (
                            conditionCount === 1
                                ? ''
                                : 's'
                        )
                    )
            );


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


             if (
             $('<strong>')
                analysis.conditionVariables.length
                .text(
            ) {
                    this.state.preloadTitle ||
                const note =
                    'New preload'
                    $('<div>')
                )
                        .addClass(
                .appendTo(meta);
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis.conditionVariables.join(
                                ', '
                            )
                        );


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


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


             if (
             const textarea =
                validation.errors.length
                this.textarea(
            ) {
                    this.state.preloadContent
                const errors =
                )
                     $('<ul>')
                     .attr(
                        .addClass(
                        'id',
                            'preload-editor-validation-errors'
                        'preload-editor-content'
                        );
                    )
                    .addClass(
                        'preload-editor-content'
                    );


                validation.errors.forEach(
            const editorMount =
                    error => {
                $('<div>')
                        $('<li>')
                    .addClass(
                            .text(error)
                        'preload-editor-codemirror-mount'
                            .appendTo(errors);
                    )
                    }
                    .append(textarea);
                );


                container.append(errors);
            editorSection.append(
            }
                editorMount
            );


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


                 validation.warnings.forEach(
            const analysisSection =
                    warning => {
                 this.createSection('Analysis');
                        $('<li>')
                            .text(warning)
                            .appendTo(warnings);
                    }
                );


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


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


        /* =========================================================
            const variablesSection =
        * VARIABLE CARDS
                this.createSection('Variables');
        * ========================================================= */


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


             const analysis =
            variablesSection.append(
                 this.analysis ||
                variableContainer
                this.analyzePreload(
            );
                     this.state.preloadContent
 
             const configSection =
                 this.createSection(
                     'Generated configuration'
                 );
                 );


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


             /*
             configSection.append(configPreview);
            * PAGE always comes first.
            */
            const ordered = [];


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


             variables.forEach(name => {
             const save =
                 if (
                 this.createButton(
                     name !== 'PAGE' &&
                     'Save Preload + Config',
                     !ordered.includes(name)
                    'preload-editor-button-primary',
                ) {
                     () => this.saveAll()
                    ordered.push(name);
                 );
                 }
            });


             /*
             const savePreload =
            * Preserve unused config fields after
                this.createButton(
            * actual variables.
                    'Save Preload Only',
            */
                    '',
            Object.keys(
                    () => this.savePreloadOnly()
                 this.state.config.fields || {}
                 );
            ).forEach(name => {
 
                const upper =
            actions.append(save, savePreload);
                    name.toUpperCase();


                if (
            right.append(
                    !ordered.includes(upper)
                analysisSection,
                 ) {
                 variablesSection,
                    ordered.push(upper);
                configSection,
                 }
                 actions
             });
             );


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


                this.state.config.fields[name] =
            this.reanalyze();
                    field;
            this.renderVariables(variableContainer);
            this.renderAnalysis(analysisContainer);
            this.renderConfigPreview(configPreview);


                 const card =
            this.mountCodeMirror(textarea.get(0))
                     this.createVariableCard(
                 .catch(error => {
                         name,
                     console.error(
                        field,
                         'Preload Editor CodeMirror:',
                         analysis
                         error
                     );
                     );


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


         createVariableCard(
         reanalyze() {
            name,
             const source = this.getEditorContent();
            field,
            const content = this.extractIncludeonly(source);
            analysis
            const analysis = this.analyzePreload(content);
        ) {
 
             const card =
            this.state.preloadContent = source;
                $('<div>')
            this.state.variables = analysis.variables;
                    .addClass(
            this.state.conditions = analysis.conditions;
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );


             const header =
             this.state.config =
                 $('<div>')
                 this.prepareConfigForVariables(
                     .addClass(
                     this.state.config ||
                         'preload-editor-variable-header'
                         this.emptyConfig(),
                     );
                     analysis
                );


             const heading =
             this.analysis = analysis;
                $('<h3>')
        },
                    .text(name);


             header.append(heading);
        renderAnalysis(container) {
             container.empty();


             if (name === 'PAGE') {
             const analysis =
                 header.append(
                this.analysis ||
                     $('<span>')
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );
 
            const variableCount =
                analysis.variables.length;
 
            const conditionCount =
                analysis.conditions.length;
 
            const summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );
 
            summary.append(
                $('<span>').text(
                    variableCount +
                    ' variable' +
                    (variableCount === 1 ? '' : 's')
                ),
                $('<span>').text(
                    conditionCount +
                    ' condition marker' +
                    (conditionCount === 1 ? '' : 's')
                )
            );
 
            container.append(summary);
 
            if (analysis.conditionVariables.length) {
                 container.append(
                     $('<div>')
                         .addClass(
                         .addClass(
                             'preload-editor-badge'
                             'preload-editor-analysis-note'
                         )
                         )
                         .text(
                         .text(
                             'AUTOMATIC'
                             'Condition variables: ' +
                            analysis.conditionVariables.join(', ')
                         )
                         )
                 );
                 );
             } else if (
             }
                !analysis.variables.includes(
 
                    name
             const validation =
                )
                 this.validateConfig(
             ) {
                     this.state.config,
                 header.append(
                    analysis
                     $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text(
                            'UNUSED'
                        )
                 );
                 );
            }


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


            /*
                validation.errors.forEach(error => {
            * Existing field object is mutated by
                    $('<li>')
            * the controls below.
                        .text(error)
            */
                        .appendTo(errors);
                });


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


             label.on(
             if (validation.warnings.length) {
                'input.preloadEditor',
                 const warnings =
                 () => {
                     $('<ul>')
                     field.label =
                         .addClass(
                         label.val();
                            'preload-editor-validation-warnings'
                        );


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


            card.append(
                container.append(warnings);
                this.fieldRow(
             }
                    'Label',
                    label
                )
             );


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


             description
        renderAnalysisBySelector() {
                 .addClass(
             const container =
                     'preload-editor-small-textarea'
                this.root &&
                 )
                 this.root.find(
                 .on(
                     '.preload-editor-analysis'
                    'input.preloadEditor',
                 );
                    () => {
 
                        field.description =
            if (container && container.length) {
                            description.val();
                 this.renderAnalysis(container);
            }
        },
 
        renderVariables(container) {
            container.empty();


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


             card.append(
             const variables =
                 this.fieldRow(
                 analysis.variables.slice();
                    'Description',
 
                    description
            const ordered = ['PAGE'];
                )
            );


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


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


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


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


                    /*
                this.state.config.fields[name] = field;
                    * Re-render the whole card so
                    * type-specific controls appear.
                    */
                    const newCard =
                        this.createVariableCard(
                            name,
                            field,
                            analysis
                        );


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


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


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


             /*
        createVariableCard(name, field, analysis) {
            * PAGE has special controls.
             const card =
            */
                $('<div>')
            if (name === 'PAGE') {
                    .addClass(
                field.type = 'page';
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );


                const required =
            const header =
                     this.checkbox(
                $('<div>')
                         field.required !== false
                     .addClass(
                         'preload-editor-variable-header'
                     );
                     );


                required.on(
            header.append(
                    'change.preloadEditor',
                $('<h3>').text(name)
                    () => {
            );
                        field.required =
                            required.prop(
                                'checked'
                            );


                         this.markDirty();
            if (name === 'PAGE') {
                         this.renderConfigPreviewBySelector();
                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);


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


                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(
                card.append(
                         'preload-editor-small-textarea'
                    this.fieldRow(
                        'Namespace',
                        namespace
                    )
                 );
 
                const allowNew =
                     this.checkbox(
                         field['allow-new'] ===
                            true
                     );
                     );


                allowNew.on(
            description.on(
                    'change.preloadEditor',
                'input.preloadEditor',
                    () => {
                () => {
                        field['allow-new'] =
                    field.description = description.val();
                            allowNew.prop(
                    this.markDirty();
                                'checked'
                    this.renderConfigPreviewBySelector();
                            );
                }
            );


                        this.markDirty();
            card.append(
                        this.renderConfigPreviewBySelector();
                this.fieldRow(
                    }
                    'Description',
                 );
                    description
                )
            );
 
            const types = [
                ['text', 'Text'],
                ['textarea', 'Textarea'],
                ['number', 'Number'],
                ['boolean', 'Boolean'],
                ['select', 'Select'],
                ['multiselect', 'Multi-select'],
                ['page', 'Page'],
                ['user', 'User'],
                 ['date', 'Date']
            ];


                 card.append(
            const typeSelect =
                     this.fieldRow(
                 this.select(
                         'Allow new page',
                     types.map(
                         allowNew
                         ([value, label]) => ({
                     )
                            value,
                            label
                         })
                     ),
                    field.type || 'text'
                 );
                 );


                return card;
             typeSelect.on(
             }
 
            /*
            * Required.
            */
            const required =
                this.checkbox(
                    field.required === true
                );
 
            required.on(
                 'change.preloadEditor',
                 'change.preloadEditor',
                 () => {
                 () => {
                     field.required =
                     field.type = typeSelect.val();
                         required.prop(
                    this.markDirty();
                             'checked'
 
                         );
                    card.replaceWith(
                         this.createVariableCard(
                             name,
                            field,
                            analysis
                         )
                    );


                    this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 2,140: Line 1,772:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Required',
                     'Type',
                     required
                     typeSelect
                 )
                 )
             );
             );


             /*
             if (name === 'PAGE') {
            * Placeholder.
                 field.type = 'page';
            */
            const placeholder =
                 this.textInput(
                    field.placeholder || ''
                );


            placeholder.on(
                const required =
                'input.preloadEditor',
                    this.checkbox(
                () => {
                        field.required !== false
                    const value =
                    );
                        placeholder.val();
 
                required.on(
                    'change.preloadEditor',
                    () => {
                        field.required =
                            required.prop('checked');


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


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


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


            /*
                namespace.on(
            * Default.
                    'change.preloadEditor',
            */
                    () => {
            const defaultInput =
                        field.namespace =
                this.textInput(
                            namespace.val();
                    field.default ===
 
                        undefined
                         this.markDirty();
                         ? ''
                         this.renderConfigPreviewBySelector();
                         : field.default
                    }
                 );
                 );


            defaultInput.on(
                card.append(
                'input.preloadEditor',
                    this.fieldRow(
                () => {
                        'Namespace',
                     const value =
                        namespace
                        defaultInput.val();
                     )
                );


                     if (value) {
                const allowNew =
                         field.default =
                     this.checkbox(
                            value;
                         field['allow-new'] === true
                     } else {
                     );
                        delete field.default;
                    }


                    this.markDirty();
                 allowNew.on(
                    this.renderConfigPreviewBySelector();
                     'change.preloadEditor',
                }
            );
 
            card.append(
                this.fieldRow(
                    'Default',
                    defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            );
 
            /*
            * Textarea-specific maxRows.
            */
            if (
                field.type === 'textarea'
            ) {
                const maxRows =
                    this.textInput(
                        field.maxRows ||
                            ''
                    )
                        .attr(
                            'type',
                            'number'
                        );
 
                 maxRows.on(
                     'input.preloadEditor',
                     () => {
                     () => {
                         const value =
                         field['allow-new'] =
                             parseInt(
                             allowNew.prop('checked');
                                maxRows.val(),
                                10
                            );
 
                        if (
                            Number.isFinite(
                                value
                            ) &&
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }


                         this.markDirty();
                         this.markDirty();
Line 2,259: Line 1,866:
                 card.append(
                 card.append(
                     this.fieldRow(
                     this.fieldRow(
                         'Maximum rows',
                         'Allow new page',
                         maxRows
                         allowNew
                     )
                     )
                 );
                 );
            }


            /*
                 return card;
            * Select/multiselect source.
            */
            if (
                 field.type === 'select' ||
                field.type === 'multiselect'
            ) {
                this.renderSelectConfiguration(
                    card,
                    field
                );
             }
             }


            return card;
             const required =
        },
                 this.checkbox(
 
                     field.required === true
        renderSelectConfiguration(
            card,
            field
        ) {
             const source =
                 this.textInput(
                     field.source || '',
                    'category:Example'
                 );
                 );


             source.on(
             required.on(
                 'input.preloadEditor',
                 'change.preloadEditor',
                 () => {
                 () => {
                     const value =
                     field.required =
                         source.val();
                         required.prop('checked');


                    if (value) {
                     this.markDirty();
                        field.source =
                            value;
                    } else {
                        delete field.source;
                    }
 
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 2,311: Line 1,892:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Source',
                     'Required',
                     source,
                     required
                    'Currently supported source format: category:Category Name'
                 )
                 )
             );
             );


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


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


                     if (value) {
                     if (value) {
                         field['source-prefix'] =
                         field.placeholder = value;
                            value;
                     } else {
                     } else {
                         delete field[
                         delete field.placeholder;
                            'source-prefix'
                        ];
                     }
                     }


Line 2,345: Line 1,920:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Source prefix',
                     'Placeholder',
                     sourcePrefix,
                     placeholder
                    'Only source values beginning with this text are included.'
                 )
                 )
             );
             );


             const excludeCategory =
             const defaultInput =
                 this.textInput(
                 this.textInput(
                     field.exclude &&
                     field.default === undefined
                    field.exclude.category
                         ? ''
                         ? field.exclude.category
                         : field.default
                         : ''
                 );
                 );


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


                     if (value) {
                     if (value) {
                         field.exclude =
                         field.default = value;
                            Object.assign(
                     } else {
                                {},
                         delete field.default;
                                field.exclude || {},
                                {
                                    category:
                                        value
                                }
                            );
                     } else if (
                        field.exclude
                    ) {
                         delete field.exclude.category;
 
                        if (
                            !Object.keys(
                                field.exclude
                            ).length
                        ) {
                            delete field.exclude;
                        }
                     }
                     }


Line 2,396: Line 1,950:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Exclude category',
                     'Default',
                     excludeCategory
                     defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                 )
                 )
             );
             );


             /*
             if (field.type === 'textarea') {
            * Value transformation.
                const maxRows =
            */
                    this.textInput(
            card.append(
                        field.maxRows || ''
                this.createTransformControls(
                     ).attr(
                     field,
                        'type',
                    'value',
                        'number'
                    'Value transformation'
                    );
                )
            );


            /*
                 maxRows.on(
            * Label transformation.
                     'input.preloadEditor',
            */
                     () => {
            card.append(
                        const value =
                 this.createTransformControls(
                            parseInt(
                    field,
                                maxRows.val(),
                     'label',
                                10
                     'Label transformation'
                            );
                )
            );


            /*
                        if (
            * Manual options.
                            Number.isFinite(value) &&
            */
                            value > 0
            const optionsValue =
                        ) {
                Array.isArray(
                            field.maxRows = value;
                    field.options
                         } else {
                )
                             delete field.maxRows;
                    ? field.options
                        }
                         .map(option => {
                             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(
                    this.fieldRow(
                     optionsValue
                        'Maximum rows',
                 )
                        maxRows
                    .addClass(
                    )
                        'preload-editor-small-textarea'
                );
                     );
             }
 
            if (
                field.type === 'select' ||
                field.type === 'multiselect'
            ) {
                 this.renderSelectConfiguration(
                     card,
                    field
                 );
            }
 
            return card;
        },
 
        renderSelectConfiguration(card, field) {
            const source =
                this.textInput(
                    field.source || '',
                     'category:Example'
                );


             options.on(
             source.on(
                 'input.preloadEditor',
                 'input.preloadEditor',
                 () => {
                 () => {
                     const lines =
                     const value = source.val();
                        options.val()
                            .split('\n')
                            .map(
                                line =>
                                    line.trim()
                            )
                            .filter(Boolean);


                     if (!lines.length) {
                     if (value) {
                         delete field.options;
                         field.source = value;
                     } else {
                     } else {
                         field.options =
                         delete field.source;
                            lines.map(line => {
                                const parts =
                                    line.split(
                                        /\s*\|\s*/
                                    );
 
                                if (
                                    parts.length ===
                                    1
                                ) {
                                    return parts[0];
                                }
 
                                return {
                                    value:
                                        parts[0],
                                    label:
                                        parts
                                            .slice(1)
                                            .join(
                                                ' | '
                                            )
                                };
                            });
                     }
                     }


Line 2,512: Line 2,034:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Manual options',
                     'Source',
                     options,
                     source,
                     'One per line. Optional label format: value | label. Leave empty when using a source.'
                     'Currently supported source format: category:Category Name'
                 )
                 )
             );
             );
        },


        createTransformControls(
             const sourcePrefix =
            field,
                 this.textInput(
            property,
                     field['source-prefix'] || ''
            title
                );
        ) {
             const wrapper =
                 $('<div>')
                     .addClass(
                        'preload-editor-transform'
                    );


             $('<h4>')
             sourcePrefix.on(
                .text(title)
                'input.preloadEditor',
                .appendTo(wrapper);
                () => {
                    const value =
                        sourcePrefix.val();
 
                    if (value) {
                        field['source-prefix'] = value;
                    } else {
                        delete field['source-prefix'];
                    }


            const existing =
                     this.markDirty();
                field[property] &&
                     this.renderConfigPreviewBySelector();
                typeof field[property] ===
                }
                     'object'
            );
                     ? field[property]
                    : {};


             const transform =
             card.append(
                 this.select(
                 this.fieldRow(
                     [
                     'Source prefix',
                        {
                     sourcePrefix,
                            value: '',
                     'Only source values beginning with this text are included.'
                            label: 'No transformation'
                 )
                        },
            );
                        {
                            value: 'remove-prefix',
                            label: 'Remove prefix'
                        },
                        {
                            value: 'remove-suffix',
                            label: 'Remove suffix'
                        },
                        {
                            value: 'replace',
                            label: 'Replace'
                        }
                     ],
                     existing.transform || ''
                 );


             const prefix =
             const excludeCategory =
                 this.textInput(
                 this.textInput(
                     existing.prefix || ''
                     field.exclude &&
                    field.exclude.category
                        ? field.exclude.category
                        : ''
                 );
                 );


             const suffix =
             excludeCategory.on(
                 this.textInput(
                 'input.preloadEditor',
                     existing.suffix || ''
                () => {
                );
                     const value =
                        excludeCategory.val();


            const from =
                    if (value) {
                this.textInput(
                        field.exclude =
                    existing.from || ''
                            Object.assign(
                );
                                {},
 
                                field.exclude || {},
            const to =
                                { category: value }
                this.textInput(
                            );
                    existing.to || ''
                    } else if (field.exclude) {
                );
                         delete field.exclude.category;
 
            const update =
                () => {
                    const type =
                         transform.val();
 
                    let object = null;


                    if (
                        type ===
                        'remove-prefix'
                    ) {
                         if (
                         if (
                             prefix.val()
                             !Object.keys(
                                field.exclude
                            ).length
                         ) {
                         ) {
                             object = {
                             delete field.exclude;
                                transform:
                                    type,
                                prefix:
                                    prefix.val()
                            };
                         }
                         }
                    } else if (
                     }
                        type ===
 
                        'remove-suffix'
                     this.markDirty();
                    ) {
                        if (
                            suffix.val()
                        ) {
                            object = {
                                transform:
                                    type,
                                suffix:
                                    suffix.val()
                            };
                        }
                    } else if (
                        type === 'replace'
                    ) {
                        if (
                            from.val()
                        ) {
                            object = {
                                transform:
                                    type,
                                from:
                                    from.val(),
                                to:
                                    to.val()
                            };
                        }
                    }
 
                    if (object) {
                        field[property] =
                            object;
                    } else {
                        delete field[property];
                     }
 
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 };
                 }
            );


             transform.on(
             card.append(
                 'change.preloadEditor',
                 this.fieldRow(
                () => {
                     'Exclude category',
                     update();
                     excludeCategory
                     renderInputs();
                 )
                 }
             );
             );


             [
             card.append(
                prefix,
                 this.createTransformControls(
                suffix,
                     field,
                from,
                     'value',
                to
                     'Value transformation'
            ].forEach(input => {
                ),
                 input.on(
                 this.createTransformControls(
                     'input.preloadEditor',
                    field,
                     update
                     'label',
                );
                     'Label transformation'
            });
 
            const controls =
                $('<div>')
                     .addClass(
                        'preload-editor-transform-controls'
                    );
 
            controls.append(
                 this.fieldRow(
                     'Transformation',
                     transform
                 )
                 )
             );
             );


             const prefixRow =
             const optionsValue =
                 this.fieldRow(
                 Array.isArray(field.options)
                     'Prefix',
                     ? field.options
                    prefix
                        .map(option => {
                );
                            if (
                                typeof option === 'string'
                            ) {
                                return option;
                            }


            const suffixRow =
                            return (
                this.fieldRow(
                                String(
                    'Suffix',
                                    option.value || ''
                    suffix
                                ) +
                );
                                ' | ' +
                                String(
                                    option.label ||
                                    option.value ||
                                    ''
                                )
                            );
                        })
                        .join('\n')
                    : '';


             const fromRow =
             const options =
                 this.fieldRow(
                 this.textarea(optionsValue)
                     'From',
                     .addClass(
                     from
                        'preload-editor-small-textarea'
                );
                     );


             const toRow =
             options.on(
                this.fieldRow(
                'input.preloadEditor',
                    'To',
                    to
                );
 
            controls.append(
                prefixRow,
                suffixRow,
                fromRow,
                toRow
            );
 
            const renderInputs =
                 () => {
                 () => {
                     const type =
                     const lines =
                         transform.val();
                         options.val()
                            .split('\n')
                            .map(line => line.trim())
                            .filter(Boolean);


                     prefixRow.toggle(
                     if (!lines.length) {
                         type ===
                        delete field.options;
                            'remove-prefix'
                    } else {
                    );
                         field.options =
                            lines.map(line => {
                                const parts =
                                    line.split(
                                        /\s*\|\s*/
                                    );


                    suffixRow.toggle(
                                if (parts.length === 1) {
                        type ===
                                    return parts[0];
                            'remove-suffix'
                                }
                    );


                    fromRow.toggle(
                                return {
                        type === 'replace'
                                    value: parts[0],
                    );
                                    label:
                                        parts
                                            .slice(1)
                                            .join(' | ')
                                };
                            });
                    }


                     toRow.toggle(
                     this.markDirty();
                        type === 'replace'
                     this.renderConfigPreviewBySelector();
                     );
                 }
                 };
            );


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


         /* =========================================================
         createTransformControls(field, property, title) {
        * CONFIG OUTPUT
            const wrapper =
        * ========================================================= */
                $('<div>')
                    .addClass(
                        'preload-editor-transform'
                    );


        getCleanConfig() {
            $('<h4>')
            const config =
                 .text(title)
                 JSON.parse(
                .appendTo(wrapper);
                    JSON.stringify(
                        this.state.config
                    )
                );


             /*
             const existing =
            * Keep PAGE first.
                field[property] &&
            */
                typeof field[property] === 'object'
            const fields =
                    ? field[property]
                config.fields || {};
                    : {};


             const orderedFields = {};
             const transform =
 
                this.select(
            if (fields.PAGE) {
                    [
                orderedFields.PAGE =
                        {
                    fields.PAGE;
                            value: '',
            }
                            label: 'No transformation'
 
                        },
            Object.keys(fields)
                        {
                .filter(
                            value: 'remove-prefix',
                     name =>
                            label: 'Remove prefix'
                        name.toUpperCase() !==
                        },
                        'PAGE'
                        {
                 )
                            value: 'remove-suffix',
                 .forEach(name => {
                            label: 'Remove suffix'
                     orderedFields[name] =
                        },
                        fields[name];
                        {
                 });
                            value: 'replace',
                            label: 'Replace'
                        }
                     ],
                    existing.transform || ''
                 );
 
            const prefix =
                 this.textInput(
                     existing.prefix || ''
                 );


             config.fields =
             const suffix =
                 orderedFields;
                this.textInput(
                    existing.suffix || ''
                 );


             return config;
             const from =
        },
                this.textInput(
                    existing.from || ''
                );


        getConfigJSON() {
             const to =
             return JSON.stringify(
                 this.textInput(
                 this.getCleanConfig(),
                    existing.to || ''
                null,
                 );
                 4
            );
        },


        renderConfigPreview(container) {
            const controls =
            if (!container) {
                $('<div>')
                return;
                    .addClass(
            }
                        'preload-editor-transform-controls'
                    );


            container.text(
             const prefixRow =
                this.getConfigJSON()
                 this.fieldRow(
            );
                     'Prefix',
        },
                    prefix
 
        renderConfigPreviewBySelector() {
             const preview =
                 this.root &&
                this.root.find(
                     '.preload-editor-config-preview'
                 );
                 );


             if (preview && preview.length) {
             const suffixRow =
                 this.renderConfigPreview(
                 this.fieldRow(
                     preview
                     'Suffix',
                    suffix
                 );
                 );
            }


             const analysis =
             const fromRow =
                 this.root &&
                 this.fieldRow(
                this.root.find(
                     'From',
                     '.preload-editor-analysis'
                    from
                 );
                 );


             if (
             const toRow =
                analysis &&
                 this.fieldRow(
                analysis.length
                     'To',
            ) {
                    to
                 this.renderAnalysis(
                     analysis
                 );
                 );
            }
        },


        markDirty() {
            const renderInputs = () => {
            this.state.dirty = true;
                const type = transform.val();
        },


        /* =========================================================
                prefixRow.toggle(
        * SAVE
                    type === 'remove-prefix'
        * ========================================================= */
                );


        async savePreloadOnly() {
                 suffixRow.toggle(
            let title =
                     type === 'remove-suffix'
                 String(
                 );
                     this.state.preloadTitle ||
                    ''
                 ).trim();


            if (!title) {
                 fromRow.toggle(
                 const result =
                    type === 'replace'
                    await this.createPromptDialog(
                );
                        'Preload title',
                        'Enter the full title for the preload.'
                    );


                 if (!result) {
                 toRow.toggle(
                     return;
                     type === 'replace'
                }
                );
            };


                 title = result;
            const update = () => {
                const type = transform.val();
                 let object = null;


                 this.state.preloadTitle =
                 if (type === 'remove-prefix') {
                     title;
                    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()
                        };
                    }
                }


            const validation =
                if (object) {
                 this.validateBeforeSave();
                    field[property] = object;
                 } else {
                    delete field[property];
                }


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


                 return;
            transform.on(
             }
                'change.preloadEditor',
                 () => {
                    update();
                    renderInputs();
                }
             );


             this.setStatus(
             [
                 'Saving preload...',
                prefix,
                 'loading'
                suffix,
                from,
                to
            ].forEach(input => {
                 input.on(
                    'input.preloadEditor',
                    update
                );
            });
 
            controls.append(
                this.fieldRow(
                    'Transformation',
                    transform
                ),
                 prefixRow,
                suffixRow,
                fromRow,
                toRow
             );
             );


             try {
             renderInputs();
                const exists =
                    await this.pageExists(
                        title
                    );


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


                this.state.mode = 'edit';
        // Config output
                this.state.dirty = false;


                 this.setStatus(
        getCleanConfig() {
                     'Preload saved successfully.',
            const config =
                     'success'
                 JSON.parse(
                     JSON.stringify(
                        this.state.config
                     )
                 );
                 );


                mw.notify(
            const fields = config.fields || {};
                    'Preload saved.',
            const orderedFields = {};
                    {
 
                        type: 'success'
             if (fields.PAGE) {
                    }
                 orderedFields.PAGE = fields.PAGE;
                );
            }
             } catch (error) {
                 console.error(
                    'Preload Editor save:',
                    error
                );


                 this.setStatus(
            Object.keys(fields)
                     error.message ||
                 .filter(
                         'Could not save preload.',
                     name =>
                     'error'
                         name.toUpperCase() !== 'PAGE'
                 );
                )
                .forEach(name => {
                     orderedFields[name] = fields[name];
                 });


                mw.notify(
            config.fields = orderedFields;
                    error.message ||
             return config;
                        'Could not save preload.',
                    {
                        type: 'error'
                    }
                );
             }
         },
         },


         async saveAll() {
         getConfigJSON() {
             let title =
             return JSON.stringify(
                 String(
                 this.getCleanConfig(),
                    this.state.preloadTitle ||
                null,
                    ''
                 4
                 ).trim();
            );
        },


             if (!title) {
        renderConfigPreview(container) {
            if (!container) {
                return;
            }
 
            container.text(
                this.getConfigJSON()
            );
        },
 
        renderConfigPreviewBySelector() {
            const preview =
                this.root &&
                this.root.find(
                    '.preload-editor-config-preview'
                );
 
            if (preview && preview.length) {
                this.renderConfigPreview(preview);
            }
 
            this.renderAnalysisBySelector();
        },
 
        markDirty() {
            this.state.dirty = true;
        },
 
        // Save
 
        async savePreloadOnly() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();
 
             if (!title) {
                 const result =
                 const result =
                     await this.createPromptDialog(
                     await this.createPromptDialog(
Line 2,953: Line 2,477:


                 title = result;
                 title = result;
                this.state.preloadTitle = title;
            }


                this.state.preloadTitle =
            this.state.preloadContent =
                    title;
                this.getEditorContent();
            }


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


             if (
             if (validation.errors.length) {
                validation.errors.length
                 this.showValidationErrors(validation);
            ) {
                 this.showValidationErrors(
                    validation
                );
 
                 return;
                 return;
             }
             }
            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';


             this.setStatus(
             this.setStatus(
                 'Saving preload and config...',
                 'Saving preload...',
                 'loading'
                 'loading'
             );
             );


             try {
             try {
                 const preloadExists =
                 const exists =
                     await this.pageExists(
                     await this.pageExists(title);
                        title
                    );


                 await this.savePage(
                 await this.savePage(
                     title,
                     title,
                     this.state.preloadContent,
                     this.state.preloadContent,
                     preloadExists
                     exists
                         ? 'Updated preload with Preload Editor'
                         ? 'Updated preload with Preload Editor'
                         : 'Created preload with Preload Editor',
                         : 'Created preload with Preload Editor',
                     !preloadExists
                     !exists
                );
 
                const configExists =
                    await this.pageExists(
                        configTitle
                    );
 
                await this.savePage(
                    configTitle,
                    this.getConfigJSON(),
                    configExists
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor',
                    !configExists
                 );
                 );


                 this.state.mode = 'edit';
                 this.state.mode = 'edit';
                 this.state.dirty = false;
                 this.state.dirty = false;
                this.state.preloadTitle =
                    title;


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


                 mw.notify(
                 mw.notify(
                     'Preload and config saved.',
                     'Preload saved.',
                     {
                     { type: 'success' }
                        type: 'success'
                    }
                 );
                 );
             } catch (error) {
             } catch (error) {
Line 3,036: Line 2,529:
                 this.setStatus(
                 this.setStatus(
                     error.message ||
                     error.message ||
                         'Could not save preload/config.',
                         'Could not save preload.',
                     'error'
                     'error'
                 );
                 );
Line 3,042: Line 2,535:
                 mw.notify(
                 mw.notify(
                     error.message ||
                     error.message ||
                         'Could not save preload/config.',
                         'Could not save preload.',
                     {
                     { type: 'error' }
                        type: 'error'
                    }
                 );
                 );
             }
             }
         },
         },


         validateBeforeSave() {
         async saveAll() {
             const errors = [];
             let title =
            const warnings = [];
 
            const title =
                 String(
                 String(
                     this.state.preloadTitle ||
                     this.state.preloadTitle || ''
                    ''
                 ).trim();
                 ).trim();


             if (!title) {
             if (!title) {
                 errors.push(
                 const result =
                    'A preload title is required.'
                    await this.createPromptDialog(
                 );
                        'Preload title',
                        'Enter the full title for the preload.'
                    );
 
                 if (!result) {
                    return;
                }
 
                title = result;
                this.state.preloadTitle = title;
             }
             }


             if (
             this.state.preloadContent =
                title &&
                this.getEditorContent();
                 !/\/Preload$/i.test(title)
 
             ) {
            const validation =
                 warnings.push(
                 this.validateBeforeSave();
                    'The title does not end with "/Preload".'
 
                 );
             if (validation.errors.length) {
                 this.showValidationErrors(validation);
                 return;
             }
             }


             const content =
             const configTitle =
                 String(
                 title.replace(/\/+$/, '') +
                    this.state.preloadContent ||
                 '/config.json';
                    ''
                 );


             if (!content.trim()) {
             this.setStatus(
                 errors.push(
                 'Saving preload and config...',
                    'The preload cannot be empty.'
                 'loading'
                 );
            );
            }


             if (
             try {
                 !/<includeonly>/i.test(
                 const preloadExists =
                     content
                     await this.pageExists(title);
                )
            ) {
                warnings.push(
                    'The preload does not contain an <includeonly> section.'
                );
            }


            if (
                 await this.savePage(
                 !/<noinclude>/i.test(
                     title,
                     content
                    this.state.preloadContent,
                )
                    preloadExists
            ) {
                        ? 'Updated preload with Preload Editor'
                warnings.push(
                        : 'Created preload with Preload Editor',
                    'The preload does not contain a <noinclude> section.'
                    !preloadExists
                 );
                 );
            }


            const analysis =
                const configExists =
                 this.analyzePreload(
                    await this.pageExists(configTitle);
                     content
 
                 await this.savePage(
                    configTitle,
                    this.getConfigJSON(),
                    configExists
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor',
                     !configExists
                 );
                 );


            const configValidation =
                this.state.mode = 'edit';
                 this.validateConfig(
                this.state.dirty = false;
                     this.state.config,
                this.state.preloadTitle = title;
                     analysis
 
                 this.setStatus(
                     'Preload and config saved successfully.',
                    'success'
                );
 
                mw.notify(
                    'Preload and config saved.',
                    { type: 'success' }
                );
            } catch (error) {
                console.error(
                    'Preload Editor save:',
                    error
                );
 
                this.setStatus(
                    error.message ||
                        'Could not save preload/config.',
                    'error'
                );
 
                mw.notify(
                    error.message ||
                        'Could not save preload/config.',
                     { type: 'error' }
                 );
                 );
 
            }
             errors.push(
        },
                 ...configValidation.errors
 
             );
        validateBeforeSave() {
 
            const errors = [];
             warnings.push(
            const warnings = [];
                 ...configValidation.warnings
 
             );
            const title =
 
                String(
             /*
                    this.state.preloadTitle || ''
            * Basic conditional marker validation.
                ).trim();
            */
 
             let depth = 0;
             if (!title) {
 
                errors.push(
             analysis.conditions.forEach(
                    'A preload title is required.'
                condition => {
                );
                    if (
            }
                        condition.type ===
 
                        'if'
            if (
                    ) {
                title &&
                        depth++;
                 !/\/Preload$/i.test(title)
                    } else if (
            ) {
                        condition.type ===
                warnings.push(
                        'endif'
                    'The title does not end with "/Preload".'
                    ) {
                );
                        depth--;
            }
 
 
                        if (depth < 0) {
            const content =
                            errors.push(
                this.getEditorContent();
                                'Found @endif without a matching @if.'
 
                            );
            this.state.preloadContent = content;
 
 
                            depth = 0;
            if (!content.trim()) {
                        }
                errors.push(
                    } else if (
                    'The preload cannot be empty.'
                        condition.type ===
                );
                        'elseif' ||
            }
                        condition.type ===
 
                        'else'
             if (!/<includeonly>/i.test(content)) {
                    ) {
                warnings.push(
                        if (depth === 0) {
                    'The preload does not contain an <includeonly> section.'
                            errors.push(
                );
                                '@' +
            }
                                condition.type +
 
                                ' exists outside an @if block.'
             if (!/<noinclude>/i.test(content)) {
                            );
                warnings.push(
                        }
                    'The preload does not contain a <noinclude> section.'
                     }
                );
                 }
            }
             );
 
            const analysis =
                this.analyzePreload(
                    this.extractIncludeonly(content)
                );
 
            const configValidation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );
 
            errors.push(
                 ...configValidation.errors
             );
 
             warnings.push(
                ...configValidation.warnings
            );
 
             let depth = 0;
 
             analysis.conditions.forEach(condition => {
                if (condition.type === 'if') {
                    depth++;
                } else if (
                    condition.type === 'endif'
                ) {
                    depth--;
 
                    if (depth < 0) {
                        errors.push(
                            'Found @endif without a matching @if.'
                        );
 
                        depth = 0;
                    }
                } else if (
                    condition.type === 'elseif' ||
                    condition.type === 'else'
                ) {
                    if (depth === 0) {
                        errors.push(
                            '@' +
                            condition.type +
                            ' exists outside an @if block.'
                        );
                     }
                 }
             });


             if (depth > 0) {
             if (depth > 0) {
Line 3,175: Line 2,744:


             return {
             return {
                 errors: errors,
                 errors,
                 warnings: warnings,
                 warnings,
                 valid:
                 valid: errors.length === 0
                    errors.length === 0
             };
             };
         },
         },


         showValidationErrors(validation) {
         showValidationErrors(validation) {
             this.renderAnalysis(
             this.renderAnalysisBySelector();
                this.root.find(
                    '.preload-editor-analysis'
                )
            );


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


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


    /*
     mw.PreloadEditor = PreloadEditor;
    * Expose globally.
    */
     mw.PreloadEditor =
        PreloadEditor;


    /*
    * Initialise only on the project page.
    */
     $(function () {
     $(function () {
         const title =
         const title =
             mw.config.get(
             mw.config.get('wgPageName');
                'wgPageName'
            );


         if (
         if (

Revision as of 07:37, 21 September 2026

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

    const PreloadEditor = {
        version: '0.2.0',

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

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

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

        // API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        // Preload parsing

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

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

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

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

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

            return variables;
        },

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

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

            return conditions;
        },

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

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

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

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

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

            return variables;
        },

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

            return {
                variables,
                conditions,
                conditionVariables
            };
        },

        // Config

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

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

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

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

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

            return config;
        },

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

            return {
                label: this.humanizeName(name),
                description: '',
                type: isConditionVariable ? 'boolean' : 'text'
            };
        },

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

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

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

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

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

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

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

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

            config.fields = fields;
            return config;
        },

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

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

            if (
                !config.fields ||
                typeof config.fields !== 'object' ||
                Array.isArray(config.fields)
            ) {
                errors.push('"fields" must be an object.');

                return {
                    valid: false,
                    errors,
                    warnings
                };
            }

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

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

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

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

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

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

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

            return {
                valid: errors.length === 0,
                errors,
                warnings
            };
        },

        // Transformation helpers

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

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

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

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

                default:
                    return null;
            }
        },

        // UI helpers

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

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

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

            return section;
        },

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

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

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

            return button;
        },

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

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

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

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

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

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

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

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

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

            return select;
        },

        // CodeMirror

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

            const {
                view,
                inputHandler
            } = this.editor;

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

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

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

        getEditorView() {
            return this.editorView;
        },

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

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

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

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

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

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

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

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

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

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

                    settled = true;
                    cleanup();

                    const view = cm.view;

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

                        this.state.dirty = true;
                        this.reanalyze();

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

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

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

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

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

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

                    this.editorView = view;

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

                    resolve(view);
                };

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

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

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

        // Main UI

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

            if (!root.length) {
                return;
            }

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

            root.append(this.root);
            this.renderShell();
        },

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

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

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

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

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

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

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

            loader.append(loadRow);
            this.root.append(loader);

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

            this.root.append(this.status);

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

            this.root.append(this.workspace);
            this.renderWelcome();
        },

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

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

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

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

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

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

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

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

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

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

        // Load / create

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

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

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

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

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

                    this.renderMissingPage(
                        title,
                        suggestions
                    );

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

                    return;
                }

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

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

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

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

            if (!query) {
                query = title;
            }

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

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

                return [];
            }
        },

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

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

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

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

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

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

                section.append(list);
            }

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

            this.workspace.append(section);
        },

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

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

            this.renderEditor();

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

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

            if (!result) {
                return;
            }

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

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

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

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

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

                this.reanalyze();
                this.renderEditor();

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

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

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

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

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

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

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

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

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

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

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

                const input = this.textInput();

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

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

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

                            if (!value) {
                                return;
                            }

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

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

                input.trigger('focus');

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

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

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

            let config = this.emptyConfig();

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

            const configPage =
                await this.getPage(configTitle);

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

            const analysis =
                this.analyzePreload(content);

            config =
                this.prepareConfigForVariables(
                    config,
                    analysis
                );

            this.state = {
                preloadTitle: title,
                preloadSource: source,
                preloadContent: source,
                config,
                variables: analysis.variables,
                conditions: analysis.conditions,
                mode: 'edit',
                dirty: false
            };

            this.renderEditor();
        },

        // Editor

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

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

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

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

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

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

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

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

            editorSection.append(meta);

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

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

            editorSection.append(
                editorMount
            );

            left.append(editorSection);

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

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

            analysisSection.append(
                analysisContainer
            );

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

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

            variablesSection.append(
                variableContainer
            );

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

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

            configSection.append(configPreview);

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

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

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

            actions.append(save, savePreload);

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

            layout.append(left, right);
            this.workspace.append(layout);

            this.reanalyze();
            this.renderVariables(variableContainer);
            this.renderAnalysis(analysisContainer);
            this.renderConfigPreview(configPreview);

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

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

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

            this.state.preloadContent = source;
            this.state.variables = analysis.variables;
            this.state.conditions = analysis.conditions;

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

            this.analysis = analysis;
        },

        renderAnalysis(container) {
            container.empty();

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

            const variableCount =
                analysis.variables.length;

            const conditionCount =
                analysis.conditions.length;

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

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

            container.append(summary);

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

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

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

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

                container.append(errors);
            }

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

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

                container.append(warnings);
            }

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

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

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

        renderVariables(container) {
            container.empty();

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

            const variables =
                analysis.variables.slice();

            const ordered = ['PAGE'];

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

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

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

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

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

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

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

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

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

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

            header.append(
                $('<h3>').text(name)
            );

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

            card.append(header);

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

            label.on(
                'input.preloadEditor',
                () => {
                    field.label = label.val();
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                }
            );

            card.append(
                this.fieldRow('Label', label)
            );

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

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

            card.append(
                this.fieldRow(
                    'Description',
                    description
                )
            );

            const types = [
                ['text', 'Text'],
                ['textarea', 'Textarea'],
                ['number', 'Number'],
                ['boolean', 'Boolean'],
                ['select', 'Select'],
                ['multiselect', 'Multi-select'],
                ['page', 'Page'],
                ['user', 'User'],
                ['date', 'Date']
            ];

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

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

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

                    this.renderConfigPreviewBySelector();
                }
            );

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                return card;
            }

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

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

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

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

            const placeholder =
                this.textInput(
                    field.placeholder || ''
                );

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

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

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

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

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

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

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

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

            card.append(
                this.fieldRow(
                    'Default',
                    defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            );

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

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

                        if (
                            Number.isFinite(value) &&
                            value > 0
                        ) {
                            field.maxRows = value;
                        } else {
                            delete field.maxRows;
                        }

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

                card.append(
                    this.fieldRow(
                        'Maximum rows',
                        maxRows
                    )
                );
            }

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

            return card;
        },

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

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

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

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

            card.append(
                this.fieldRow(
                    'Source',
                    source,
                    'Currently supported source format: category:Category Name'
                )
            );

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

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

                    if (value) {
                        field['source-prefix'] = value;
                    } else {
                        delete field['source-prefix'];
                    }

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

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

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

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

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

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

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

            card.append(
                this.fieldRow(
                    'Exclude category',
                    excludeCategory
                )
            );

            card.append(
                this.createTransformControls(
                    field,
                    'value',
                    'Value transformation'
                ),
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );

            const optionsValue =
                Array.isArray(field.options)
                    ? field.options
                        .map(option => {
                            if (
                                typeof option === 'string'
                            ) {
                                return option;
                            }

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

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

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

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

                                if (parts.length === 1) {
                                    return parts[0];
                                }

                                return {
                                    value: parts[0],
                                    label:
                                        parts
                                            .slice(1)
                                            .join(' | ')
                                };
                            });
                    }

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

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

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

            $('<h4>')
                .text(title)
                .appendTo(wrapper);

            const existing =
                field[property] &&
                typeof field[property] === 'object'
                    ? field[property]
                    : {};

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

            const prefix =
                this.textInput(
                    existing.prefix || ''
                );

            const suffix =
                this.textInput(
                    existing.suffix || ''
                );

            const from =
                this.textInput(
                    existing.from || ''
                );

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

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

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

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

            const fromRow =
                this.fieldRow(
                    'From',
                    from
                );

            const toRow =
                this.fieldRow(
                    'To',
                    to
                );

            const renderInputs = () => {
                const type = transform.val();

                prefixRow.toggle(
                    type === 'remove-prefix'
                );

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

                fromRow.toggle(
                    type === 'replace'
                );

                toRow.toggle(
                    type === 'replace'
                );
            };

            const update = () => {
                const type = transform.val();
                let object = null;

                if (type === 'remove-prefix') {
                    if (prefix.val()) {
                        object = {
                            transform: type,
                            prefix: prefix.val()
                        };
                    }
                } else if (type === 'remove-suffix') {
                    if (suffix.val()) {
                        object = {
                            transform: type,
                            suffix: suffix.val()
                        };
                    }
                } else if (type === 'replace') {
                    if (from.val()) {
                        object = {
                            transform: type,
                            from: from.val(),
                            to: to.val()
                        };
                    }
                }

                if (object) {
                    field[property] = object;
                } else {
                    delete field[property];
                }

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

            transform.on(
                'change.preloadEditor',
                () => {
                    update();
                    renderInputs();
                }
            );

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

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

            renderInputs();

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

        // Config output

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

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

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

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

            config.fields = orderedFields;
            return config;
        },

        getConfigJSON() {
            return JSON.stringify(
                this.getCleanConfig(),
                null,
                4
            );
        },

        renderConfigPreview(container) {
            if (!container) {
                return;
            }

            container.text(
                this.getConfigJSON()
            );
        },

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

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

            this.renderAnalysisBySelector();
        },

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

        // Save

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

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

                if (!result) {
                    return;
                }

                title = result;
                this.state.preloadTitle = title;
            }

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

            const validation =
                this.validateBeforeSave();

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

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

            try {
                const exists =
                    await this.pageExists(title);

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

                this.state.mode = 'edit';
                this.state.dirty = false;

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

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

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

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

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

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

                if (!result) {
                    return;
                }

                title = result;
                this.state.preloadTitle = title;
            }

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

            const validation =
                this.validateBeforeSave();

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

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

            this.setStatus(
                'Saving preload and config...',
                'loading'
            );

            try {
                const preloadExists =
                    await this.pageExists(title);

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

                const configExists =
                    await this.pageExists(configTitle);

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

                this.state.mode = 'edit';
                this.state.dirty = false;
                this.state.preloadTitle = title;

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

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

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

                mw.notify(
                    error.message ||
                        'Could not save preload/config.',
                    { type: 'error' }
                );
            }
        },

        validateBeforeSave() {
            const errors = [];
            const warnings = [];

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

            if (!title) {
                errors.push(
                    'A preload title is required.'
                );
            }

            if (
                title &&
                !/\/Preload$/i.test(title)
            ) {
                warnings.push(
                    'The title does not end with "/Preload".'
                );
            }

            const content =
                this.getEditorContent();

            this.state.preloadContent = content;

            if (!content.trim()) {
                errors.push(
                    'The preload cannot be empty.'
                );
            }

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

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

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

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

            errors.push(
                ...configValidation.errors
            );

            warnings.push(
                ...configValidation.warnings
            );

            let depth = 0;

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

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

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

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

            return {
                errors,
                warnings,
                valid: errors.length === 0
            };
        },

        showValidationErrors(validation) {
            this.renderAnalysisBySelector();

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

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

    mw.PreloadEditor = PreloadEditor;

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

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

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));