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

MediaWiki interface page
Revision as of 08:57, 21 September 2026 by ReisuDesign (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function (mw, $) {
    'use strict';

    const PreloadEditor = {
        version: '0.3.0',

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

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

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

        // API

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

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

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

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

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

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

            return response?.query?.search || [];
        },

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

            if (createOnly) params.createonly = true;

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

        // Preload parsing

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

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

            while ((match = pattern.exec(content)) !== null) {
                const name = match[1].toUpperCase();
                if (!variables.includes(name)) variables.push(name);
            }

            return variables;
        },

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

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

            return conditions;
        },

        findConditionVariables(content) {
            const variables = [];

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

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

                matches.forEach(name => {
                    name = name.toUpperCase();
                    if (!variables.includes(name)) variables.push(name);
                });
            });

            return variables;
        },

        analyzePreload(content) {
            return {
                variables: this.findVariables(content),
                conditions: this.findConditions(content),
                conditionVariables: this.findConditionVariables(content)
            };
        },

        // Config

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

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

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

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

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

            return config;
        },

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

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

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

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

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

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

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

            config.fields = fields;
            return config;
        },

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

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

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

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

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

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

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

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

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

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

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

        // Transformation helpers

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

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

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

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

                default:
                    return null;
            }
        },

        // UI helpers

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

            if (className) section.addClass(className);

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

            return section;
        },

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

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

            return button;
        },

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

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

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

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

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

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

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

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

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

            return select;
        },

        // CodeMirror

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

            const { view, inputHandler } = this.editor;

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

            if (view) view.destroy();

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

        getEditorView() {
            return this.editorView;
        },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            view.focus();
        },

        // V2.3 editor tools

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

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

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

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

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

            const selected = this.getEditorSelection();
            if (!selected) return;

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

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

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

            const selected = this.getEditorSelection();
            if (!selected) return;

            let text;
            let cursorOffset;

            switch (type) {
                case 'if': {
                    const opening = '<!-- @if ' + variable + ' -->';
                    const closing = '<!-- @endif -->';

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

                    break;
                }

                case 'elseif':
                    text = '<!-- @elseif -->';
                    cursorOffset = text.length;
                    break;

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

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

                default:
                    return;
            }

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

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

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

            return Array.from(variables);
        },

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

            if (className) select.addClass(className);

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

            return select;
        },

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

            const variables = this.getToolVariables();
            const current =
                this.editorTools.variableSelect.val();
            const currentCondition =
                this.editorTools.conditionVariableSelect.val();

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

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

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

            populate(
                this.editorTools.variableSelect,
                current
            );

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

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

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

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

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

            // Wrap selection

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

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

            // Insert existing variable

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

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

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

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

            // Insert condition

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

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

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

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

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

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

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

            conditionGroup.append(
                conditionTypeSelect,
                conditionVariableWrap,
                insertCondition
            );

            tools.append(
                wrapGroup,
                variableGroup,
                conditionGroup
            );

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

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

            return tools;
        },

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

        // Preload syntax highlighting

        getKnownVariables() {
            const known = new Set(['PAGE']);
            const fields = this.state.config?.fields;

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

            return known;
        },

        createSyntaxDecorations(state, Decoration) {
            const decorations = [];
            const source = state.doc.toString();
            const knownVariables = this.getKnownVariables();

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

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

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

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

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

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

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

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

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

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

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

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

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

                    const start =
                        expressionStart + expressionMatch.index;

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

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

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

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

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

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

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

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

            this.editor.syntaxField = syntaxField;
        },

        // CodeMirror mounting

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

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

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

                    settled = true;
                    cleanup();

                    const view = cm.view;

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

                        this.state.dirty = true;
                        this.reanalyze();
                        this.renderVariablesBySelector();
                        this.renderAnalysisBySelector();
                        this.renderConfigPreviewBySelector();
                        this.updateEditorTools();
                    };

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

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

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

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

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

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

                    try {
                        await this.installSyntaxHighlighting(view);
                        resolve(view);
                    } catch (error) {
                        reject(error);
                    }
                };

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

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

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

        // Main UI

        init() {
            const root = $('#mw-content-text');
            if (!root.length) return;

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

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

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

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

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

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

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

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

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

            this.root.append(loader);

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

            this.root.append(this.status);

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

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

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

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

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

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

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

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

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

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

            if (type) this.status.addClass('is-' + type);
            this.status.text(message || '');
        },

        // Load / create

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

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

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

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

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

                    this.renderMissingPage(title, suggestions);
                    this.setStatus(
                        'Preload not found.',
                        'warning'
                    );
                    return;
                }

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

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

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

            if (!query) query = title;

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

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

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

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

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

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

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

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

                section.append(list);
            }

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

            this.workspace.append(section);
        },

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

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

            this.renderEditor();

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

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

            if (!result) return;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                const input = this.textInput();

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

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

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

                        if (!value) return;

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

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

                input.trigger('focus');

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

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

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

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

            const configPage =
                await this.getPage(configTitle);

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

            const analysis =
                this.analyzePreload(content);

            config = this.prepareConfigForVariables(
                config,
                analysis
            );

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

            this.renderEditor();
        },

        // Editor

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

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

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

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

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

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

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

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

            editorSection.append(meta);

            editorSection.append(
                this.renderEditorTools()
            );

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

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

            editorSection.append(editorMount);
            left.append(editorSection);

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

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

            analysisSection.append(analysisContainer);

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

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

            variablesSection.append(variableContainer);

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

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

            configSection.append(configPreview);

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

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

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

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

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

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

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

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

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

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

            this.analysis = analysis;
        },

        renderAnalysis(container) {
            container.empty();

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

            const variableCount = analysis.variables.length;
            const conditionCount = analysis.conditions.length;

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

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

            container.append(summary);

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

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

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

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

                container.append(errors);
            }

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

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

                container.append(warnings);
            }

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

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

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

        renderVariables(container) {
            container.empty();

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

            const variables = analysis.variables.slice();
            const ordered = ['PAGE'];

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

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

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

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

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

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

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

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

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

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

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

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

            card.append(header);

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

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

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

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

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

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

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

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

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

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

                this.renderConfigPreviewBySelector();
            });

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

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

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

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

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

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

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

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

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

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

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

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

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

                return card;
            }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            return card;
        },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            renderInputs();
            wrapper.append(controls);

            return wrapper;
        },

        // Config output

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

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

            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) {
                container.text(this.getConfigJSON());
            }
        },

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

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

            this.renderAnalysisBySelector();
        },

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

        // Save

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

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

                if (!result) return;

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

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

            const validation =
                this.validateBeforeSave();

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

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

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

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

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

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

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

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

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

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

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

                if (!result) return;

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

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

            const validation =
                this.validateBeforeSave();

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

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

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

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

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

                const configExists =
                    await this.pageExists(configTitle);

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

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

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

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

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

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

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

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

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

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

            const content =
                this.getEditorContent();

            this.state.preloadContent = content;

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

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

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

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

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

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

            let depth = 0;

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

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

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

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

        showValidationErrors(validation) {
            this.renderAnalysisBySelector();

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

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

    mw.PreloadEditor = PreloadEditor;

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

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));