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 07:55, 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.2.0',

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

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

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

    // API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    // Preload parsing

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

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

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

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

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

        return variables;
    },

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

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

        return conditions;
    },

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

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

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

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

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

        return variables;
    },

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

        return {
            variables,
            conditions,
            conditionVariables
        };
    },

    // Config

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

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

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

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

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

        return config;
    },

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

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

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

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

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

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

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

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

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

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

        config.fields = fields;
        return config;
    },

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

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

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

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

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

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

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

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

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

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

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

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

    // Transformation helpers

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

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

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

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

            default:
                return null;
        }
    },

    // UI helpers

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

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

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

        return section;
    },

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

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

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

        return button;
    },

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

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

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

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

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

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

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

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

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

        return select;
    },

    // CodeMirror

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

        const {
            view,
            inputHandler
        } = this.editor;

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

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

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

    getEditorView() {
        return this.editorView;
    },

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

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

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

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

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

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

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

    // Preload syntax highlighting

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

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

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

        return known;
    },

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

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

        let match;

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

            const known =
                knownVariables.has(name);

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

            decorations.push(
                Decoration.mark({
                    class: classes.join(' ')
                }).range(
                    match.index,
                    match.index + match[0].length
                )
            );
        }

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

        while (
            (match = conditionPattern.exec(source)) !== null
        ) {
            const fullStart = match.index;
            const fullEnd =
                match.index + match[0].length;

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

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

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

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

            if (
                match[1].toLowerCase() === 'if' ||
                match[1].toLowerCase() === 'elseif'
            ) {
                const expression =
                    match[2];

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

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

                let expressionMatch;

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

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

                    const start =
                        expressionStart +
                        expressionMatch.index;

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

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

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

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

        const require =
            mw.loader.require;

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

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

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

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

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

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

        this.editor.syntaxField =
            syntaxField;
    },

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

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

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

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

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

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

                this.editorView = view;

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

                try {
                    await this.installSyntaxHighlighting(
                        view
                    );

                    resolve(view);
                } catch (error) {
                    reject(error);
                }
            };

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

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

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

    // Main UI

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

        if (!root.length) {
            return;
        }

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

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

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

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

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

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

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

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

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

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

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

        this.root.append(this.status);

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

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

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

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

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

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

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

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

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

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

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

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

    // Load / create

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

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

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

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

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

                this.renderMissingPage(
                    title,
                    suggestions
                );

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

                return;
            }

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

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

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

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

        if (!query) {
            query = title;
        }

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

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

            return [];
        }
    },

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

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

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

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

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

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

            section.append(list);
        }

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

        this.workspace.append(section);
    },

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

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

        this.renderEditor();

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

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

        if (!result) {
            return;
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            const input = this.textInput();

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

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

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

                        if (!value) {
                            return;
                        }

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

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

            input.trigger('focus');

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

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

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

        let config = this.emptyConfig();

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

        const configPage =
            await this.getPage(configTitle);

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

        const analysis =
            this.analyzePreload(content);

        config =
            this.prepareConfigForVariables(
                config,
                analysis
            );

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

        this.renderEditor();
    },

    // Editor

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

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

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

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

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

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

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

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

        editorSection.append(meta);

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

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

        editorSection.append(
            editorMount
        );

        left.append(editorSection);

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

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

        analysisSection.append(
            analysisContainer
        );

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

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

        variablesSection.append(
            variableContainer
        );

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

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

        configSection.append(configPreview);

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

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

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

        actions.append(save, savePreload);

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

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

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

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

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

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

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

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

        this.analysis = analysis;
    },

    renderAnalysis(container) {
        container.empty();

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

        const variableCount =
            analysis.variables.length;

        const conditionCount =
            analysis.conditions.length;

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

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

        container.append(summary);

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

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

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

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

            container.append(errors);
        }

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

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

            container.append(warnings);
        }

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

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

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

    renderVariables(container) {
        container.empty();

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

        const variables =
            analysis.variables.slice();

        const ordered = ['PAGE'];

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

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

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

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

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

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

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

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

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

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

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

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

        card.append(header);

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

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

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

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

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

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

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

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

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

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

                this.renderConfigPreviewBySelector();
            }
        );

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            return card;
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return card;
    },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        renderInputs();

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

    // Config output

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

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

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

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

        config.fields = orderedFields;
        return config;
    },

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

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

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

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

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

        this.renderAnalysisBySelector();
    },

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

    // Save

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

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

            if (!result) {
                return;
            }

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

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

        const validation =
            this.validateBeforeSave();

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

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

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

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

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

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

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

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

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

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

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

            if (!result) {
                return;
            }

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

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

        const validation =
            this.validateBeforeSave();

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

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

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

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

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

            const configExists =
                await this.pageExists(configTitle);

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

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

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

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

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

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

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

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

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

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

        const content =
            this.getEditorContent();

        this.state.preloadContent = content;

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

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

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

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

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

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

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

        let depth = 0;

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

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

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

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

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

    showValidationErrors(validation) {
        this.renderAnalysisBySelector();

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

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

mw.PreloadEditor = PreloadEditor;

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

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

    PreloadEditor.init();
});

}(mediaWiki, jQuery));