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

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

MediaWiki interface page
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
Line 1: Line 1:
(function (mw, $) {
(function (mw, $) {
     'use strict';
     'use strict';


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


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


Line 36: Line 32:


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


             if (!page || page.missing) {
            const page = response?.query?.pages?.[0];
                return null;
             if (!page || page.missing) return null;
            }


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


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


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


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


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


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


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


Line 131: Line 87:


         extractIncludeonly(source) {
         extractIncludeonly(source) {
             const match =
             const match = String(source || '').match(
                String(source || '').match(
                /<includeonly>([\s\S]*?)<\/includeonly>/i
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
            );
                );
             return match ? match[1].trim() : String(source || '').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();
            ) {
                 if (!variables.includes(name)) variables.push(name);
                 const name =
                    match[1].toUpperCase();
 
                 if (!variables.includes(name)) {
                    variables.push(name);
                }
             }
             }


Line 166: Line 110:
             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 187: Line 125:
         findConditionVariables(content) {
         findConditionVariables(content) {
             const variables = [];
             const variables = [];
             const conditions =
 
                this.findConditions(content);
             this.findConditions(content).forEach(condition => {
 
                 if (condition.type !== 'if' &&
            conditions.forEach(condition => {
                     condition.type !== 'elseif') return;
                 if (
                    condition.type !== 'if' &&
                     condition.type !== 'elseif'
                ) {
                    return;
                }


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


                 matches.forEach(name => {
                 matches.forEach(name => {
                     const upper =
                     name = name.toUpperCase();
                        name.toUpperCase();
                     if (!variables.includes(name)) variables.push(name);
 
                     if (
                        !variables.includes(upper)
                    ) {
                        variables.push(upper);
                    }
                 });
                 });
             });
             });
Line 219: Line 143:


         analyzePreload(content) {
         analyzePreload(content) {
            const variables =
                this.findVariables(content);
            const conditions =
                this.findConditions(content);
            const conditionVariables =
                this.findConditionVariables(
                    content
                );
             return {
             return {
                 variables,
                 variables: this.findVariables(content),
                 conditions,
                 conditions: this.findConditions(content),
                 conditionVariables
                 conditionVariables: this.findConditionVariables(content)
             };
             };
         },
         },
Line 241: Line 154:
         emptyConfig() {
         emptyConfig() {
             return {
             return {
                 settings: {
                 settings: { incomplete: 'add-stub' },
                    incomplete: 'add-stub'
                },
                 fields: {
                 fields: {
                     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 259: Line 169:


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


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


             if (
             if (!config.fields ||
                !config.fields ||
                 typeof config.fields !== 'object' ||
                 typeof config.fields !== 'object' ||
                 Array.isArray(config.fields)
                 Array.isArray(config.fields)) {
            ) {
                 config.fields = {};
                 config.fields = {};
             }
             }
Line 286: Line 190:
                 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 299: Line 202:


         createDefaultField(name, analysis) {
         createDefaultField(name, analysis) {
            const isConditionVariable =
                analysis.conditionVariables.includes(
                    name
                );
             return {
             return {
                 label:
                 label: this.humanizeName(name),
                    this.humanizeName(name),
                 description: '',
                 description: '',
                 type:
                 type: analysis.conditionVariables.includes(name)
                     isConditionVariable
                     ? 'boolean'
                        ? 'boolean'
                    : 'text'
                        : 'text'
             };
             };
         },
         },
Line 318: Line 214:
             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 =
             const existing = config.fields || {};
                config.fields || {};
            const fields = {};


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


            analysis.variables.forEach(name => {
                if (name === 'PAGE') return;
                 fields[name] =
                 fields[name] =
                     existing[name] ||
                     existing[name] ||
                     this.createDefaultField(
                     this.createDefaultField(name, analysis);
                        name,
                        analysis
                    );
             });
             });


             Object.keys(existing).forEach(
             Object.keys(existing).forEach(name => {
                name => {
                const upper = name.toUpperCase();
                    const upper =
                if (!fields[upper]) fields[upper] = existing[name];
                        name.toUpperCase();
            });
 
                    if (!fields[upper]) {
                        fields[upper] =
                            existing[name];
                    }
                }
            );


             config.fields = fields;
             config.fields = fields;
Line 386: Line 255:
             const warnings = [];
             const warnings = [];


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


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


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


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


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


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


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


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


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


             return {
             return {
                 valid:
                 valid: !errors.length,
                    errors.length === 0,
                 errors,
                 errors,
                 warnings
                 warnings
Line 513: Line 335:
         // Transformation helpers
         // Transformation helpers


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


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


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


Line 546: Line 352:
                     return from === ''
                     return from === ''
                         ? null
                         ? null
                         : {
                         : { transform, from, to: to || '' };
                            transform:
                                'replace',
                            from,
                            to: to || ''
                        };


                 default:
                 default:
Line 560: Line 361:
         // UI helpers
         // UI helpers


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


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


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


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


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


Line 614: Line 389:
         },
         },


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


             $('<label>')
             $('<label>')
                 .addClass(
                 .addClass('preload-editor-label')
                    'preload-editor-label'
                )
                 .text(label)
                 .text(label)
                 .appendTo(row);
                 .appendTo(row);
Line 634: Line 400:
             if (description) {
             if (description) {
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass('preload-editor-help')
                        'preload-editor-help'
                    )
                     .text(description)
                     .text(description)
                     .appendTo(row);
                     .appendTo(row);
Line 645: Line 409:
         },
         },


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


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


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


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


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


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


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


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


             this.editor = null;
             this.editor = null;
Line 759: Line 477:


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


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


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


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


             const current =
             const current = this.editorView.state.doc.toString();
                this.editorView.state.doc
             if (current === content) return;
                    .toString();
 
             if (current === content) {
                return;
            }


             this.editorView.dispatch({
             this.editorView.dispatch({
                 changes: {
                 changes: {
                     from: 0,
                     from: 0,
                     to:
                     to: this.editorView.state.doc.length,
                        this.editorView.state.doc
                            .length,
                     insert: content
                     insert: content
                 }
                 }
Line 802: Line 505:


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


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


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


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


Line 837: Line 529:
             cursorPosition
             cursorPosition
         ) {
         ) {
             if (!this.editorView) {
             if (!this.editorView) return;
                return;
            }


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


             const from =
             const from =
Line 861: Line 550:


             view.dispatch({
             view.dispatch({
                 changes: {
                 changes: { from, to, insert: text },
                    from,
                 selection: { anchor: cursor }
                    to,
                    insert: text
                },
                 selection: {
                    anchor: cursor
                }
             });
             });


Line 874: Line 557:
         },
         },


         insertVariable(name) {
         // V2.3 editor tools
             name =
 
                String(name || '')
        wrapSelectionWithVariable() {
                    .trim()
             const selected = this.getEditorSelection();
                    .toUpperCase();


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


             const selected =
             const replacement =
                 this.getEditorSelection();
                 '%%' + selected.text + '%%';


             if (!selected) {
             this.insertEditorText(
                return;
                replacement,
             }
                selected.from,
                selected.to,
                selected.from + replacement.length
            );
        },
 
        insertVariable(name) {
            name = String(name || '').trim().toUpperCase();
            if (!name) return;
 
             const selected = this.getEditorSelection();
            if (!selected) return;


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


             this.insertEditorText(
             this.insertEditorText(
Line 901: Line 594:
         },
         },


         wrapSelectionWithVariable(name) {
         insertCondition(type, variable) {
             name =
             type = String(type || '').toLowerCase();
                String(name || '')
            variable = String(variable || '').trim().toUpperCase();
                    .trim()
 
                    .toUpperCase();
            const selected = this.getEditorSelection();
            if (!selected) return;
 
            let text;
            let cursorOffset;
 
            switch (type) {
                case 'if': {
                    const opening = '<!-- @if ' + variable + ' -->';
                    const closing = '<!-- @endif -->';


            if (!name) {
                    if (selected.from !== selected.to) {
                return;
                        text =
            }
                            opening +
                            '\n' +
                            selected.text +
                            '\n' +
                            closing;
                        cursorOffset = text.length;
                    } else {
                        text =
                            opening +
                            '\n\n' +
                            closing;
                        cursorOffset = opening.length + 1;
                    }


            const selected =
                    break;
                 this.getEditorSelection();
                 }


            if (
                case 'elseif':
                !selected ||
                    text = '<!-- @elseif -->';
                selected.from === selected.to
                    cursorOffset = text.length;
            ) {
                    break;
                this.insertVariable(name);
                return;
            }


            const text =
                case 'else':
                '%%' +
                    text = '<!-- @else -->';
                name +
                    cursorOffset = text.length;
                '%%';
                    break;


            const selectedText =
                case 'endif':
                selected.text;
                    text = '<!-- @endif -->';
                    cursorOffset = text.length;
                    break;


            const replacement =
                 default:
                 text +
                    return;
                selectedText +
            }
                text;


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


         insertCondition(type, variable) {
         getToolVariables() {
             type =
             const variables = new Set(['PAGE']);
                String(type || '')
                    .toLowerCase();


             variable =
             if (this.analysis?.variables) {
                 String(variable || '')
                 this.analysis.variables.forEach(name => {
                     .trim()
                     variables.add(String(name).toUpperCase());
                    .toUpperCase();
                 });
 
            if (
                 type === 'if' ||
                type === 'elseif'
            ) {
                if (!variable) {
                    return;
                }
             }
             }


             const selected =
             return Array.from(variables);
                this.getEditorSelection();
        },


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


             let text = '';
             if (className) select.addClass(className);
            let cursorOffset = 0;


             if (type === 'if') {
             options.forEach(option => {
                const opening =
                $('<option>')
                     '<!-- @if ' +
                     .attr('value', option.value)
                     variable +
                     .text(option.label)
                     ' -->';
                     .appendTo(select);
            });


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


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


                    cursorOffset =
            const variables = this.getToolVariables();
                        text.length;
            const current =
                 } else {
                 this.editorTools.variableSelect.val();
                    text =
            const currentCondition =
                        opening +
                this.editorTools.conditionVariableSelect.val();
                        '\n' +
                        '\n' +
                        closing;


                    cursorOffset =
            const populate = (select, value) => {
                        opening.length + 1;
                 select.empty();
                }
            } else if (type === 'elseif') {
                 text =
                    '<!-- @elseif ' +
                    variable +
                    ' -->';


                 cursorOffset =
                 variables.forEach(name => {
                     text.length;
                     $('<option>')
            } else if (type === 'else') {
                        .attr('value', name)
                text =
                        .text(name)
                    '<!-- @else -->';
                        .appendTo(select);
                });


                 cursorOffset =
                 if (value && variables.includes(value)) {
                     text.length;
                     select.val(value);
            } else if (type === 'endif') {
                } else if (variables.length) {
                 text =
                    select.val(variables[0]);
                    '<!-- @endif -->';
                 }
            };


                 cursorOffset =
            populate(
                    text.length;
                 this.editorTools.variableSelect,
            } else {
                 current
                 return;
             );
             }


             this.insertEditorText(
             populate(
                 text,
                 this.editorTools.conditionVariableSelect,
                selected.from,
                 currentCondition
                selected.to,
                 selected.from +
                    cursorOffset
             );
             );
         },
         },


         // Preload syntax highlighting
         updateConditionVariableVisibility() {
            if (!this.editorTools) return;


        getKnownVariables() {
             const type =
             const known =
                 this.editorTools.conditionTypeSelect.val();
                 new Set(['PAGE']);


             const fields =
             this.editorTools.conditionVariableWrap.toggle(
                this.state.config &&
                 type === 'if'
                this.state.config.fields;
             );
 
            if (
                 fields &&
                typeof fields === 'object'
             ) {
                Object.keys(fields)
                    .forEach(name => {
                        known.add(
                            String(name)
                                .toUpperCase()
                        );
                    });
            }
 
            return known;
         },
         },


         createSyntaxDecorations(
         renderEditorTools() {
            state,
             const tools = $('<div>')
            Decoration
                 .addClass('preload-editor-tools');
        ) {
             const decorations = [];
            const source =
                 state.doc.toString();


             const knownVariables =
             // Wrap selection
                this.getKnownVariables();


             const variablePattern =
             const wrapGroup = $('<div>')
                 /%%([A-Za-z0-9_-]+)%%/g;
                 .addClass(
                    'preload-editor-tool-group preload-editor-tool-group-wrap'
                );


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


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


                const known =
            const variableGroup = $('<div>')
                    knownVariables.has(name);
                .addClass(
                    'preload-editor-tool-group preload-editor-tool-group-variable'
                );


                const classes = [
            $('<span>')
                    'preload-editor-syntax-variable',
                .addClass('preload-editor-tool-label')
                    known
                .text('Insert existing variable')
                        ? 'preload-editor-syntax-variable-known'
                 .appendTo(variableGroup);
                        : 'preload-editor-syntax-variable-unknown'
                 ];


                decorations.push(
            const variableSelect = this.createToolSelect(
                    Decoration.mark({
                [],
                        class:
                'preload-editor-variable-select'
                            classes.join(' ')
            );
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
            }


             const conditionPattern =
             variableGroup.append(
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                variableSelect,
                 this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () => this.insertVariable(variableSelect.val())
                )
            );


             while (
             // Insert condition
                (
                    match =
                        conditionPattern.exec(
                            source
                        )
                ) !== null
            ) {
                const fullStart =
                    match.index;


                const fullEnd =
            const conditionGroup = $('<div>')
                    match.index +
                 .addClass(
                    match[0].length;
                     'preload-editor-tool-group preload-editor-tool-group-condition'
 
                 decorations.push(
                     Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                    }).range(
                        fullStart,
                        fullEnd
                    )
                 );
                 );


                 const directiveStart =
            $('<span>')
                    match.index +
                 .addClass('preload-editor-tool-label')
                    match[0].indexOf('@');
                .text('Insert condition')
                .appendTo(conditionGroup);


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


                decorations.push(
            const conditionVariableSelect = this.createToolSelect(
                    Decoration.mark({
                [],
                        class:
                'preload-editor-condition-variable-select'
                            'preload-editor-syntax-directive'
            );
                    }).range(
                        directiveStart,
                        directiveEnd
                    )
                );


                if (
            const conditionVariableWrap = $('<span>')
                    match[1].toLowerCase() ===
                .addClass(
                        'if' ||
                    'preload-editor-condition-variable'
                    match[1].toLowerCase() ===
                 )
                        'elseif'
                .append(conditionVariableSelect);
                 ) {
                    const expression =
                        match[2];


                    const expressionStart =
            const insertCondition = this.createButton(
                        match.index +
                'Insert',
                        match[0].indexOf(
                'preload-editor-tool-button',
                            expression
                () => this.insertCondition(
                        );
                    conditionTypeSelect.val(),
                    conditionVariableSelect.val()
                )
            );


                    const expressionPattern =
            conditionTypeSelect.on(
                        /[A-Za-z0-9_-]+/g;
                'change.preloadEditor',
                () => this.updateConditionVariableVisibility()
            );


                    let expressionMatch;
            conditionGroup.append(
                conditionTypeSelect,
                conditionVariableWrap,
                insertCondition
            );


                    while (
            tools.append(
                        (
                wrapGroup,
                            expressionMatch =
                variableGroup,
                                expressionPattern
                conditionGroup
                                    .exec(
            );
                                        expression
                                    )
                        ) !== null
                    ) {
                        const name =
                            expressionMatch[0]
                                .toUpperCase();


                        if (
            this.editorTools = {
                            !knownVariables.has(
                root: tools,
                                name
                variableSelect,
                            )
                conditionTypeSelect,
                        ) {
                conditionVariableSelect,
                            continue;
                conditionVariableWrap,
                        }
                insertCondition
            };


                        const start =
            this.updateEditorToolVariables();
                            expressionStart +
            this.updateConditionVariableVisibility();
                            expressionMatch.index;
 
            return tools;
        },
 
        updateEditorTools() {
            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();
        },
 
        // Preload syntax highlighting


                        const end =
        getKnownVariables() {
                            start +
            const known = new Set(['PAGE']);
                            expressionMatch[0].length;
            const fields = this.state.config?.fields;


                        decorations.push(
            if (fields && typeof fields === 'object') {
                            Decoration.mark({
                Object.keys(fields).forEach(name => {
                                class:
                    known.add(String(name).toUpperCase());
                                    'preload-editor-syntax-condition-variable'
                 });
                            }).range(
                                start,
                                end
                            )
                        );
                    }
                 }
             }
             }


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


         async installSyntaxHighlighting(
         createSyntaxDecorations(state, Decoration) {
             view
             const decorations = [];
        ) {
             const source = state.doc.toString();
             await mw.loader.using(
            const knownVariables = this.getKnownVariables();
                'ext.CodeMirror.v6.lib'
            );


             const require =
             const variablePattern = /%%([A-Za-z0-9_-]+)%%/g;
                mw.loader.require;
            let match;


             const {
             while ((match = variablePattern.exec(source)) !== null) {
                 StateField,
                 const name = match[1].toUpperCase();
                StateEffect,
                 const known = knownVariables.has(name);
                EditorView,
                Decoration
            } = require(
                 'ext.CodeMirror.v6.lib'
            );


            const syntaxField =
                 decorations.push(
                 StateField.define({
                     Decoration.mark({
                     create: state =>
                         class: [
                         this.createSyntaxDecorations(
                             'preload-editor-syntax-variable',
                             state,
                             known
                             Decoration
                                ? 'preload-editor-syntax-variable-known'
                         ),
                                : 'preload-editor-syntax-variable-unknown'
 
                         ].join(' ')
                     update: (
                     }).range(
                         decorations,
                         match.index,
                         transaction
                         match.index + match[0].length
                     ) => {
                     )
                        if (
                );
                            !transaction.docChanged
            }
                        ) {
 
                            return decorations;
            const conditionPattern =
                        }
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;


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


                    provide: field =>
                const directiveStart =
                        EditorView.decorations.from(
                    match.index + match[0].indexOf('@');
                            field
                        )
                });


            view.dispatch({
                decorations.push(
                effects:
                    Decoration.mark({
                     StateEffect.appendConfig.of(
                        class: 'preload-editor-syntax-directive'
                         syntaxField
                     }).range(
                         directiveStart,
                        directiveStart + match[1].length + 1
                     )
                     )
            });
                );


            this.editor.syntaxField =
                const type = match[1].toLowerCase();
                syntaxField;
        },


        // Editor tools
                if (type !== 'if' && type !== 'elseif') continue;


        getToolVariables() {
                const expression = match[2];
            const variables = new Set([
                const expressionStart =
                 'PAGE'
                    match.index + match[0].indexOf(expression);
            ]);
 
                const expressionPattern = /[A-Za-z0-9_-]+/g;
                let expressionMatch;
 
                while (
                    (expressionMatch =
                        expressionPattern.exec(expression)) !== null
                 ) {
                    const name =
                        expressionMatch[0].toUpperCase();
 
                    if (!knownVariables.has(name)) continue;


            if (
                     const start =
                this.analysis &&
                         expressionStart + expressionMatch.index;
                Array.isArray(
                    this.analysis.variables
                )
            ) {
                this.analysis.variables
                     .forEach(name => {
                         variables.add(
                            String(name)
                                .toUpperCase()
                        );
                    });
            }


            if (
                    decorations.push(
                this.state.config &&
                        Decoration.mark({
                this.state.config.fields
                            class:
            ) {
                                'preload-editor-syntax-condition-variable'
                Object.keys(
                        }).range(
                    this.state.config.fields
                            start,
                ).forEach(name => {
                            start + expressionMatch[0].length
                    variables.add(
                         )
                         String(name)
                            .toUpperCase()
                     );
                     );
                 });
                 }
             }
             }


             return Array.from(variables);
             return Decoration.set(decorations, true);
         },
         },


         createToolSelect(
         async installSyntaxHighlighting(view) {
             options,
             await mw.loader.using('ext.CodeMirror.v6.lib');
             className
 
        ) {
             const require = mw.loader.require;
             const select =
            const {
                $('<select>')
                StateField,
                    .addClass(
                StateEffect,
                        'preload-editor-tool-select'
                EditorView,
                    );
                Decoration
             } = require('ext.CodeMirror.v6.lib');


             if (className) {
             const syntaxField = StateField.define({
                 select.addClass(
                 create: state =>
                    className
                    this.createSyntaxDecorations(state, Decoration),
                );
            }


            options.forEach(option => {
                update: (decorations, transaction) =>
                $('<option>')
                    transaction.docChanged
                    .attr(
                        ? this.createSyntaxDecorations(
                         'value',
                            transaction.state,
                        option.value
                            Decoration
                    )
                        )
                    .text(option.label)
                         : decorations,
                    .appendTo(select);
 
                provide: field =>
                    EditorView.decorations.from(field)
            });
 
            view.dispatch({
                effects: StateEffect.appendConfig.of(syntaxField)
             });
             });


             return select;
             this.editor.syntaxField = syntaxField;
         },
         },


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


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


            const currentVariable =
                    settled = true;
                this.editorTools.variableSelect
                     cleanup();
                     .val();


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


            const populate = (
                    const inputHandler = () => {
                select,
                        this.state.preloadContent =
                current
                            view.state.doc.toString();
            ) => {
                select.empty();


                variables.forEach(
                        this.state.dirty = true;
                    name => {
                         this.reanalyze();
                         $('<option>')
                        this.renderVariablesBySelector();
                            .attr(
                        this.renderAnalysisBySelector();
                                'value',
                        this.renderConfigPreviewBySelector();
                                name
                        this.updateEditorTools();
                            )
                     };
                            .text(name)
                            .appendTo(
                                select
                            );
                     }
                );


                if (
                     view.dom.addEventListener(
                     current &&
                         'input',
                    variables.includes(
                         inputHandler
                         current
                    )
                ) {
                    select.val(
                         current
                     );
                     );
                } else if (
 
                     variables.length
                     view.dom.classList.add(
                ) {
                         'preload-editor-codemirror'
                    select.val(
                         variables[0]
                     );
                     );
                }
            };


            populate(
                    const wrapper =
                this.editorTools
                        view.dom.closest(
                    .variableSelect,
                            '.ext-codemirror-wrapper'
                currentVariable
                        );
            );


            populate(
                    if (wrapper) {
                this.editorTools
                        wrapper.classList.add(
                    .conditionVariableSelect,
                            'preload-editor-codemirror-wrapper'
                currentConditionVariable
                        );
            );
                    }
        },


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


            const variableGroup =
                    this.editorView = view;
                $('<div>')
                     this.state.preloadContent =
                     .addClass(
                         view.state.doc.toString();
                         'preload-editor-tool-group'
                    );


            $('<span>')
                    try {
                .addClass(
                        await this.installSyntaxHighlighting(view);
                     'preload-editor-tool-label'
                        resolve(view);
                )
                     } catch (error) {
                .text('Variable')
                        reject(error);
                .appendTo(
                     }
                     variableGroup
                 };
                 );


            const variableSelect =
                const onLoad = loader => {
                this.createToolSelect(
                    if (
                    [],
                        settled ||
                    'preload-editor-variable-select'
                        typeof loader !== 'function'
                );
                    ) return;


            const insertVariable =
                    try {
                this.createButton(
                        loader(textarea);
                     'Insert',
                     } catch (error) {
                    'preload-editor-tool-button',
                         settled = true;
                    () => {
                        cleanup();
                         this.insertVariable(
                         reject(error);
                            variableSelect.val()
                     }
                         );
                 };
                     }
                 );


            const wrapVariable =
                 mw.hook('dev.CCM.ready').add(onReady);
                 this.createButton(
                mw.hook('dev.CCM.load').add(onLoad);
                    'Wrap selection',
            });
                    'preload-editor-tool-button',
        },
                    () => {
                        this.wrapSelectionWithVariable(
                            variableSelect.val()
                        );
                    }
                );


            variableGroup.append(
        // Main UI
                variableSelect,
                insertVariable,
                wrapVariable
            );


             const conditionGroup =
        init() {
                $('<div>')
             const root = $('#mw-content-text');
                    .addClass(
            if (!root.length) return;
                        'preload-editor-tool-group'
                    );


             $('<span>')
             this.root = $('<div>')
                 .addClass(
                 .attr('id', 'preload-editor')
                    'preload-editor-tool-label'
                 .addClass('preload-editor');
                )
                 .text('Condition')
                .appendTo(
                    conditionGroup
                );


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


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


             const insertCondition =
             const titleInput = this.textInput(
                 this.createButton(
                 this.state.preloadTitle,
                    'Insert',
                'Template:Example/Preload'
                    'preload-editor-tool-button',
            ).attr('id', 'preload-editor-title-input');
                    () => {
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        );
                    }
                );


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


             tools.append(
             const newButton = this.createButton(
                 variableGroup,
                 'New',
                 conditionGroup
                 '',
                () => this.startNew()
             );
             );


             this.editorTools = {
             const sampleButton = this.createButton(
                 root: tools,
                 'Load Sample Page',
                 variableSelect,
                 '',
                 wrapVariable,
                 () => this.loadSamplePage()
                conditionVariableSelect,
             );
                conditionTypeSelect,
                insertCondition
             };


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


             return tools;
             this.root.append(loader);
        },


        updateEditorTools() {
            this.status = $('<div>')
            this.updateEditorToolVariables();
                .addClass('preload-editor-status');
        },


        // CodeMirror mounting
            this.root.append(this.status);


        async mountCodeMirror(
            this.workspace = $('<div>')
            textarea
                 .addClass('preload-editor-workspace');
        ) {
            return new Promise(
                 (resolve, reject) => {
                    let settled = false;


                    const cleanup = () => {
            this.root.append(this.workspace);
                        mw.hook(
            this.renderWelcome();
                            'dev.CCM.ready'
        },
                        ).remove(
                            onReady
                        );


                        mw.hook(
        renderWelcome() {
                            'dev.CCM.load'
            this.workspace.empty();
                        ).remove(
                            onLoad
                        );
                    };


                    const onReady =
            const section = this.createSection('Get started');
                        async cm => {
                            if (
                                settled ||
                                !cm ||
                                cm.textarea !==
                                    textarea ||
                                !cm.view
                            ) {
                                return;
                            }


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


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


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


                                    this.renderVariablesBySelector();
            section.append(tips);
                                    this.renderAnalysisBySelector();
            this.workspace.append(section);
                                    this.renderConfigPreviewBySelector();
        },
                                    this.updateEditorTools();
                                };


                            view.dom.addEventListener(
        setStatus(message, type) {
                                'input',
            if (!this.status) return;
                                inputHandler
                            );


                            view.dom.classList.add(
            this.status.removeClass(
                                'preload-editor-codemirror'
                'is-error',
                            );
                'is-success',
                'is-warning',
                'is-loading'
            );


                            const wrapper =
            if (type) this.status.addClass('is-' + type);
                                view.dom.closest(
            this.status.text(message || '');
                                    '.ext-codemirror-wrapper'
        },
                                );


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


                            this.editor = {
        async loadExisting(title) {
                                cm,
            title = String(title || '').trim();
                                view,
                                textarea,
                                inputHandler
                            };


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


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


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


                                resolve(view);
                if (!page) {
                            } catch (
                    const suggestions =
                                error
                        await this.findSimilarPreloads(title);
                            ) {
                                reject(error);
                            }
                        };


                     const onLoad =
                     this.renderMissingPage(title, suggestions);
                        loader => {
                    this.setStatus(
                            if (
                        'Preload not found.',
                                settled ||
                        'warning'
                                typeof loader !==
                    );
                                    'function'
                    return;
                            ) {
                }
                                return;
                            }


                            try {
                await this.loadDefinition(
                                loader(textarea);
                    page.title,
                            } catch (
                    page.content
                                error
                );
                            ) {
                                settled = true;
                                cleanup();
                                reject(
                                    error
                                );
                            }
                        };


                    mw.hook(
                this.setStatus(
                        'dev.CCM.ready'
                    'Preload loaded.',
                     ).add(onReady);
                     'success'
                );
            } catch (error) {
                console.error('Preload Editor:', error);
                this.setStatus(
                    error.message || 'Could not load preload.',
                    'error'
                );
            }
        },


                    mw.hook(
        async findSimilarPreloads(title) {
                        'dev.CCM.load'
            let query = title
                    ).add(onLoad);
                .replace(/\/Preload$/i, '')
                 }
                 .trim();
            );
        },


        // Main UI
            if (!query) query = title;


        init() {
            try {
            const root =
                const results = await this.searchPages(query);
                $('#mw-content-text');


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


            this.root =
        renderMissingPage(title, suggestions) {
                $('<div>')
            this.workspace.empty();
                    .attr(
                        'id',
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                    );


             root.append(this.root);
             const section = this.createSection('Preload not found');
            this.renderShell();
        },


        renderShell() {
            $('<p>')
            this.destroyEditor();
                .text('No preload exists at "' + title + '".')
            this.root.empty();
                .appendTo(section);


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


            const titleInput =
                const list = $('<div>')
                this.textInput(
                    .addClass('preload-editor-suggestions');
                    this.state.preloadTitle,
                    'Template:Example/Preload'
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );


            const loadButton =
                suggestions.forEach(result => {
                this.createButton(
                    list.append(
                    'Load',
                        this.createButton(
                    'preload-editor-button-primary',
                            result.title,
                    () =>
                            'preload-editor-suggestion',
                        this.loadExisting(
                            () => this.loadExisting(result.title)
                            titleInput.val()
                         )
                         )
                 );
                    );
                 });


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


             const sampleButton =
             section.append(
                 this.createButton(
                 this.createButton(
                     'Load Sample Page',
                     'Create this preload',
                     '',
                     'preload-editor-button-primary',
                     () =>
                     () => this.startNew(title)
                        this.loadSamplePage()
                 )
                 );
            );


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


            loader.append(loadRow);
        async startNew(title) {
             this.root.append(loader);
             title = String(title || '').trim();


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


             this.root.append(
             this.renderEditor();
                this.status
            );


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


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


        renderWelcome() {
             if (!result) return;
             this.workspace.empty();


             const section =
             this.setStatus(
                this.createSection(
                'Loading sample page...',
                    'Get started'
                 'loading'
                 );
            );


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


            const tips =
                 if (!page) {
                 $('<ul>')
                     throw new Error(
                     .addClass(
                         'Sample page not found: ' + result
                         'preload-editor-tips'
                     );
                     );
                }


            [
                 const sample =
                'Existing preload: loads the preload and its config.json if available.',
                     this.convertSampleToPreload(page.content);
                '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.state = {
            this.workspace.append(
                    preloadTitle: '',
                section
                    preloadSource: '',
            );
                    preloadContent: sample,
        },
                    config: this.emptyConfig(),
                    variables: ['PAGE'],
                    conditions: [],
                    mode: 'new',
                    dirty: true
                };


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


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


            if (type) {
                this.setStatus(
                this.status.addClass(
                    error.message ||
                     'is-' + type
                        'Could not load sample page.',
                     'error'
                 );
                 );
             }
             }
        },
        convertSampleToPreload(source) {
            let content = String(source || '').trim();


             this.status.text(
             const include = content.match(
                 message || ''
                 /<includeonly>([\s\S]*?)<\/includeonly>/i
             );
             );
        },


        // Load / create
            if (include) content = include[1].trim();


        async loadExisting(title) {
             content = content
             title =
                 .replace(
                 String(title || '')
                     /<noinclude>[\s\S]*?<\/noinclude>/gi,
                    .trim();
                     ''
 
                 )
            if (!title) {
                 .trim();
                mw.notify(
                     'Enter a preload page title first.',
                     { type: 'warn' }
                 );
                 return;
            }


             this.setStatus(
             return (
                 'Loading preload...',
                 '<includeonly>\n' +
                 'loading'
                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');


            try {
                 const box = $('<div>')
                 const page =
                     .addClass('preload-editor-prompt');
                     await this.getPage(
                        title
                    );


                 if (!page) {
                 $('<h2>')
                     const suggestions =
                     .text(title)
                        await this.findSimilarPreloads(
                    .appendTo(box);
                            title
                        );


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


                    this.setStatus(
                const input = this.textInput();
                        'Preload not found.',
                        'warning'
                    );


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


                 await this.loadDefinition(
                 const cancel = this.createButton(
                     page.title,
                     'Cancel',
                     page.content
                    '',
                     () => {
                        overlay.remove();
                        resolve(null);
                    }
                 );
                 );


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


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


        async findSimilarPreloads(
                buttons.append(cancel, confirm);
            title
                box.append(input, buttons);
        ) {
                 overlay.append(box);
            let query =
                $(document.body).append(overlay);
                 title
                    .replace(
                        /\/Preload$/i,
                        ''
                    )
                    .trim();


            if (!query) {
                input.trigger('focus');
                query = title;
            }


            try {
                 input.on(
                 const results =
                     'keydown.preloadEditor',
                     await this.searchPages(
                    event => {
                         query
                         if (event.key === 'Enter') {
                    );
                            confirm.trigger('click');
                        }


                return results.filter(
                        if (event.key === 'Escape') {
                    result =>
                            cancel.trigger('click');
                        /\/Preload$/i.test(
                        }
                            result.title
                    }
                        )
                 );
                 );
             } catch (
             });
                error
            ) {
                console.error(
                    'Preload Editor search:',
                    error
                );
 
                return [];
            }
         },
         },


         renderMissingPage(
         async loadDefinition(title, source) {
            title,
             const content = this.extractIncludeonly(source);
             suggestions
             let config = this.emptyConfig();
        ) {
             this.workspace.empty();


             const section =
             const configTitle =
                 this.createSection(
                 title.replace(/\/+$/, '') +
                    'Preload not found'
                 '/config.json';
                 );


             $('<p>')
             const configPage =
                .text(
                 await this.getPage(configTitle);
                    'No preload exists at "' +
                    title +
                    '".'
                )
                 .appendTo(section);


             if (suggestions.length) {
             if (configPage) {
                 $('<h3>')
                 try {
                     .text(
                     config = JSON.parse(configPage.content);
                        'Similar preloads'
                 } catch (error) {
                    )
                     throw new Error(
                    .appendTo(section);
                         'The existing config.json contains invalid JSON: ' +
 
                         error.message
                 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(
             const analysis =
                 this.createButton(
                 this.analyzePreload(content);
                    'Create this preload',
                    'preload-editor-button-primary',
                    () =>
                        this.startNew(
                            title
                        )
                )
            );


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


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


             this.renderEditor();
             this.renderEditor();
        },
        // Editor
        renderEditor() {
            this.destroyEditor();
            this.workspace.empty();


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


        async loadSamplePage() {
             const left = $('<div>')
             const result =
                 .addClass('preload-editor-left');
                 await this.createPromptDialog(
                    'Load Sample Page',
                    'Enter the title of a normal wiki page to use as the starting point.'
                );


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


             this.setStatus(
             const editorSection = this.createSection(
                 'Loading sample page...',
                 'Preload',
                 'loading'
                 'preload-editor-main-section'
             );
             );


             try {
             const meta = $('<div>')
                const page =
                .addClass('preload-editor-meta');
                    await this.getPage(
                        result
                    );


                if (!page) {
            $('<strong>')
                    throw new Error(
                .text(this.state.preloadTitle || 'New preload')
                        'Sample page not found: ' +
                .appendTo(meta);
                        result
                    );
                }


                 const sample =
            if (this.state.mode === 'new') {
                     this.convertSampleToPreload(
                 $('<span>')
                        page.content
                     .addClass('preload-editor-badge')
                     );
                    .text('NEW')
                     .appendTo(meta);
            }
 
            editorSection.append(meta);
 
            editorSection.append(
                this.renderEditorTools()
            );


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


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


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


                 this.setStatus(
            const analysisSection =
                    error.message ||
                 this.createSection('Analysis');
                        'Could not load sample page.',
                    'error'
                );
            }
        },


        convertSampleToPreload(
             const analysisContainer = $('<div>')
            source
                .addClass('preload-editor-analysis');
        ) {
             let content =
                String(source || '')
                    .trim();


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


             if (include) {
             const variablesSection =
                 content =
                 this.createSection('Variables');
                    include[1].trim();
            }


             content =
             const variableContainer = $('<div>')
                content
                .addClass('preload-editor-variables');
                    .replace(
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                        ''
                    )
                    .trim();


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


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


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


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


                    $('<p>')
            const actions = $('<div>')
                        .text(message)
                .addClass('preload-editor-actions');
                        .appendTo(box);


                     const input =
            actions.append(
                        this.textInput();
                this.createButton(
                     'Save Preload + Config',
                    'preload-editor-button-primary',
                    () => this.saveAll()
                ),
                this.createButton(
                    'Save Preload Only',
                    '',
                    () => this.savePreloadOnly()
                )
            );


                    const buttons =
            right.append(
                        $('<div>')
                analysisSection,
                            .addClass(
                variablesSection,
                                'preload-editor-prompt-buttons'
                configSection,
                            );
                actions
            );


                    const cancel =
            layout.append(left, right);
                        this.createButton(
            this.workspace.append(layout);
                            'Cancel',
                            '',
                            () => {
                                overlay.remove();
                                resolve(
                                    null
                                );
                            }
                        );


                    const confirm =
            this.reanalyze();
                        this.createButton(
            this.renderVariables(variableContainer);
                            'Load',
            this.renderAnalysis(analysisContainer);
                            'preload-editor-button-primary',
            this.renderConfigPreview(configPreview);
                            () => {
            this.updateEditorTools();
                                const value =
                                    String(
                                        input.val() ||
                                        ''
                                    ).trim();


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


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


                    buttons.append(
        reanalyze() {
                        cancel,
            const source = this.getEditorContent();
                        confirm
            const content = this.extractIncludeonly(source);
                    );
            const analysis = this.analyzePreload(content);


                    box.append(
            this.state.preloadContent = source;
                        input,
            this.state.variables = analysis.variables;
                        buttons
            this.state.conditions = analysis.conditions;
                    );


                    overlay.append(box);
            this.state.config =
                     $(document.body).append(
                this.prepareConfigForVariables(
                         overlay
                     this.state.config ||
                     );
                         this.emptyConfig(),
                     analysis
                );


                    input.trigger(
            this.analysis = analysis;
                        'focus'
        },
                    );


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


                            if (
            const analysis =
                                event.key ===
                this.analysis ||
                                'Escape'
                this.analyzePreload(
                            ) {
                     this.extractIncludeonly(
                                cancel.trigger(
                        this.getEditorContent()
                                    'click'
                     )
                                );
                            }
                        }
                     );
                }
            );
        },
 
        async loadDefinition(
            title,
            source
        ) {
            const content =
                this.extractIncludeonly(
                     source
                 );
                 );


             let config =
             const variableCount = analysis.variables.length;
                this.emptyConfig();
            const conditionCount = analysis.conditions.length;


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


             const configPage =
             summary.append(
                 await this.getPage(
                 $('<span>').text(
                     configTitle
                     variableCount +
                );
                    ' variable' +
 
                    (variableCount === 1 ? '' : 's')
            if (configPage) {
                 ),
                 try {
                $('<span>').text(
                    config =
                     conditionCount +
                        JSON.parse(
                    ' condition marker' +
                            configPage.content
                     (conditionCount === 1 ? '' : 's')
                        );
                )
                } catch (
            );
                     error
 
                ) {
             container.append(summary);
                     throw new Error(
                        'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
             }


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


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


             this.state = {
             if (validation.errors.length) {
                preloadTitle: title,
                const errors = $('<ul>')
                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(
                     .addClass(
                         'preload-editor-layout'
                         'preload-editor-validation-errors'
                     );
                     );


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


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


            const editorSection =
                 validation.warnings.forEach(warning => {
                 this.createSection(
                     $('<li>').text(warning).appendTo(warnings);
                     'Preload',
                 });
                    'preload-editor-main-section'
                 );


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


             $('<strong>')
             if (!validation.errors.length &&
                .text(
                !validation.warnings.length) {
                    this.state.preloadTitle ||
                container.append(
                     'New preload'
                    $('<div>')
                 )
                        .addClass(
                .appendTo(meta);
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                );
            }
        },
 
        renderAnalysisBySelector() {
            const container =
                this.root?.find(
                     '.preload-editor-analysis'
                 );


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


             editorSection.append(
        renderVariables(container) {
                meta
             container.empty();
            );


             const editorTools =
             const analysis =
                 this.renderEditorTools();
                 this.analysis ||
 
                 this.analyzePreload(
            editorSection.append(
                     this.extractIncludeonly(
                editorTools
                        this.getEditorContent()
            );
 
            const textarea =
                 this.textarea(
                     this.state.preloadContent
                )
                    .attr(
                        'id',
                        'preload-editor-content'
                     )
                     )
                    .addClass(
                );
                        'preload-editor-content'
                    );


             const editorMount =
             const variables = analysis.variables.slice();
                $('<div>')
            const ordered = ['PAGE'];
                    .addClass(
                        'preload-editor-codemirror-mount'
                    )
                    .append(textarea);


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


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


            const analysisSection =
                 if (!ordered.includes(upper)) {
                 this.createSection(
                     ordered.push(upper);
                     'Analysis'
                 }
                 );
            });


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


            analysisSection.append(
                this.state.config.fields[name] = field;
                analysisContainer
            );


            const variablesSection =
                container.append(
                this.createSection(
                    this.createVariableCard(
                     'Variables'
                        name,
                        field,
                        analysis
                     )
                 );
                 );
            });
        },


             const variableContainer =
        renderVariablesBySelector() {
                 $('<div>')
             const container =
                     .addClass(
                 this.root?.find(
                        'preload-editor-variables'
                     '.preload-editor-variables'
                    );
 
            variablesSection.append(
                variableContainer
            );
 
            const configSection =
                this.createSection(
                    'Generated configuration'
                 );
                 );


             const configPreview =
             if (container?.length) {
                $('<pre>')
                this.renderVariables(container);
                    .addClass(
            }
                        'preload-editor-config-preview'
        },
                    );


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


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


             const save =
             header.append($('<h3>').text(name));
                this.createButton(
                    'Save Preload + Config',
                    'preload-editor-button-primary',
                    () => this.saveAll()
                );


             const savePreload =
             if (name === 'PAGE') {
                 this.createButton(
                 header.append(
                     'Save Preload Only',
                     $('<span>')
                     '',
                        .addClass('preload-editor-badge')
                    () =>
                        .text('AUTOMATIC')
                         this.savePreloadOnly()
                );
            } else if (!analysis.variables.includes(name)) {
                header.append(
                     $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                         .text('UNUSED')
                 );
                 );
            }


             actions.append(
             card.append(header);
                save,
                savePreload
            );


             right.append(
             const label = this.textInput(field.label || '');
                analysisSection,
                variablesSection,
                configSection,
                actions
            );


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


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


             this.reanalyze();
             const description = this.textarea(
            this.renderVariables(
                 field.description || ''
                variableContainer
             ).addClass('preload-editor-small-textarea');
            );
            this.renderAnalysis(
                 analysisContainer
            );
            this.renderConfigPreview(
                configPreview
             );
            this.updateEditorTools();


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


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


        reanalyze() {
            const types = [
             const source =
                ['text', 'Text'],
                 this.getEditorContent();
                ['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'
            );


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


            const analysis =
                card.replaceWith(
                this.analyzePreload(
                    this.createVariableCard(
                     content
                        name,
                        field,
                        analysis
                     )
                 );
                 );


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


             this.state.variables =
             card.append(
                 analysis.variables;
                this.fieldRow(
                    'Type',
                    typeSelect
                 )
            );


             this.state.conditions =
             if (name === 'PAGE') {
                 analysis.conditions;
                 field.type = 'page';


            this.state.config =
                const required = this.checkbox(
                this.prepareConfigForVariables(
                     field.required !== false
                     this.state.config ||
                        this.emptyConfig(),
                    analysis
                 );
                 );


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


        renderAnalysis(
                    this.markDirty();
            container
                    this.renderConfigPreviewBySelector();
        ) {
                });
            container.empty();


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


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


            const conditionCount =
                namespace.on('change.preloadEditor', () => {
                analysis.conditions.length;
                    field.namespace = namespace.val();
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                });


            const summary =
                 card.append(
                 $('<div>')
                     this.fieldRow(
                     .addClass(
                         'Namespace',
                         'preload-editor-analysis-summary'
                         namespace
                    );
 
            summary.append(
                $('<span>').text(
                    variableCount +
                    ' variable' +
                    (
                         variableCount === 1
                            ? ''
                            : 's'
                     )
                     )
                 ),
                 );
                $('<span>').text(
                    conditionCount +
                    ' condition marker' +
                    (
                        conditionCount === 1
                            ? ''
                            : 's'
                    )
                )
            );


            container.append(
                 const allowNew = this.checkbox(
                 summary
                     field['allow-new'] === true
            );
 
            if (
                analysis.conditionVariables
                    .length
            ) {
                container.append(
                     $('<div>')
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis
                                .conditionVariables
                                .join(', ')
                        )
                 );
                 );
            }


            const validation =
                 allowNew.on('change.preloadEditor', () => {
                 this.validateConfig(
                     field['allow-new'] =
                    this.state.config,
                        allowNew.prop('checked');
                     analysis
                );


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


                 validation.errors.forEach(
                 card.append(
                     error => {
                     this.fieldRow(
                         $('<li>')
                         'Allow new page',
                            .text(error)
                        allowNew
                            .appendTo(
                    )
                                errors
                            );
                    }
                 );
                 );


                 container.append(
                 return card;
                    errors
                );
             }
             }


             if (
             const required = this.checkbox(
                 validation.warnings.length
                 field.required === true
             ) {
             );
                const warnings =
 
                    $('<ul>')
            required.on('change.preloadEditor', () => {
                        .addClass(
                field.required =
                            'preload-editor-validation-warnings'
                    required.prop('checked');
                        );


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


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


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


        renderAnalysisBySelector() {
            placeholder.on('input.preloadEditor', () => {
            const container =
                const value = placeholder.val();
                this.root &&
                this.root.find(
                    '.preload-editor-analysis'
                );


            if (
                if (value) field.placeholder = value;
                container &&
                 else delete field.placeholder;
                container.length
            ) {
                 this.renderAnalysis(
                    container
                );
            }
        },


        renderVariables(
                this.markDirty();
            container
                this.renderConfigPreviewBySelector();
        ) {
             });
             container.empty();


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


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


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


            variables.forEach(
                 if (value) field.default = value;
                 name => {
                 else delete field.default;
                    if (
                        name !== 'PAGE' &&
                        !ordered.includes(
                            name
                        )
                    ) {
                        ordered.push(name);
                    }
                 }
            );


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


                    if (
            card.append(
                        !ordered.includes(
                this.fieldRow(
                            upper
                    'Default',
                        )
                    defaultInput,
                     ) {
                     'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                        ordered.push(
                )
                            upper
                        );
                    }
                }
             );
             );


             ordered.forEach(
             if (field.type === 'textarea') {
                name => {
                const maxRows = this.textInput(
                    const field =
                    field.maxRows || ''
                        this.state.config
                ).attr('type', 'number');
                            .fields[name] ||
                        this.createDefaultField(
                            name,
                            analysis
                        );


                    this.state.config
                maxRows.on('input.preloadEditor', () => {
                        .fields[name] =
                    const value =
                         field;
                         parseInt(maxRows.val(), 10);


                     container.append(
                     if (Number.isFinite(value) && value > 0) {
                         this.createVariableCard(
                         field.maxRows = value;
                            name,
                    } else {
                            field,
                        delete field.maxRows;
                            analysis
                    }
                        )
 
                     );
                    this.markDirty();
                 }
                     this.renderConfigPreviewBySelector();
            );
                 });
        },


        renderVariablesBySelector() {
                 card.append(
            const container =
                    this.fieldRow(
                 this.root &&
                        'Maximum rows',
                this.root.find(
                        maxRows
                    '.preload-editor-variables'
                    )
                 );
                 );
            }


             if (
             if (field.type === 'select' ||
                 container &&
                 field.type === 'multiselect') {
                container.length
                 this.renderSelectConfiguration(
            ) {
                     card,
                 this.renderVariables(
                    field
                     container
                 );
                 );
             }
             }
            return card;
         },
         },


         createVariableCard(
         renderSelectConfiguration(card, field) {
            name,
             const source = this.textInput(
            field,
                 field.source || '',
            analysis
                'category:Example'
        ) {
            );
             const card =
 
                 $('<div>')
            source.on('input.preloadEditor', () => {
                    .addClass(
                const value = source.val();
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );


            const header =
                if (value) field.source = value;
                 $('<div>')
                 else delete field.source;
                    .addClass(
 
                        'preload-editor-variable-header'
                this.markDirty();
                     );
                this.renderConfigPreviewBySelector();
            });
 
            card.append(
                this.fieldRow(
                    'Source',
                    source,
                     'Currently supported source format: category:Category Name'
                )
            );


             header.append(
             const sourcePrefix = this.textInput(
                 $('<h3>').text(name)
                 field['source-prefix'] || ''
             );
             );


             if (name === 'PAGE') {
             sourcePrefix.on('input.preloadEditor', () => {
                 header.append(
                 const value = sourcePrefix.val();
                    $('<span>')
 
                        .addClass(
                if (value) {
                            'preload-editor-badge'
                    field['source-prefix'] = value;
                        )
                 } else {
                        .text(
                     delete field['source-prefix'];
                            'AUTOMATIC'
                }
                        )
 
                 );
                this.markDirty();
            } else if (
                 this.renderConfigPreviewBySelector();
                !analysis.variables
             });
                    .includes(name)
            ) {
                header.append(
                     $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text(
                            'UNUSED'
                        )
                 );
             }


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


             const label =
             const excludeCategory = this.textInput(
                this.textInput(
                field.exclude?.category || ''
                    field.label || ''
            );
                );


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


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


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


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


             const description =
             card.append(
                 this.textarea(
                 this.createTransformControls(
                     field.description || ''
                     field,
                    'value',
                    'Value transformation'
                ),
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                 )
                 )
                    .addClass(
            );
                        'preload-editor-small-textarea'
                    );


             description.on(
             const optionsValue =
                 'input.preloadEditor',
                 Array.isArray(field.options)
                () => {
                     ? field.options.map(option => {
                     field.description =
                         if (typeof option === 'string') {
                         description.val();
                            return option;
                        }


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


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


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


            const types = [
                 if (!lines.length) {
                 ['text', 'Text'],
                    delete field.options;
                ['textarea', 'Textarea'],
                 } else {
                ['number', 'Number'],
                     field.options = lines.map(line => {
                ['boolean', 'Boolean'],
                        const parts =
                ['select', 'Select'],
                            line.split(/\s*\|\s*/);
                 [
                     'multiselect',
                    'Multi-select'
                ],
                ['page', 'Page'],
                ['user', 'User'],
                ['date', 'Date']
            ];


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


            typeSelect.on(
                        return {
                'change.preloadEditor',
                            value: parts[0],
                () => {
                            label: parts.slice(1).join(' | ')
                    field.type =
                         };
                         typeSelect.val();
                    });
                }


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


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


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


                 const required =
            $('<h4>')
                    this.checkbox(
                 .text(title)
                        field.required !== false
                .appendTo(wrapper);
                    );


                 required.on(
            const existing =
                    'change.preloadEditor',
                 field[property] &&
                     () => {
                typeof field[property] === 'object'
                        field.required =
                     ? field[property]
                            required.prop(
                    : {};
                                'checked'
                            );


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


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


                const namespace =
            const suffix = this.textInput(
                    this.select(
                existing.suffix || ''
                        [
            );
                            {
                                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(
            const from = this.textInput(
                    'change.preloadEditor',
                existing.from || ''
                    () => {
            );
                        field.namespace =
                            namespace.val();


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


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


                 card.append(
            const prefixRow =
                    this.fieldRow(
                 this.fieldRow('Prefix', prefix);
                        'Namespace',
 
                        namespace
            const suffixRow =
                    )
                 this.fieldRow('Suffix', suffix);
                 );


                const allowNew =
            const fromRow =
                    this.checkbox(
                this.fieldRow('From', from);
                        field['allow-new'] ===
                            true
                    );


                 allowNew.on(
            const toRow =
                    'change.preloadEditor',
                 this.fieldRow('To', to);
                    () => {
                        field['allow-new'] =
                            allowNew.prop(
                                'checked'
                            );


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


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


                 card.append(
                 suffixRow.toggle(
                     this.fieldRow(
                     type === 'remove-suffix'
                        'Allow new page',
                        allowNew
                    )
                 );
                 );


                 return card;
                 fromRow.toggle(type === 'replace');
             }
                toRow.toggle(type === 'replace');
             };


             const required =
             const update = () => {
                 this.checkbox(
                 const type = transform.val();
                    field.required === true
                let object = null;
                );


            required.on(
                if (type === 'remove-prefix' && prefix.val()) {
                 'change.preloadEditor',
                    object = {
                 () => {
                        transform: type,
                     field.required =
                        prefix: prefix.val()
                         required.prop(
                    };
                            'checked'
                 } else if (
                         );
                    type === 'remove-suffix' &&
                    suffix.val()
                 ) {
                     object = {
                        transform: type,
                         suffix: suffix.val()
                    };
                } else if (type === 'replace' && from.val()) {
                    object = {
                        transform: type,
                         from: from.val(),
                        to: to.val()
                    };
                }


                    this.markDirty();
                if (object) {
 
                    field[property] = object;
                     this.renderConfigPreviewBySelector();
                } else {
                     delete field[property];
                 }
                 }
            );


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


             const placeholder =
             transform.on(
                this.textInput(
                 'change.preloadEditor',
                    field.placeholder || ''
                );
 
            placeholder.on(
                 'input.preloadEditor',
                 () => {
                 () => {
                     const value =
                     update();
                        placeholder.val();
                     renderInputs();
 
                    if (value) {
                        field.placeholder =
                            value;
                    } else {
                        delete field.placeholder;
                    }
 
                    this.markDirty();
 
                     this.renderConfigPreviewBySelector();
                 }
                 }
             );
             );


             card.append(
             [prefix, suffix, from, to].forEach(input => {
                 this.fieldRow(
                 input.on(
                     'Placeholder',
                     'input.preloadEditor',
                     placeholder
                     update
                )
            );
 
            const defaultInput =
                this.textInput(
                    field.default === undefined
                        ? ''
                        : field.default
                 );
                 );
            });


             defaultInput.on(
             controls.append(
                 'input.preloadEditor',
                 this.fieldRow(
                 () => {
                    'Transformation',
                    const value =
                    transform
                        defaultInput.val();
                ),
                prefixRow,
                suffixRow,
                 fromRow,
                toRow
            );
 
            renderInputs();
            wrapper.append(controls);


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


                    this.markDirty();
        // Config output


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


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


             if (
             if (fields.PAGE) {
                 field.type ===
                 orderedFields.PAGE = fields.PAGE;
                'textarea'
            }
             ) {
 
                 const maxRows =
             Object.keys(fields)
                     this.textInput(
                 .filter(
                         field.maxRows || ''
                     name =>
                    ).attr(
                         name.toUpperCase() !== 'PAGE'
                        'type',
                )
                        'number'
                .forEach(name => {
                    );
                    orderedFields[name] = fields[name];
                });


                maxRows.on(
            config.fields = orderedFields;
                    'input.preloadEditor',
            return config;
                    () => {
        },
                        const value =
                            parseInt(
                                maxRows.val(),
                                10
                            );


                        if (
        getConfigJSON() {
                            Number.isFinite(
            return JSON.stringify(
                                value
                this.getCleanConfig(),
                            ) &&
                null,
                            value > 0
                4
                        ) {
            );
                            field.maxRows =
        },
                                value;
                        } else {
                            delete field.maxRows;
                        }


                        this.markDirty();
        renderConfigPreview(container) {
            if (container) {
                container.text(this.getConfigJSON());
            }
        },


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


                card.append(
            if (preview?.length) {
                    this.fieldRow(
                this.renderConfigPreview(preview);
                        'Maximum rows',
             }
                        maxRows
                    )
                );
             }


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


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


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


             source.on(
        async savePreloadOnly() {
                'input.preloadEditor',
             let title =
                 () => {
                String(
                    const value =
                    this.state.preloadTitle || ''
                        source.val();
                 ).trim();


                    if (value) {
            if (!title) {
                        field.source =
                const result =
                            value;
                    await this.createPromptDialog(
                    } else {
                        'Preload title',
                         delete field.source;
                         'Enter the full title for the preload.'
                     }
                     );


                    this.markDirty();
                if (!result) return;


                    this.renderConfigPreviewBySelector();
                title = result;
                }
                this.state.preloadTitle = title;
            );
            }


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


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


             sourcePrefix.on(
             if (validation.errors.length) {
                'input.preloadEditor',
                this.showValidationErrors(validation);
                () => {
                return;
                    const value =
            }
                        sourcePrefix.val();


                    if (value) {
            this.setStatus(
                        field[
                'Saving preload...',
                            'source-prefix'
                'loading'
                        ] = value;
                    } else {
                        delete field[
                            'source-prefix'
                        ];
                    }
 
                    this.markDirty();
 
                    this.renderConfigPreviewBySelector();
                }
             );
             );


             card.append(
             try {
                 this.fieldRow(
                 const exists =
                     'Source prefix',
                     await this.pageExists(title);
                    sourcePrefix,
                    'Only source values beginning with this text are included.'
                )
            );


            const excludeCategory =
                 await this.savePage(
                 this.textInput(
                     title,
                     field.exclude &&
                     this.state.preloadContent,
                     field.exclude.category
                    exists
                         ? field.exclude.category
                         ? 'Updated preload with Preload Editor'
                         : ''
                         : 'Created preload with Preload Editor',
                    !exists
                 );
                 );


            excludeCategory.on(
                this.state.mode = 'edit';
                'input.preloadEditor',
                 this.state.dirty = false;
                 () => {
                    const value =
                        excludeCategory.val();


                    if (value) {
                this.setStatus(
                        field.exclude =
                    'Preload saved successfully.',
                            Object.assign(
                     'success'
                                {},
                );
                                field.exclude || {},
                                {
                                    category:
                                        value
                                }
                            );
                     } else if (
                        field.exclude
                    ) {
                        delete field.exclude
                            .category;


                        if (
                mw.notify(
                            !Object.keys(
                    'Preload saved.',
                                field.exclude
                    { type: 'success' }
                            ).length
                );
                        ) {
            } catch (error) {
                            delete field.exclude;
                console.error(
                        }
                    'Preload Editor save:',
                     }
                     error
                );


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


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


             card.append(
        async saveAll() {
                 this.fieldRow(
             let title =
                     'Exclude category',
                 String(
                    excludeCategory
                     this.state.preloadTitle || ''
                 )
                 ).trim();
            );


             card.append(
             if (!title) {
                 this.createTransformControls(
                 const result =
                    field,
                    await this.createPromptDialog(
                    'value',
                        'Preload title',
                    'Value transformation'
                        'Enter the full title for the preload.'
                ),
                     );
                this.createTransformControls(
                    field,
                    'label',
                     'Label transformation'
                )
            );


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


                            return (
                title = result;
                                String(
                this.state.preloadTitle = title;
                                    option.value ||
            }
                                    ''
                                ) +
                                ' | ' +
                                String(
                                    option.label ||
                                    option.value ||
                                    ''
                                )
                            );
                        })
                        .join('\n')
                    : '';


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


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


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


                                    if (
            const configTitle =
                                        parts.length ===
                title.replace(/\/+$/, '') +
                                        1
                '/config.json';
                                    ) {
                                        return parts[0];
                                    }


                                    return {
            this.setStatus(
                                        value:
                'Saving preload and config...',
                                            parts[0],
                'loading'
                                        label:
            );
                                            parts
                                                .slice(
                                                    1
                                                )
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }


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


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


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


        createTransformControls(
                await this.savePage(
            field,
                    configTitle,
            property,
                    this.getConfigJSON(),
            title
                    configExists
        ) {
                        ? 'Updated preload config with Preload Editor'
            const wrapper =
                         : 'Created preload config with Preload Editor',
                $('<div>')
                     !configExists
                    .addClass(
                );
                         'preload-editor-transform'
                     );


            $('<h4>')
                this.state.mode = 'edit';
                 .text(title)
                 this.state.dirty = false;
                 .appendTo(wrapper);
                 this.state.preloadTitle = title;


            const existing =
                 this.setStatus(
                field[property] &&
                     'Preload and config saved successfully.',
                typeof field[property] ===
                     'success'
                    '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 =
                mw.notify(
                 this.textInput(
                    'Preload and config saved.',
                     existing.prefix || ''
                    { type: 'success' }
                );
             } catch (error) {
                 console.error(
                     'Preload Editor save:',
                    error
                 );
                 );


            const suffix =
                 this.setStatus(
                 this.textInput(
                     error.message ||
                     existing.suffix || ''
                        'Could not save preload/config.',
                    'error'
                 );
                 );


            const from =
                 mw.notify(
                 this.textInput(
                     error.message ||
                     existing.from || ''
                        'Could not save preload/config.',
                    { type: 'error' }
                 );
                 );
            }
        },


             const to =
        validateBeforeSave() {
                this.textInput(
             const errors = [];
                    existing.to || ''
            const warnings = [];
                );


             const controls =
             const title =
                 $('<div>')
                 String(
                     .addClass(
                     this.state.preloadTitle || ''
                        'preload-editor-transform-controls'
                ).trim();
                    );


             const prefixRow =
             if (!title) {
                 this.fieldRow(
                 errors.push(
                     'Prefix',
                     'A preload title is required.'
                    prefix
                 );
                 );
            }


             const suffixRow =
             if (
                 this.fieldRow(
                title &&
                     'Suffix',
                 !/\/Preload$/i.test(title)
                    suffix
            ) {
                warnings.push(
                     'The title does not end with "/Preload".'
                 );
                 );
            }


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


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


             const renderInputs = () => {
             if (!content.trim()) {
                const type =
                 errors.push(
                    transform.val();
                     'The preload cannot be empty.'
 
                 prefixRow.toggle(
                     type ===
                        'remove-prefix'
                 );
                 );
            }


                 suffixRow.toggle(
            if (!/<includeonly>/i.test(content)) {
                     type ===
                 warnings.push(
                        'remove-suffix'
                     'The preload does not contain an <includeonly> section.'
                 );
                 );
            }


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


                 toRow.toggle(
            const analysis =
                     type === 'replace'
                 this.analyzePreload(
                     this.extractIncludeonly(content)
                );
 
            const configValidation =
                this.validateConfig(
                    this.state.config,
                    analysis
                 );
                 );
            };


             const update = () => {
             errors.push(...configValidation.errors);
                const type =
            warnings.push(...configValidation.warnings);
                    transform.val();
 
            let depth = 0;


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


                if (
                    if (depth < 0) {
                    type ===
                        errors.push(
                    'remove-prefix'
                             'Found @endif without a matching @if.'
                ) {
                        );
                    if (prefix.val()) {
                         depth = 0;
                        object = {
                             transform: type,
                            prefix:
                                prefix.val()
                         };
                     }
                     }
                 } else if (
                 } else if (
                     type ===
                     condition.type === 'elseif' ||
                     'remove-suffix'
                     condition.type === 'else'
                 ) {
                 ) {
                     if (suffix.val()) {
                     if (depth === 0) {
                        object = {
                        errors.push(
                            transform: type,
                             '@' +
                            suffix:
                             condition.type +
                                suffix.val()
                             ' exists outside an @if block.'
                        };
                         );
                    }
                } 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) {
             if (depth > 0) {
Line 4,164: Line 2,730:
                 errors,
                 errors,
                 warnings,
                 warnings,
                 valid:
                 valid: errors.length === 0
                    errors.length === 0
             };
             };
         },
         },


         showValidationErrors(
         showValidationErrors(validation) {
            validation
        ) {
             this.renderAnalysisBySelector();
             this.renderAnalysisBySelector();


             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 =
     mw.PreloadEditor = PreloadEditor;
        PreloadEditor;


     $(function () {
     $(function () {
        const title =
            mw.config.get(
                'wgPageName'
            );
         if (
         if (
             title !==
             mw.config.get('wgPageName') !==
             'Petit_Planet:Preload_Editor'
             'Petit_Planet:Preload_Editor'
         ) {
         ) {