MediaWiki:Gadget-PreloadEditor.js
MediaWiki interface page
More actions
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function (mw, $) {
'use strict';
const PreloadEditor = {
version: '0.3.0',
state: {
preloadTitle: '',
preloadSource: '',
preloadContent: '',
config: null,
variables: [],
conditions: [],
mode: 'idle',
dirty: false
},
editor: null,
editorView: null,
analysis: null,
root: null,
workspace: null,
status: null,
editorTools: null,
defaults: {
preload:
'<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
},
// API
async getPage(title) {
const response = await new mw.Api().get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
});
const page = response?.query?.pages?.[0];
if (!page || page.missing) return null;
const content =
page.revisions?.[0]?.slots?.main?.content;
return {
title: page.title || title,
content: typeof content === 'string' ? content : ''
};
},
async pageExists(title) {
return !!await this.getPage(title);
},
async searchPages(query) {
const response = await new mw.Api().get({
action: 'query',
list: 'search',
srsearch: query,
srlimit: 10,
srnamespace: 10,
formatversion: 2
});
return response?.query?.search || [];
},
async savePage(title, content, summary, createOnly) {
const params = {
action: 'edit',
title,
text: content,
summary: summary || 'Updated with Preload Editor',
formatversion: 2
};
if (createOnly) params.createonly = true;
return new mw.Api().postWithToken('csrf', params);
},
// Preload parsing
extractIncludeonly(source) {
const match = String(source || '').match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
return match ? match[1].trim() : String(source || '').trim();
},
findVariables(content) {
const variables = [];
const pattern = /%%([A-Za-z0-9_-]+)%%/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const name = match[1].toUpperCase();
if (!variables.includes(name)) variables.push(name);
}
return variables;
},
findConditions(content) {
const conditions = [];
const pattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
let match;
while ((match = pattern.exec(content)) !== null) {
conditions.push({
type: match[1].toLowerCase(),
expression: match[2].trim(),
index: match.index
});
}
return conditions;
},
findConditionVariables(content) {
const variables = [];
this.findConditions(content).forEach(condition => {
if (condition.type !== 'if' &&
condition.type !== 'elseif') return;
const matches =
condition.expression.match(/[A-Za-z0-9_-]+/g) || [];
matches.forEach(name => {
name = name.toUpperCase();
if (!variables.includes(name)) variables.push(name);
});
});
return variables;
},
analyzePreload(content) {
return {
variables: this.findVariables(content),
conditions: this.findConditions(content),
conditionVariables: this.findConditionVariables(content)
};
},
// Config
emptyConfig() {
return {
settings: { incomplete: 'add-stub' },
fields: {
PAGE: {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
}
}
};
},
normalizeConfig(config) {
if (!config ||
typeof config !== 'object' ||
Array.isArray(config)) {
config = this.emptyConfig();
}
if (!config.settings ||
typeof config.settings !== 'object' ||
Array.isArray(config.settings)) {
config.settings = {};
}
if (!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)) {
config.fields = {};
}
if (!config.fields.PAGE) {
config.fields.PAGE = {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
}
return config;
},
createDefaultField(name, analysis) {
return {
label: this.humanizeName(name),
description: '',
type: analysis.conditionVariables.includes(name)
? 'boolean'
: 'text'
};
},
humanizeName(name) {
return String(name || '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, char => char.toUpperCase());
},
prepareConfigForVariables(config, analysis) {
config = this.normalizeConfig(config);
const existing = config.fields || {};
const fields = {};
fields.PAGE = existing.PAGE || {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
analysis.variables.forEach(name => {
if (name === 'PAGE') return;
fields[name] =
existing[name] ||
this.createDefaultField(name, analysis);
});
Object.keys(existing).forEach(name => {
const upper = name.toUpperCase();
if (!fields[upper]) fields[upper] = existing[name];
});
config.fields = fields;
return config;
},
validateConfig(config, analysis) {
const errors = [];
const warnings = [];
if (!config ||
typeof config !== 'object' ||
Array.isArray(config)) {
return {
valid: false,
errors: ['Config must be an object.'],
warnings: []
};
}
if (!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)) {
return {
valid: false,
errors: ['"fields" must be an object.'],
warnings
};
}
const configured =
Object.keys(config.fields).map(name => name.toUpperCase());
analysis.variables.forEach(name => {
if (!configured.includes(name)) {
errors.push(
'Variable "' + name + '" is not configured.'
);
}
});
configured.forEach(name => {
if (!analysis.variables.includes(name)) {
warnings.push(
'Field "' + name + '" is not used by the preload.'
);
}
});
if (!configured.includes('PAGE')) {
errors.push('PAGE must always be configured.');
}
Object.entries(config.fields).forEach(([name, field]) => {
if (!field ||
typeof field !== 'object' ||
Array.isArray(field)) {
errors.push(
'Field "' + name + '" must be an object.'
);
return;
}
if (!field.type) {
warnings.push(
'Field "' + name + '" has no type.'
);
}
if ((field.type === 'select' ||
field.type === 'multiselect') &&
!field.source &&
!Array.isArray(field.options)) {
warnings.push(
'Field "' + name + '" is a ' +
field.type +
' without a source or manual options.'
);
}
});
return {
valid: !errors.length,
errors,
warnings
};
},
// Transformation helpers
transformationObject(transform, prefix, suffix, from, to) {
if (!transform) return null;
switch (transform) {
case 'remove-prefix':
return prefix
? { transform, prefix }
: null;
case 'remove-suffix':
return suffix
? { transform, suffix }
: null;
case 'replace':
return from === ''
? null
: { transform, from, to: to || '' };
default:
return null;
}
},
// UI helpers
createSection(title, className) {
const section = $('<section>')
.addClass('preload-editor-section');
if (className) section.addClass(className);
$('<h2>')
.addClass('preload-editor-section-title')
.text(title)
.appendTo(section);
return section;
},
createButton(label, className, callback) {
const button = $('<button>')
.attr('type', 'button')
.addClass('preload-editor-button')
.text(label);
if (className) button.addClass(className);
if (callback) {
button.on('click.preloadEditor', callback);
}
return button;
},
fieldRow(label, input, description) {
const row = $('<div>')
.addClass('preload-editor-field');
$('<label>')
.addClass('preload-editor-label')
.text(label)
.appendTo(row);
if (description) {
$('<div>')
.addClass('preload-editor-help')
.text(description)
.appendTo(row);
}
row.append(input);
return row;
},
textInput(value, placeholder) {
return $('<input>')
.attr({
type: 'text',
value: value == null ? '' : value,
placeholder: placeholder || ''
})
.addClass('preload-editor-input');
},
textarea(value) {
return $('<textarea>')
.val(value == null ? '' : value)
.addClass('preload-editor-textarea');
},
checkbox(checked) {
return $('<input>')
.attr('type', 'checkbox')
.prop('checked', !!checked)
.addClass('preload-editor-checkbox');
},
select(options, value) {
const select = $('<select>')
.addClass('preload-editor-select');
options.forEach(option => {
$('<option>')
.attr('value', option.value)
.text(option.label)
.prop(
'selected',
String(option.value) === String(value ?? '')
)
.appendTo(select);
});
return select;
},
// CodeMirror
destroyEditor() {
if (!this.editor) {
this.editorView = null;
this.editorTools = null;
return;
}
const { view, inputHandler } = this.editor;
if (view && inputHandler) {
view.dom.removeEventListener('input', inputHandler);
}
if (view) view.destroy();
this.editor = null;
this.editorView = null;
this.editorTools = null;
},
getEditorView() {
return this.editorView;
},
getEditorContent() {
if (this.editorView?.state) {
return this.editorView.state.doc.toString();
}
return this.state.preloadContent || '';
},
setEditorContent(content) {
content = String(content || '');
if (!this.editorView) {
this.state.preloadContent = content;
return;
}
const current = this.editorView.state.doc.toString();
if (current === content) return;
this.editorView.dispatch({
changes: {
from: 0,
to: this.editorView.state.doc.length,
insert: content
}
});
},
getEditorSelection() {
if (!this.editorView?.state) return null;
const selection = this.editorView.state.selection.main;
return {
from: selection.from,
to: selection.to,
text: this.editorView.state.doc.sliceString(
selection.from,
selection.to
)
};
},
focusEditor() {
if (this.editorView) this.editorView.focus();
},
insertEditorText(
text,
selectionFrom,
selectionTo,
cursorPosition
) {
if (!this.editorView) return;
const view = this.editorView;
const selection = view.state.selection.main;
const from =
selectionFrom === undefined
? selection.from
: selectionFrom;
const to =
selectionTo === undefined
? selection.to
: selectionTo;
const cursor =
cursorPosition === undefined
? from + text.length
: cursorPosition;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: cursor }
});
view.focus();
},
// V2.3 editor tools
wrapSelectionWithVariable() {
const selected = this.getEditorSelection();
if (!selected || selected.from === selected.to) {
this.focusEditor();
return;
}
const replacement =
'%%' + selected.text + '%%';
this.insertEditorText(
replacement,
selected.from,
selected.to,
selected.from + replacement.length
);
},
insertVariable(name) {
name = String(name || '').trim().toUpperCase();
if (!name) return;
const selected = this.getEditorSelection();
if (!selected) return;
const text = '%%' + name + '%%';
this.insertEditorText(
text,
selected.from,
selected.to
);
},
insertCondition(type, variable) {
type = String(type || '').toLowerCase();
variable = String(variable || '').trim().toUpperCase();
const selected = this.getEditorSelection();
if (!selected) return;
let text;
let cursorOffset;
switch (type) {
case 'if': {
const opening = '<!-- @if ' + variable + ' -->';
const closing = '<!-- @endif -->';
if (selected.from !== selected.to) {
text =
opening +
'\n' +
selected.text +
'\n' +
closing;
cursorOffset = text.length;
} else {
text =
opening +
'\n\n' +
closing;
cursorOffset = opening.length + 1;
}
break;
}
case 'elseif':
text = '<!-- @elseif -->';
cursorOffset = text.length;
break;
case 'else':
text = '<!-- @else -->';
cursorOffset = text.length;
break;
case 'endif':
text = '<!-- @endif -->';
cursorOffset = text.length;
break;
default:
return;
}
this.insertEditorText(
text,
selected.from,
selected.to,
selected.from + cursorOffset
);
},
getToolVariables() {
const variables = new Set(['PAGE']);
if (this.analysis?.variables) {
this.analysis.variables.forEach(name => {
variables.add(String(name).toUpperCase());
});
}
return Array.from(variables);
},
createToolSelect(options, className) {
const select = $('<select>')
.addClass('preload-editor-tool-select');
if (className) select.addClass(className);
options.forEach(option => {
$('<option>')
.attr('value', option.value)
.text(option.label)
.appendTo(select);
});
return select;
},
updateEditorToolVariables() {
if (!this.editorTools) return;
const variables = this.getToolVariables();
const current =
this.editorTools.variableSelect.val();
const currentCondition =
this.editorTools.conditionVariableSelect.val();
const populate = (select, value) => {
select.empty();
variables.forEach(name => {
$('<option>')
.attr('value', name)
.text(name)
.appendTo(select);
});
if (value && variables.includes(value)) {
select.val(value);
} else if (variables.length) {
select.val(variables[0]);
}
};
populate(
this.editorTools.variableSelect,
current
);
populate(
this.editorTools.conditionVariableSelect,
currentCondition
);
},
updateConditionVariableVisibility() {
if (!this.editorTools) return;
const type =
this.editorTools.conditionTypeSelect.val();
this.editorTools.conditionVariableWrap.toggle(
type === 'if'
);
},
renderEditorTools() {
const tools = $('<div>')
.addClass('preload-editor-tools');
// Wrap selection
const wrapGroup = $('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-wrap'
);
wrapGroup.append(
this.createButton(
'Wrap selection with %%',
'preload-editor-tool-button',
() => this.wrapSelectionWithVariable()
)
);
// Insert existing variable
const variableGroup = $('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-variable'
);
$('<span>')
.addClass('preload-editor-tool-label')
.text('Insert existing variable')
.appendTo(variableGroup);
const variableSelect = this.createToolSelect(
[],
'preload-editor-variable-select'
);
variableGroup.append(
variableSelect,
this.createButton(
'Insert',
'preload-editor-tool-button',
() => this.insertVariable(variableSelect.val())
)
);
// Insert condition
const conditionGroup = $('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-condition'
);
$('<span>')
.addClass('preload-editor-tool-label')
.text('Insert condition')
.appendTo(conditionGroup);
const conditionTypeSelect = this.createToolSelect(
[
{ value: 'if', label: '@if' },
{ value: 'elseif', label: '@elseif' },
{ value: 'else', label: '@else' },
{ value: 'endif', label: '@endif' }
],
'preload-editor-condition-type-select'
);
const conditionVariableSelect = this.createToolSelect(
[],
'preload-editor-condition-variable-select'
);
const conditionVariableWrap = $('<span>')
.addClass(
'preload-editor-condition-variable'
)
.append(conditionVariableSelect);
const insertCondition = this.createButton(
'Insert',
'preload-editor-tool-button',
() => this.insertCondition(
conditionTypeSelect.val(),
conditionVariableSelect.val()
)
);
conditionTypeSelect.on(
'change.preloadEditor',
() => this.updateConditionVariableVisibility()
);
conditionGroup.append(
conditionTypeSelect,
conditionVariableWrap,
insertCondition
);
tools.append(
wrapGroup,
variableGroup,
conditionGroup
);
this.editorTools = {
root: tools,
variableSelect,
conditionTypeSelect,
conditionVariableSelect,
conditionVariableWrap,
insertCondition
};
this.updateEditorToolVariables();
this.updateConditionVariableVisibility();
return tools;
},
updateEditorTools() {
this.updateEditorToolVariables();
this.updateConditionVariableVisibility();
},
// Preload syntax highlighting
getKnownVariables() {
const known = new Set(['PAGE']);
const fields = this.state.config?.fields;
if (fields && typeof fields === 'object') {
Object.keys(fields).forEach(name => {
known.add(String(name).toUpperCase());
});
}
return known;
},
createSyntaxDecorations(state, Decoration) {
const decorations = [];
const source = state.doc.toString();
const knownVariables = this.getKnownVariables();
const variablePattern = /%%([A-Za-z0-9_-]+)%%/g;
let match;
while ((match = variablePattern.exec(source)) !== null) {
const name = match[1].toUpperCase();
const known = knownVariables.has(name);
decorations.push(
Decoration.mark({
class: [
'preload-editor-syntax-variable',
known
? 'preload-editor-syntax-variable-known'
: 'preload-editor-syntax-variable-unknown'
].join(' ')
}).range(
match.index,
match.index + match[0].length
)
);
}
const conditionPattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
while ((match = conditionPattern.exec(source)) !== null) {
decorations.push(
Decoration.mark({
class: 'preload-editor-syntax-condition'
}).range(
match.index,
match.index + match[0].length
)
);
const directiveStart =
match.index + match[0].indexOf('@');
decorations.push(
Decoration.mark({
class: 'preload-editor-syntax-directive'
}).range(
directiveStart,
directiveStart + match[1].length + 1
)
);
const type = match[1].toLowerCase();
if (type !== 'if' && type !== 'elseif') continue;
const expression = match[2];
const expressionStart =
match.index + match[0].indexOf(expression);
const expressionPattern = /[A-Za-z0-9_-]+/g;
let expressionMatch;
while (
(expressionMatch =
expressionPattern.exec(expression)) !== null
) {
const name =
expressionMatch[0].toUpperCase();
if (!knownVariables.has(name)) continue;
const start =
expressionStart + expressionMatch.index;
decorations.push(
Decoration.mark({
class:
'preload-editor-syntax-condition-variable'
}).range(
start,
start + expressionMatch[0].length
)
);
}
}
return Decoration.set(decorations, true);
},
async installSyntaxHighlighting(view) {
await mw.loader.using('ext.CodeMirror.v6.lib');
const require = mw.loader.require;
const {
StateField,
StateEffect,
EditorView,
Decoration
} = require('ext.CodeMirror.v6.lib');
const syntaxField = StateField.define({
create: state =>
this.createSyntaxDecorations(state, Decoration),
update: (decorations, transaction) =>
transaction.docChanged
? this.createSyntaxDecorations(
transaction.state,
Decoration
)
: decorations,
provide: field =>
EditorView.decorations.from(field)
});
view.dispatch({
effects: StateEffect.appendConfig.of(syntaxField)
});
this.editor.syntaxField = syntaxField;
},
// CodeMirror mounting
async mountCodeMirror(textarea) {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
mw.hook('dev.CCM.ready').remove(onReady);
mw.hook('dev.CCM.load').remove(onLoad);
};
const onReady = async cm => {
if (
settled ||
!cm ||
cm.textarea !== textarea ||
!cm.view
) return;
settled = true;
cleanup();
const view = cm.view;
const inputHandler = () => {
this.state.preloadContent =
view.state.doc.toString();
this.state.dirty = true;
this.reanalyze();
this.renderVariablesBySelector();
this.renderAnalysisBySelector();
this.renderConfigPreviewBySelector();
this.updateEditorTools();
};
view.dom.addEventListener(
'input',
inputHandler
);
view.dom.classList.add(
'preload-editor-codemirror'
);
const wrapper =
view.dom.closest(
'.ext-codemirror-wrapper'
);
if (wrapper) {
wrapper.classList.add(
'preload-editor-codemirror-wrapper'
);
}
this.editor = {
cm,
view,
textarea,
inputHandler
};
this.editorView = view;
this.state.preloadContent =
view.state.doc.toString();
try {
await this.installSyntaxHighlighting(view);
resolve(view);
} catch (error) {
reject(error);
}
};
const onLoad = loader => {
if (
settled ||
typeof loader !== 'function'
) return;
try {
loader(textarea);
} catch (error) {
settled = true;
cleanup();
reject(error);
}
};
mw.hook('dev.CCM.ready').add(onReady);
mw.hook('dev.CCM.load').add(onLoad);
});
},
// Main UI
init() {
const root = $('#mw-content-text');
if (!root.length) return;
this.root = $('<div>')
.attr('id', 'preload-editor')
.addClass('preload-editor');
root.append(this.root);
this.renderShell();
},
renderShell() {
this.destroyEditor();
this.root.empty();
const loader = this.createSection('Preload');
const titleInput = this.textInput(
this.state.preloadTitle,
'Template:Example/Preload'
).attr('id', 'preload-editor-title-input');
const loadButton = this.createButton(
'Load',
'preload-editor-button-primary',
() => this.loadExisting(titleInput.val())
);
const newButton = this.createButton(
'New',
'',
() => this.startNew()
);
const sampleButton = this.createButton(
'Load Sample Page',
'',
() => this.loadSamplePage()
);
loader.append(
$('<div>')
.addClass('preload-editor-toolbar')
.append(
titleInput,
loadButton,
newButton,
sampleButton
)
);
this.root.append(loader);
this.status = $('<div>')
.addClass('preload-editor-status');
this.root.append(this.status);
this.workspace = $('<div>')
.addClass('preload-editor-workspace');
this.root.append(this.workspace);
this.renderWelcome();
},
renderWelcome() {
this.workspace.empty();
const section = this.createSection('Get started');
$('<p>')
.text(
'Load an existing preload, create a new one, or start from a sample wiki page.'
)
.appendTo(section);
const tips = $('<ul>')
.addClass('preload-editor-tips');
[
'Existing preload: loads the preload and its config.json if available.',
'New preload: starts with the standard Preload Template structure.',
'Sample page: loads normal wiki page content so you can turn it into a preload.'
].forEach(text => {
$('<li>').text(text).appendTo(tips);
});
section.append(tips);
this.workspace.append(section);
},
setStatus(message, type) {
if (!this.status) return;
this.status.removeClass(
'is-error',
'is-success',
'is-warning',
'is-loading'
);
if (type) this.status.addClass('is-' + type);
this.status.text(message || '');
},
// Load / create
async loadExisting(title) {
title = String(title || '').trim();
if (!title) {
mw.notify(
'Enter a preload page title first.',
{ type: 'warn' }
);
return;
}
this.setStatus('Loading preload...', 'loading');
try {
const page = await this.getPage(title);
if (!page) {
const suggestions =
await this.findSimilarPreloads(title);
this.renderMissingPage(title, suggestions);
this.setStatus(
'Preload not found.',
'warning'
);
return;
}
await this.loadDefinition(
page.title,
page.content
);
this.setStatus(
'Preload loaded.',
'success'
);
} catch (error) {
console.error('Preload Editor:', error);
this.setStatus(
error.message || 'Could not load preload.',
'error'
);
}
},
async findSimilarPreloads(title) {
let query = title
.replace(/\/Preload$/i, '')
.trim();
if (!query) query = title;
try {
const results = await this.searchPages(query);
return results.filter(result =>
/\/Preload$/i.test(result.title)
);
} catch (error) {
console.error(
'Preload Editor search:',
error
);
return [];
}
},
renderMissingPage(title, suggestions) {
this.workspace.empty();
const section = this.createSection('Preload not found');
$('<p>')
.text('No preload exists at "' + title + '".')
.appendTo(section);
if (suggestions.length) {
$('<h3>')
.text('Similar preloads')
.appendTo(section);
const list = $('<div>')
.addClass('preload-editor-suggestions');
suggestions.forEach(result => {
list.append(
this.createButton(
result.title,
'preload-editor-suggestion',
() => this.loadExisting(result.title)
)
);
});
section.append(list);
}
section.append(
this.createButton(
'Create this preload',
'preload-editor-button-primary',
() => this.startNew(title)
)
);
this.workspace.append(section);
},
async startNew(title) {
title = String(title || '').trim();
this.state = {
preloadTitle: title,
preloadSource: '',
preloadContent: this.defaults.preload,
config: this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.renderEditor();
this.setStatus(
'Creating a new preload.',
'success'
);
},
async loadSamplePage() {
const result = await this.createPromptDialog(
'Load Sample Page',
'Enter the title of a normal wiki page to use as the starting point.'
);
if (!result) return;
this.setStatus(
'Loading sample page...',
'loading'
);
try {
const page = await this.getPage(result);
if (!page) {
throw new Error(
'Sample page not found: ' + result
);
}
const sample =
this.convertSampleToPreload(page.content);
this.state = {
preloadTitle: '',
preloadSource: '',
preloadContent: sample,
config: this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.reanalyze();
this.renderEditor();
this.setStatus(
'Sample page loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor sample:',
error
);
this.setStatus(
error.message ||
'Could not load sample page.',
'error'
);
}
},
convertSampleToPreload(source) {
let content = String(source || '').trim();
const include = content.match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
if (include) content = include[1].trim();
content = content
.replace(
/<noinclude>[\s\S]*?<\/noinclude>/gi,
''
)
.trim();
return (
'<includeonly>\n' +
content +
'\n</includeonly>\n' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
);
},
createPromptDialog(title, message) {
return new Promise(resolve => {
const overlay = $('<div>')
.addClass('preload-editor-prompt-overlay');
const box = $('<div>')
.addClass('preload-editor-prompt');
$('<h2>')
.text(title)
.appendTo(box);
$('<p>')
.text(message)
.appendTo(box);
const input = this.textInput();
const buttons = $('<div>')
.addClass('preload-editor-prompt-buttons');
const cancel = this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const confirm = this.createButton(
'Load',
'preload-editor-button-primary',
() => {
const value =
String(input.val() || '').trim();
if (!value) return;
overlay.remove();
resolve(value);
}
);
buttons.append(cancel, confirm);
box.append(input, buttons);
overlay.append(box);
$(document.body).append(overlay);
input.trigger('focus');
input.on(
'keydown.preloadEditor',
event => {
if (event.key === 'Enter') {
confirm.trigger('click');
}
if (event.key === 'Escape') {
cancel.trigger('click');
}
}
);
});
},
async loadDefinition(title, source) {
const content = this.extractIncludeonly(source);
let config = this.emptyConfig();
const configTitle =
title.replace(/\/+$/, '') +
'/config.json';
const configPage =
await this.getPage(configTitle);
if (configPage) {
try {
config = JSON.parse(configPage.content);
} catch (error) {
throw new Error(
'The existing config.json contains invalid JSON: ' +
error.message
);
}
}
const analysis =
this.analyzePreload(content);
config = this.prepareConfigForVariables(
config,
analysis
);
this.state = {
preloadTitle: title,
preloadSource: source,
preloadContent: source,
config,
variables: analysis.variables,
conditions: analysis.conditions,
mode: 'edit',
dirty: false
};
this.renderEditor();
},
// Editor
renderEditor() {
this.destroyEditor();
this.workspace.empty();
const layout = $('<div>')
.addClass('preload-editor-layout');
const left = $('<div>')
.addClass('preload-editor-left');
const right = $('<div>')
.addClass('preload-editor-right');
const editorSection = this.createSection(
'Preload',
'preload-editor-main-section'
);
const meta = $('<div>')
.addClass('preload-editor-meta');
$('<strong>')
.text(this.state.preloadTitle || 'New preload')
.appendTo(meta);
if (this.state.mode === 'new') {
$('<span>')
.addClass('preload-editor-badge')
.text('NEW')
.appendTo(meta);
}
editorSection.append(meta);
editorSection.append(
this.renderEditorTools()
);
const textarea = this.textarea(
this.state.preloadContent
)
.attr('id', 'preload-editor-content')
.addClass('preload-editor-content');
const editorMount = $('<div>')
.addClass('preload-editor-codemirror-mount')
.append(textarea);
editorSection.append(editorMount);
left.append(editorSection);
const analysisSection =
this.createSection('Analysis');
const analysisContainer = $('<div>')
.addClass('preload-editor-analysis');
analysisSection.append(analysisContainer);
const variablesSection =
this.createSection('Variables');
const variableContainer = $('<div>')
.addClass('preload-editor-variables');
variablesSection.append(variableContainer);
const configSection =
this.createSection('Generated configuration');
const configPreview = $('<pre>')
.addClass('preload-editor-config-preview');
configSection.append(configPreview);
const actions = $('<div>')
.addClass('preload-editor-actions');
actions.append(
this.createButton(
'Save Preload + Config',
'preload-editor-button-primary',
() => this.saveAll()
),
this.createButton(
'Save Preload Only',
'',
() => this.savePreloadOnly()
)
);
right.append(
analysisSection,
variablesSection,
configSection,
actions
);
layout.append(left, right);
this.workspace.append(layout);
this.reanalyze();
this.renderVariables(variableContainer);
this.renderAnalysis(analysisContainer);
this.renderConfigPreview(configPreview);
this.updateEditorTools();
this.mountCodeMirror(textarea.get(0)).catch(error => {
console.error(
'Preload Editor CodeMirror:',
error
);
this.setStatus(
error.message ||
'Could not initialize CodeMirror.',
'error'
);
});
},
reanalyze() {
const source = this.getEditorContent();
const content = this.extractIncludeonly(source);
const analysis = this.analyzePreload(content);
this.state.preloadContent = source;
this.state.variables = analysis.variables;
this.state.conditions = analysis.conditions;
this.state.config =
this.prepareConfigForVariables(
this.state.config ||
this.emptyConfig(),
analysis
);
this.analysis = analysis;
},
renderAnalysis(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variableCount = analysis.variables.length;
const conditionCount = analysis.conditions.length;
const summary = $('<div>')
.addClass('preload-editor-analysis-summary');
summary.append(
$('<span>').text(
variableCount +
' variable' +
(variableCount === 1 ? '' : 's')
),
$('<span>').text(
conditionCount +
' condition marker' +
(conditionCount === 1 ? '' : 's')
)
);
container.append(summary);
if (analysis.conditionVariables.length) {
container.append(
$('<div>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'Condition variables: ' +
analysis.conditionVariables.join(', ')
)
);
}
const validation = this.validateConfig(
this.state.config,
analysis
);
if (validation.errors.length) {
const errors = $('<ul>')
.addClass(
'preload-editor-validation-errors'
);
validation.errors.forEach(error => {
$('<li>').text(error).appendTo(errors);
});
container.append(errors);
}
if (validation.warnings.length) {
const warnings = $('<ul>')
.addClass(
'preload-editor-validation-warnings'
);
validation.warnings.forEach(warning => {
$('<li>').text(warning).appendTo(warnings);
});
container.append(warnings);
}
if (!validation.errors.length &&
!validation.warnings.length) {
container.append(
$('<div>')
.addClass(
'preload-editor-validation-success'
)
.text(
'Preload and configuration are in sync.'
)
);
}
},
renderAnalysisBySelector() {
const container =
this.root?.find(
'.preload-editor-analysis'
);
if (container?.length) {
this.renderAnalysis(container);
}
},
renderVariables(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variables = analysis.variables.slice();
const ordered = ['PAGE'];
variables.forEach(name => {
if (name !== 'PAGE' &&
!ordered.includes(name)) {
ordered.push(name);
}
});
Object.keys(
this.state.config.fields || {}
).forEach(name => {
const upper = name.toUpperCase();
if (!ordered.includes(upper)) {
ordered.push(upper);
}
});
ordered.forEach(name => {
const field =
this.state.config.fields[name] ||
this.createDefaultField(name, analysis);
this.state.config.fields[name] = field;
container.append(
this.createVariableCard(
name,
field,
analysis
)
);
});
},
renderVariablesBySelector() {
const container =
this.root?.find(
'.preload-editor-variables'
);
if (container?.length) {
this.renderVariables(container);
}
},
createVariableCard(name, field, analysis) {
const card = $('<div>')
.addClass('preload-editor-variable')
.attr('data-variable', name);
const header = $('<div>')
.addClass('preload-editor-variable-header');
header.append($('<h3>').text(name));
if (name === 'PAGE') {
header.append(
$('<span>')
.addClass('preload-editor-badge')
.text('AUTOMATIC')
);
} else if (!analysis.variables.includes(name)) {
header.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text('UNUSED')
);
}
card.append(header);
const label = this.textInput(field.label || '');
label.on('input.preloadEditor', () => {
field.label = label.val();
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(this.fieldRow('Label', label));
const description = this.textarea(
field.description || ''
).addClass('preload-editor-small-textarea');
description.on('input.preloadEditor', () => {
field.description = description.val();
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Description',
description
)
);
const types = [
['text', 'Text'],
['textarea', 'Textarea'],
['number', 'Number'],
['boolean', 'Boolean'],
['select', 'Select'],
['multiselect', 'Multi-select'],
['page', 'Page'],
['user', 'User'],
['date', 'Date']
];
const typeSelect = this.select(
types.map(([value, label]) => ({
value,
label
})),
field.type || 'text'
);
typeSelect.on('change.preloadEditor', () => {
field.type = typeSelect.val();
this.markDirty();
card.replaceWith(
this.createVariableCard(
name,
field,
analysis
)
);
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Type',
typeSelect
)
);
if (name === 'PAGE') {
field.type = 'page';
const required = this.checkbox(
field.required !== false
);
required.on('change.preloadEditor', () => {
field.required =
required.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Required',
required
)
);
const namespace = this.select(
[
{ value: 'Main', label: 'Main' },
{ value: 'Template', label: 'Template' },
{ value: 'File', label: 'File' },
{ value: 'Category', label: 'Category' },
{ value: 'User', label: 'User' }
],
field.namespace || 'Main'
);
namespace.on('change.preloadEditor', () => {
field.namespace = namespace.val();
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Namespace',
namespace
)
);
const allowNew = this.checkbox(
field['allow-new'] === true
);
allowNew.on('change.preloadEditor', () => {
field['allow-new'] =
allowNew.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Allow new page',
allowNew
)
);
return card;
}
const required = this.checkbox(
field.required === true
);
required.on('change.preloadEditor', () => {
field.required =
required.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Required',
required
)
);
const placeholder = this.textInput(
field.placeholder || ''
);
placeholder.on('input.preloadEditor', () => {
const value = placeholder.val();
if (value) field.placeholder = value;
else delete field.placeholder;
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Placeholder',
placeholder
)
);
const defaultInput = this.textInput(
field.default === undefined
? ''
: field.default
);
defaultInput.on('input.preloadEditor', () => {
const value = defaultInput.val();
if (value) field.default = value;
else delete field.default;
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Default',
defaultInput,
'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
)
);
if (field.type === 'textarea') {
const maxRows = this.textInput(
field.maxRows || ''
).attr('type', 'number');
maxRows.on('input.preloadEditor', () => {
const value =
parseInt(maxRows.val(), 10);
if (Number.isFinite(value) && value > 0) {
field.maxRows = value;
} else {
delete field.maxRows;
}
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Maximum rows',
maxRows
)
);
}
if (field.type === 'select' ||
field.type === 'multiselect') {
this.renderSelectConfiguration(
card,
field
);
}
return card;
},
renderSelectConfiguration(card, field) {
const source = this.textInput(
field.source || '',
'category:Example'
);
source.on('input.preloadEditor', () => {
const value = source.val();
if (value) field.source = value;
else delete field.source;
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Source',
source,
'Currently supported source format: category:Category Name'
)
);
const sourcePrefix = this.textInput(
field['source-prefix'] || ''
);
sourcePrefix.on('input.preloadEditor', () => {
const value = sourcePrefix.val();
if (value) {
field['source-prefix'] = value;
} else {
delete field['source-prefix'];
}
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Source prefix',
sourcePrefix,
'Only source values beginning with this text are included.'
)
);
const excludeCategory = this.textInput(
field.exclude?.category || ''
);
excludeCategory.on('input.preloadEditor', () => {
const value = excludeCategory.val();
if (value) {
field.exclude = Object.assign(
{},
field.exclude || {},
{ category: value }
);
} else if (field.exclude) {
delete field.exclude.category;
if (!Object.keys(field.exclude).length) {
delete field.exclude;
}
}
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Exclude category',
excludeCategory
)
);
card.append(
this.createTransformControls(
field,
'value',
'Value transformation'
),
this.createTransformControls(
field,
'label',
'Label transformation'
)
);
const optionsValue =
Array.isArray(field.options)
? field.options.map(option => {
if (typeof option === 'string') {
return option;
}
return (
String(option.value || '') +
' | ' +
String(
option.label ||
option.value ||
''
)
);
}).join('\n')
: '';
const options = this.textarea(optionsValue)
.addClass('preload-editor-small-textarea');
options.on('input.preloadEditor', () => {
const lines = options
.val()
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (!lines.length) {
delete field.options;
} else {
field.options = lines.map(line => {
const parts =
line.split(/\s*\|\s*/);
if (parts.length === 1) {
return parts[0];
}
return {
value: parts[0],
label: parts.slice(1).join(' | ')
};
});
}
this.markDirty();
this.renderConfigPreviewBySelector();
});
card.append(
this.fieldRow(
'Manual options',
options,
'One per line. Optional label format: value | label. Leave empty when using a source.'
)
);
},
createTransformControls(field, property, title) {
const wrapper = $('<div>')
.addClass('preload-editor-transform');
$('<h4>')
.text(title)
.appendTo(wrapper);
const existing =
field[property] &&
typeof field[property] === 'object'
? field[property]
: {};
const transform = this.select(
[
{
value: '',
label: 'No transformation'
},
{
value: 'remove-prefix',
label: 'Remove prefix'
},
{
value: 'remove-suffix',
label: 'Remove suffix'
},
{
value: 'replace',
label: 'Replace'
}
],
existing.transform || ''
);
const prefix = this.textInput(
existing.prefix || ''
);
const suffix = this.textInput(
existing.suffix || ''
);
const from = this.textInput(
existing.from || ''
);
const to = this.textInput(
existing.to || ''
);
const controls = $('<div>')
.addClass(
'preload-editor-transform-controls'
);
const prefixRow =
this.fieldRow('Prefix', prefix);
const suffixRow =
this.fieldRow('Suffix', suffix);
const fromRow =
this.fieldRow('From', from);
const toRow =
this.fieldRow('To', to);
const renderInputs = () => {
const type = transform.val();
prefixRow.toggle(
type === 'remove-prefix'
);
suffixRow.toggle(
type === 'remove-suffix'
);
fromRow.toggle(type === 'replace');
toRow.toggle(type === 'replace');
};
const update = () => {
const type = transform.val();
let object = null;
if (type === 'remove-prefix' && prefix.val()) {
object = {
transform: type,
prefix: prefix.val()
};
} else if (
type === 'remove-suffix' &&
suffix.val()
) {
object = {
transform: type,
suffix: suffix.val()
};
} else if (type === 'replace' && from.val()) {
object = {
transform: type,
from: from.val(),
to: to.val()
};
}
if (object) {
field[property] = object;
} else {
delete field[property];
}
this.markDirty();
this.renderConfigPreviewBySelector();
};
transform.on(
'change.preloadEditor',
() => {
update();
renderInputs();
}
);
[prefix, suffix, from, to].forEach(input => {
input.on(
'input.preloadEditor',
update
);
});
controls.append(
this.fieldRow(
'Transformation',
transform
),
prefixRow,
suffixRow,
fromRow,
toRow
);
renderInputs();
wrapper.append(controls);
return wrapper;
},
// Config output
getCleanConfig() {
const config = JSON.parse(
JSON.stringify(this.state.config)
);
const fields = config.fields || {};
const orderedFields = {};
if (fields.PAGE) {
orderedFields.PAGE = fields.PAGE;
}
Object.keys(fields)
.filter(
name =>
name.toUpperCase() !== 'PAGE'
)
.forEach(name => {
orderedFields[name] = fields[name];
});
config.fields = orderedFields;
return config;
},
getConfigJSON() {
return JSON.stringify(
this.getCleanConfig(),
null,
4
);
},
renderConfigPreview(container) {
if (container) {
container.text(this.getConfigJSON());
}
},
renderConfigPreviewBySelector() {
const preview =
this.root?.find(
'.preload-editor-config-preview'
);
if (preview?.length) {
this.renderConfigPreview(preview);
}
this.renderAnalysisBySelector();
},
markDirty() {
this.state.dirty = true;
},
// Save
async savePreloadOnly() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) return;
title = result;
this.state.preloadTitle = title;
}
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(validation);
return;
}
this.setStatus(
'Saving preload...',
'loading'
);
try {
const exists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
exists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!exists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.setStatus(
'Preload saved successfully.',
'success'
);
mw.notify(
'Preload saved.',
{ type: 'success' }
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload.',
'error'
);
mw.notify(
error.message ||
'Could not save preload.',
{ type: 'error' }
);
}
},
async saveAll() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) return;
title = result;
this.state.preloadTitle = title;
}
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(validation);
return;
}
const configTitle =
title.replace(/\/+$/, '') +
'/config.json';
this.setStatus(
'Saving preload and config...',
'loading'
);
try {
const preloadExists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
preloadExists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!preloadExists
);
const configExists =
await this.pageExists(configTitle);
await this.savePage(
configTitle,
this.getConfigJSON(),
configExists
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor',
!configExists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.state.preloadTitle = title;
this.setStatus(
'Preload and config saved successfully.',
'success'
);
mw.notify(
'Preload and config saved.',
{ type: 'success' }
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload/config.',
'error'
);
mw.notify(
error.message ||
'Could not save preload/config.',
{ type: 'error' }
);
}
},
validateBeforeSave() {
const errors = [];
const warnings = [];
const title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
errors.push(
'A preload title is required.'
);
}
if (
title &&
!/\/Preload$/i.test(title)
) {
warnings.push(
'The title does not end with "/Preload".'
);
}
const content =
this.getEditorContent();
this.state.preloadContent = content;
if (!content.trim()) {
errors.push(
'The preload cannot be empty.'
);
}
if (!/<includeonly>/i.test(content)) {
warnings.push(
'The preload does not contain an <includeonly> section.'
);
}
if (!/<noinclude>/i.test(content)) {
warnings.push(
'The preload does not contain a <noinclude> section.'
);
}
const analysis =
this.analyzePreload(
this.extractIncludeonly(content)
);
const configValidation =
this.validateConfig(
this.state.config,
analysis
);
errors.push(...configValidation.errors);
warnings.push(...configValidation.warnings);
let depth = 0;
analysis.conditions.forEach(condition => {
if (condition.type === 'if') {
depth++;
} else if (condition.type === 'endif') {
depth--;
if (depth < 0) {
errors.push(
'Found @endif without a matching @if.'
);
depth = 0;
}
} else if (
condition.type === 'elseif' ||
condition.type === 'else'
) {
if (depth === 0) {
errors.push(
'@' +
condition.type +
' exists outside an @if block.'
);
}
}
});
if (depth > 0) {
errors.push(
'One or more @if blocks are missing @endif.'
);
}
return {
errors,
warnings,
valid: errors.length === 0
};
},
showValidationErrors(validation) {
this.renderAnalysisBySelector();
this.setStatus(
validation.errors.join(' '),
'error'
);
mw.notify(
validation.errors.join('\n'),
{ type: 'error' }
);
}
};
mw.PreloadEditor = PreloadEditor;
$(function () {
if (
mw.config.get('wgPageName') !==
'Petit_Planet:Preload_Editor'
) {
return;
}
PreloadEditor.init();
});
}(mediaWiki, jQuery));