Jump to content

MediaWiki:Gadget-PreloadEditor.js

From The Petit Planet Wiki
Revision as of 07:14, 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.1.0',

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

        defaults: {
            preload:
                '<includeonly>\n' +
                '<!-- preload here -->\n' +
                '</includeonly>' +
                '<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) {
            const page = await this.getPage(title);
            return !!page;
        },

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

            if (!match) {
                return String(source || '').trim();
            }

            return match[1].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: variables,
                conditions: conditions,
                conditionVariables:
                    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 = {};
            }

            /*
             * PAGE is always present.
             *
             * Existing PAGE configuration is preserved.
             */
            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
                );

            let type = 'text';

            /*
             * Variables used directly as conditions
             * are most commonly booleans.
             *
             * This is only a suggestion; the editor
             * can change it.
             */
            if (isConditionVariable) {
                type = 'boolean';
            }

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

        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 = {};

            /*
             * PAGE is deliberately first.
             */
            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
                    );
            });

            /*
             * Preserve fields that exist in the config
             * but are no longer present in the preload.
             *
             * They are shown as unused rather than deleted.
             */
            Object.keys(existing).forEach(name => {
                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: errors,
                    warnings: 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: errors,
                warnings: warnings
            };
        },

        /* =========================================================
         * TRANSFORMATION HELPERS
         * ========================================================= */

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

            switch (transform) {
                case 'remove-prefix':
                    if (!prefix) {
                        return null;
                    }

                    return {
                        transform:
                            'remove-prefix',
                        prefix: prefix
                    };

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

                    return {
                        transform:
                            'remove-suffix',
                        suffix: suffix
                    };

                case 'replace':
                    if (from === '') {
                        return null;
                    }

                    return {
                        transform: 'replace',
                        from: 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'
                    );

            const labelElement =
                $('<label>')
                    .addClass(
                        'preload-editor-label'
                    )
                    .text(label);

            row.append(labelElement);

            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;
        },

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

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

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

        renderWelcome() {
            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 => {
                        const button =
                            this.createButton(
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            );

                        list.append(button);
                    }
                );

                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 dialog =
                this.createPromptDialog(
                    'Load Sample Page',
                    'Enter the title of a normal wiki page to use as the starting point.'
                );

            const result =
                await dialog;

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

            /*
             * If the sample itself contains includeonly,
             * use only its included content.
             *
             * Otherwise use the complete page.
             */
            const include =
                content.match(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                );

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

            /*
             * Remove documentation/noinclude blocks
             * from a normal sample page.
             */
            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';

            try {
                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
                        );
                    }
                }
            } catch (error) {
                /*
                 * A missing config is allowed.
                 * Other errors are re-thrown.
                 */
                if (
                    error &&
                    error.message &&
                    error.message.indexOf(
                        'invalid JSON'
                    ) !== -1
                ) {
                    throw error;
                }
            }

            const analysis =
                this.analyzePreload(
                    content
                );

            config =
                this.prepareConfigForVariables(
                    config,
                    analysis
                );

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

            this.renderEditor();
        },

        /* =========================================================
         * EDITOR
         * ========================================================= */

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

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

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

            const title =
                this.state.preloadTitle ||
                'New preload';

            $('<strong>')
                .text(title)
                .appendTo(meta);

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

            editor.append(meta);

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

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

                    this.state.dirty =
                        true;

                    this.reanalyze();

                    this.renderVariables(
                        variableContainer
                    );

                    this.renderAnalysis(
                        analysisContainer
                    );

                    this.renderConfigPreview(
                        configPreview
                    );
                }
            );

            editor.append(
                this.fieldRow(
                    'Preload wikitext',
                    textarea,
                    'This is the actual preload. Variables use %%VARIABLE%% syntax.'
                )
            );

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

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

            analysisSection.append(
                analysisContainer
            );

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

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

            variablesSection.append(
                variableContainer
            );

            /*
             * Config preview.
             */
            const configSection =
                this.createSection(
                    'Generated configuration'
                );

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

            configSection.append(
                configPreview
            );

            /*
             * Actions.
             */
            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
            );

            this.workspace.append(
                editor,
                analysisSection,
                variablesSection,
                configSection,
                actions
            );

            this.reanalyze();

            this.renderVariables(
                variableContainer
            );

            this.renderAnalysis(
                analysisContainer
            );

            this.renderConfigPreview(
                configPreview
            );
        },

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

            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.state.preloadContent
                );

            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'
                        )
                    )
            );

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

            container.append(summary);

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

                container.append(note);
            }

            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.'
                        )
                );
            }
        },

        /* =========================================================
         * VARIABLE CARDS
         * ========================================================= */

        renderVariables(container) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.state.preloadContent
                );

            const variables =
                analysis.variables.slice();

            /*
             * PAGE always comes first.
             */
            const ordered = [];

            ordered.push('PAGE');

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

            /*
             * Preserve unused config fields after
             * actual variables.
             */
            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;

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

                container.append(card);
            });
        },

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

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

            const heading =
                $('<h3>')
                    .text(name);

            header.append(heading);

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

            /*
             * Existing field object is mutated by
             * the controls below.
             */

            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 || ''
                );

            description
                .addClass(
                    'preload-editor-small-textarea'
                )
                .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: value,
                            label: label
                        })
                    ),
                    field.type || 'text'
                );

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

                    this.markDirty();

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

                    card.replaceWith(
                        newCard
                    );

                    this.renderConfigPreviewBySelector();
                }
            );

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

            /*
             * PAGE has special controls.
             */
            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;
            }

            /*
             * Required.
             */
            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
                )
            );

            /*
             * Placeholder.
             */
            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
                )
            );

            /*
             * Default.
             */
            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.'
                )
            );

            /*
             * Textarea-specific maxRows.
             */
            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
                    )
                );
            }

            /*
             * Select/multiselect source.
             */
            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
                )
            );

            /*
             * Value transformation.
             */
            card.append(
                this.createTransformControls(
                    field,
                    'value',
                    'Value transformation'
                )
            );

            /*
             * Label transformation.
             */
            card.append(
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );

            /*
             * Manual options.
             */
            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 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
                );
            });

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

            controls.append(
                this.fieldRow(
                    'Transformation',
                    transform
                )
            );

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

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

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

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

            controls.append(
                prefixRow,
                suffixRow,
                fromRow,
                toRow
            );

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

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

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

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

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

            renderInputs();

            wrapper.append(
                controls
            );

            return wrapper;
        },

        /* =========================================================
         * CONFIG OUTPUT
         * ========================================================= */

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

            /*
             * Keep PAGE first.
             */
            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
                );
            }

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

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

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

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

            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 =
                String(
                    this.state.preloadContent ||
                    ''
                );

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

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

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

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

            /*
             * Basic conditional marker validation.
             */
            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: errors,
                warnings: warnings,
                valid:
                    errors.length === 0
            };
        },

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

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

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

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

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

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

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));