MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
Appearance
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| Line 3: | Line 3: | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: '0. | version: '0.2.0', | ||
state: { | state: { | ||
| Line 15: | Line 15: | ||
dirty: false | dirty: false | ||
}, | }, | ||
editor: null, | |||
editorView: null, | |||
analysis: null, | |||
root: null, | |||
workspace: null, | |||
status: null, | |||
defaults: { | defaults: { | ||
| Line 20: | Line 27: | ||
'<includeonly>\n' + | '<includeonly>\n' + | ||
'<!-- preload here -->\n' + | '<!-- preload here -->\n' + | ||
'</includeonly>' + | '</includeonly>\n' + | ||
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n' | '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n' | ||
}, | }, | ||
/ | // API | ||
async getPage(title) { | async getPage(title) { | ||
| Line 67: | Line 72: | ||
async pageExists(title) { | async pageExists(title) { | ||
return !!await this.getPage(title); | |||
}, | }, | ||
| Line 99: | Line 103: | ||
title: title, | title: title, | ||
text: content, | text: content, | ||
summary: | summary: summary || 'Updated with Preload Editor', | ||
formatversion: 2 | formatversion: 2 | ||
}; | }; | ||
| Line 109: | Line 111: | ||
} | } | ||
return api.postWithToken( | return api.postWithToken('csrf', params); | ||
}, | }, | ||
/ | // Preload parsing | ||
extractIncludeonly(source) { | extractIncludeonly(source) { | ||
const match = | const match = String(source || '').match( | ||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | |||
return match[1].trim(); | return match | ||
? match[1].trim() | |||
: String(source || '').trim(); | |||
}, | }, | ||
findVariables(content) { | findVariables(content) { | ||
const variables = []; | const variables = []; | ||
const pattern = | const pattern = /%%([A-Za-z0-9_-]+)%%/g; | ||
let match; | let match; | ||
while ( | while ((match = pattern.exec(content)) !== null) { | ||
const name = match[1].toUpperCase(); | |||
const name = | |||
if (!variables.includes(name)) { | if (!variables.includes(name)) { | ||
| Line 157: | Line 146: | ||
const pattern = | const pattern = | ||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | ||
let match; | let match; | ||
while ( | while ((match = pattern.exec(content)) !== null) { | ||
conditions.push({ | conditions.push({ | ||
type: | type: match[1].toLowerCase(), | ||
expression: match[2].trim(), | |||
expression: | index: match.index | ||
index: | |||
}); | }); | ||
} | } | ||
| Line 178: | Line 161: | ||
findConditionVariables(content) { | findConditionVariables(content) { | ||
const variables = []; | const variables = []; | ||
const conditions = | const conditions = this.findConditions(content); | ||
conditions.forEach(condition => { | conditions.forEach(condition => { | ||
| Line 195: | Line 177: | ||
matches.forEach(name => { | matches.forEach(name => { | ||
const upper = | const upper = name.toUpperCase(); | ||
if ( | if (!variables.includes(upper)) { | ||
variables.push(upper); | variables.push(upper); | ||
} | } | ||
| Line 210: | Line 189: | ||
analyzePreload(content) { | analyzePreload(content) { | ||
const variables = | const variables = this.findVariables(content); | ||
const conditions = this.findConditions(content); | |||
const conditions = | |||
const conditionVariables = | const conditionVariables = | ||
this.findConditionVariables(content); | this.findConditionVariables(content); | ||
return { | return { | ||
variables, | |||
conditions, | |||
conditionVariables | |||
}; | }; | ||
}, | }, | ||
/ | // Config | ||
emptyConfig() { | emptyConfig() { | ||
| Line 239: | Line 211: | ||
PAGE: { | PAGE: { | ||
label: 'Page Name', | label: 'Page Name', | ||
description: | description: 'Name of the page.', | ||
type: 'page', | type: 'page', | ||
required: true, | required: true, | ||
| Line 256: | Line 227: | ||
Array.isArray(config) | Array.isArray(config) | ||
) { | ) { | ||
config = | config = this.emptyConfig(); | ||
} | } | ||
| Line 276: | Line 246: | ||
} | } | ||
if (!config.fields.PAGE) { | if (!config.fields.PAGE) { | ||
config.fields.PAGE = { | config.fields.PAGE = { | ||
label: 'Page Name', | label: 'Page Name', | ||
description: | description: 'Name of the page.', | ||
type: 'page', | type: 'page', | ||
required: true, | required: true, | ||
| Line 298: | Line 262: | ||
createDefaultField(name, analysis) { | createDefaultField(name, analysis) { | ||
const isConditionVariable = | const isConditionVariable = | ||
analysis.conditionVariables.includes( | analysis.conditionVariables.includes(name); | ||
return { | return { | ||
label: this.humanizeName(name), | label: this.humanizeName(name), | ||
description: '', | description: '', | ||
type: | type: isConditionVariable ? 'boolean' : 'text' | ||
}; | }; | ||
}, | }, | ||
| Line 325: | Line 274: | ||
return String(name || '') | return String(name || '') | ||
.replace(/[_-]+/g, ' ') | .replace(/[_-]+/g, ' ') | ||
.replace( | .replace(/([a-z])([A-Z])/g, '$1 $2') | ||
.replace(/\s+/g, ' ') | .replace(/\s+/g, ' ') | ||
.trim() | .trim() | ||
.replace( | .replace(/\b\w/g, char => char.toUpperCase()); | ||
}, | }, | ||
prepareConfigForVariables( | prepareConfigForVariables(config, analysis) { | ||
config = this.normalizeConfig(config); | |||
config = | |||
const existing = config.fields || {}; | |||
const fields = {}; | const fields = {}; | ||
fields.PAGE = existing.PAGE || { | |||
label: 'Page Name', | |||
description: 'Name of the page.', | |||
fields.PAGE = | type: 'page', | ||
required: true, | |||
namespace: 'Main', | |||
'allow-new': true | |||
}; | |||
analysis.variables.forEach(name => { | |||
analysis.variables.forEach(name => { | |||
if (name === 'PAGE') { | if (name === 'PAGE') { | ||
return; | return; | ||
| Line 370: | Line 302: | ||
fields[name] = | fields[name] = | ||
existing[name] || | existing[name] || | ||
this.createDefaultField( | this.createDefaultField(name, analysis); | ||
}); | }); | ||
Object.keys(existing).forEach(name => { | Object.keys(existing).forEach(name => { | ||
const upper = | const upper = name.toUpperCase(); | ||
if (!fields[upper]) { | if (!fields[upper]) { | ||
fields[upper] = | fields[upper] = existing[name]; | ||
} | } | ||
}); | }); | ||
config.fields = fields; | config.fields = fields; | ||
return config; | return config; | ||
}, | }, | ||
| Line 408: | Line 328: | ||
return { | return { | ||
valid: false, | valid: false, | ||
errors: [ | errors: ['Config must be an object.'], | ||
warnings: [] | warnings: [] | ||
}; | }; | ||
| Line 420: | Line 338: | ||
Array.isArray(config.fields) | Array.isArray(config.fields) | ||
) { | ) { | ||
errors.push( | errors.push('"fields" must be an object.'); | ||
return { | return { | ||
valid: false, | valid: false, | ||
errors, | |||
warnings | |||
}; | }; | ||
} | } | ||
const configured = | const configured = | ||
Object.keys(config.fields) | Object.keys(config.fields).map( | ||
name => name.toUpperCase() | |||
); | |||
analysis.variables.forEach(name => { | analysis.variables.forEach(name => { | ||
if ( | if (!configured.includes(name)) { | ||
errors.push( | errors.push( | ||
'Variable "' + | 'Variable "' + | ||
| Line 450: | Line 363: | ||
configured.forEach(name => { | configured.forEach(name => { | ||
if ( | if (!analysis.variables.includes(name)) { | ||
warnings.push( | warnings.push( | ||
'Field "' + | 'Field "' + | ||
| Line 463: | Line 372: | ||
}); | }); | ||
if ( | if (!configured.includes('PAGE')) { | ||
errors.push('PAGE must always be configured.'); | |||
errors.push( | |||
} | } | ||
Object.entries( | Object.entries(config.fields).forEach( | ||
([name, field]) => { | ([name, field]) => { | ||
if ( | if ( | ||
| Line 485: | Line 388: | ||
'" must be an object.' | '" must be an object.' | ||
); | ); | ||
return; | return; | ||
} | } | ||
| Line 503: | Line 405: | ||
) && | ) && | ||
!field.source && | !field.source && | ||
!Array.isArray( | !Array.isArray(field.options) | ||
) { | ) { | ||
warnings.push( | warnings.push( | ||
| Line 519: | Line 419: | ||
return { | return { | ||
valid: | valid: errors.length === 0, | ||
errors, | |||
warnings | |||
}; | }; | ||
}, | }, | ||
/ | // Transformation helpers | ||
transformationObject( | transformationObject( | ||
| Line 543: | Line 440: | ||
switch (transform) { | switch (transform) { | ||
case 'remove-prefix': | case 'remove-prefix': | ||
return prefix | |||
? { | |||
transform: 'remove-prefix', | |||
prefix | |||
} | |||
: null; | |||
case 'remove-suffix': | case 'remove-suffix': | ||
return suffix | |||
? { | |||
transform: 'remove-suffix', | |||
suffix | |||
} | |||
: null; | |||
case 'replace': | case 'replace': | ||
return from === '' | |||
? null | |||
: { | |||
transform: 'replace', | |||
from, | |||
to: to || '' | |||
}; | |||
default: | default: | ||
| Line 580: | Line 469: | ||
}, | }, | ||
/ | // UI helpers | ||
createSection(title, className) { | createSection(title, className) { | ||
const section = | const section = | ||
$('<section>') | $('<section>') | ||
.addClass( | .addClass('preload-editor-section'); | ||
if (className) { | if (className) { | ||
| Line 596: | Line 481: | ||
$('<h2>') | $('<h2>') | ||
.addClass( | .addClass('preload-editor-section-title') | ||
.text(title) | .text(title) | ||
.appendTo(section); | .appendTo(section); | ||
| Line 605: | Line 488: | ||
}, | }, | ||
createButton( | createButton(label, className, callback) { | ||
const button = | const button = | ||
$('<button>') | $('<button>') | ||
.attr('type', 'button') | .attr('type', 'button') | ||
.addClass( | .addClass('preload-editor-button') | ||
.text(label); | .text(label); | ||
| Line 632: | Line 509: | ||
}, | }, | ||
fieldRow( | fieldRow(label, input, description) { | ||
const row = | const row = | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass('preload-editor-field'); | ||
$('<label>') | |||
.addClass('preload-editor-label') | |||
.text(label) | |||
.appendTo(row); | |||
if (description) { | if (description) { | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass('preload-editor-help') | ||
.text(description) | .text(description) | ||
.appendTo(row); | .appendTo(row); | ||
| Line 662: | Line 527: | ||
row.append(input); | row.append(input); | ||
return row; | return row; | ||
}, | }, | ||
| Line 675: | Line 539: | ||
? '' | ? '' | ||
: value, | : value, | ||
placeholder: | placeholder: placeholder || '' | ||
}) | }) | ||
.addClass( | .addClass('preload-editor-input'); | ||
}, | }, | ||
| Line 691: | Line 552: | ||
: value | : value | ||
) | ) | ||
.addClass( | .addClass('preload-editor-textarea'); | ||
}, | }, | ||
checkbox(checked) { | checkbox(checked) { | ||
return $('<input>') | return $('<input>') | ||
.attr( | .attr('type', 'checkbox') | ||
.prop('checked', !!checked) | |||
.addClass('preload-editor-checkbox'); | |||
.prop( | |||
.addClass( | |||
}, | }, | ||
| Line 713: | Line 565: | ||
const select = | const select = | ||
$('<select>') | $('<select>') | ||
.addClass( | .addClass('preload-editor-select'); | ||
options.forEach(option => { | options.forEach(option => { | ||
$('<option>') | $('<option>') | ||
.attr( | .attr('value', option.value) | ||
.text(option.label) | |||
.text(option.label) | |||
.prop( | .prop( | ||
'selected', | 'selected', | ||
String( | String(option.value) === | ||
String( | String( | ||
value === undefined | value === undefined | ||
| Line 741: | Line 586: | ||
}, | }, | ||
/ | // CodeMirror | ||
destroyEditor() { | |||
if (!this.editor) { | |||
this.editorView = null; | |||
return; | |||
} | |||
const { | |||
view, | |||
inputHandler | |||
} = this.editor; | |||
if (view && inputHandler) { | |||
view.dom.removeEventListener( | |||
'input', | |||
inputHandler | |||
); | |||
} | |||
if (view) { | |||
view.destroy(); | |||
} | |||
this.editor = null; | |||
this.editorView = null; | |||
}, | |||
getEditorView() { | |||
return this.editorView; | |||
}, | |||
getEditorContent() { | |||
if ( | |||
this.editorView && | |||
this.editorView.state | |||
) { | |||
return this.editorView.state.doc.toString(); | |||
} | |||
} | |||
return this.state.preloadContent || ''; | |||
}, | |||
setEditorContent(content) { | |||
content = String(content || ''); | |||
); | |||
if (!this.editorView) { | |||
if (!this. | this.state.preloadContent = content; | ||
return; | return; | ||
} | } | ||
this. | const current = | ||
this.editorView.state.doc.toString(); | |||
if ( | if (current === content) { | ||
return; | |||
} | } | ||
this. | this.editorView.dispatch({ | ||
changes: { | |||
); | from: 0, | ||
to: this.editorView.state.doc.length, | |||
insert: content | |||
} | |||
}); | |||
}, | }, | ||
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 = cm => { | |||
if ( | |||
settled || | |||
{ | !cm || | ||
cm.textarea !== textarea || | |||
!cm.view | |||
) { | |||
return; | |||
} | } | ||
settled = true; | |||
cleanup(); | |||
const view = cm.view; | |||
const inputHandler = () => { | |||
this.state.preloadContent = | |||
view.state.doc.toString(); | |||
this.state.dirty = true; | |||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.renderConfigPreviewBySelector(); | |||
}; | |||
); | |||
view.dom.addEventListener( | |||
'input', | |||
inputHandler | |||
); | ); | ||
view.dom.classList.add( | |||
' | 'preload-editor-codemirror' | ||
); | ); | ||
const wrapper = | |||
view.dom.closest( | |||
'.ext-codemirror-wrapper' | |||
); | |||
if (wrapper) { | |||
wrapper.classList.add( | |||
'preload-editor-codemirror-wrapper' | |||
); | |||
} | |||
this.editor = { | |||
cm, | |||
view, | |||
textarea, | |||
inputHandler | |||
}; | |||
this.editorView = view; | |||
this.state.preloadContent = | |||
view.state.doc.toString(); | |||
. | |||
resolve(view); | |||
}; | |||
const onLoad = loader => { | |||
const | 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 | |||
const | 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( | this.createButton( | ||
' | 'Load', | ||
'preload-editor-button-primary', | 'preload-editor-button-primary', | ||
() => | () => this.loadExisting(titleInput.val()) | ||
); | |||
this. | const newButton = | ||
this.createButton( | |||
'New', | |||
'', | |||
() => this.startNew() | |||
); | |||
const sampleButton = | |||
this.createButton( | |||
'Load Sample Page', | |||
'', | |||
'' | () => this.loadSamplePage() | ||
); | |||
const loadRow = | |||
$('<div>') | |||
.addClass('preload-editor-toolbar') | |||
.append( | |||
titleInput, | |||
loadButton, | |||
newButton, | |||
sampleButton | |||
); | |||
this. | loader.append(loadRow); | ||
this.root.append(loader); | |||
this. | 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(); | |||
}, | |||
this. | 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( | ||
'success' | 'is-error', | ||
'is-success', | |||
'is-warning', | |||
'is-loading' | |||
' | |||
); | ); | ||
this. | 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); | |||
const | |||
const | 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. | 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 = { | this.state = { | ||
preloadTitle: title, | preloadTitle: title, | ||
preloadSource: | preloadSource: '', | ||
preloadContent: | preloadContent: this.defaults.preload, | ||
config: | config: this.emptyConfig(), | ||
variables: | variables: ['PAGE'], | ||
conditions: [], | |||
conditions: | mode: 'new', | ||
dirty: true | |||
mode: ' | |||
dirty: | |||
}; | }; | ||
this.renderEditor(); | 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.' | |||
const | |||
this. | |||
' | |||
' | |||
); | ); | ||
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); | |||
this. | let config = this.emptyConfig(); | ||
const configTitle = | |||
title.replace(/\/+$/, '') + | |||
'/config.json'; | |||
this. | 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 = | const analysis = | ||
this.analyzePreload( | this.analyzePreload(content); | ||
config = | |||
this.prepareConfigForVariables( | |||
config, | |||
this.prepareConfigForVariables( | |||
analysis | analysis | ||
); | ); | ||
this.analysis | this.state = { | ||
analysis; | 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 | const layout = | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-layout' | ||
); | ); | ||
const left = | |||
$('< | $('<div>') | ||
. | .addClass( | ||
'preload-editor-left' | |||
' | ); | ||
const right = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-right' | |||
); | |||
const editorSection = | |||
this.createSection( | |||
'Preload', | |||
'preload-editor-main-section' | |||
); | |||
const meta = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-meta' | |||
); | |||
$('<strong>') | |||
.text( | |||
this.state.preloadTitle || | |||
'New preload' | |||
) | |||
.appendTo(meta); | |||
if (this.state.mode === 'new') { | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge' | |||
) | |||
.text('NEW') | |||
.appendTo(meta); | |||
} | } | ||
editorSection.append(meta); | |||
const textarea = | |||
this.textarea( | |||
this.state.preloadContent | |||
) | |||
.attr( | |||
'id', | |||
'preload-editor-content' | |||
) | |||
.addClass( | |||
'preload-editor-content' | |||
); | |||
const editorMount = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-codemirror-mount' | |||
) | |||
.append(textarea); | |||
editorSection.append( | |||
editorMount | |||
); | |||
left.append(editorSection); | |||
const analysisSection = | |||
this.createSection('Analysis'); | |||
const analysisContainer = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis' | |||
); | |||
analysisSection.append( | |||
analysisContainer | |||
); | |||
const variablesSection = | |||
this.createSection('Variables'); | |||
const variableContainer = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variables' | |||
); | |||
const | variablesSection.append( | ||
this. | variableContainer | ||
); | |||
const configSection = | |||
this.createSection( | |||
'Generated configuration' | |||
); | ); | ||
const | const configPreview = | ||
$('<pre>') | |||
.addClass( | |||
'preload-editor-config-preview' | |||
); | |||
configSection.append(configPreview); | |||
const actions = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-actions' | |||
); | |||
const save = | |||
this.createButton( | |||
'Save Preload + Config', | |||
'preload-editor-button-primary', | |||
() => this.saveAll() | |||
); | |||
const savePreload = | |||
this.createButton( | |||
'Save Preload Only', | |||
'', | |||
() => this.savePreloadOnly() | |||
); | |||
actions.append(save, savePreload); | |||
right.append( | |||
analysisSection, | |||
variablesSection, | |||
configSection, | |||
actions | |||
); | |||
layout.append(left, right); | |||
this.workspace.append(layout); | |||
this.reanalyze(); | |||
this.renderVariables(variableContainer); | |||
this.renderAnalysis(analysisContainer); | |||
this.renderConfigPreview(configPreview); | |||
this.mountCodeMirror(textarea.get(0)) | |||
.catch(error => { | |||
console.error( | |||
'Preload Editor CodeMirror:', | |||
error | |||
); | ); | ||
this.setStatus( | |||
error.message || | |||
'Could not initialize CodeMirror.', | |||
'error' | |||
); | |||
}); | |||
}, | }, | ||
reanalyze() { | |||
const source = this.getEditorContent(); | |||
const content = this.extractIncludeonly(source); | |||
const analysis = this.analyzePreload(content); | |||
const | 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( | .addClass( | ||
'preload-editor- | 'preload-editor-analysis-note' | ||
) | ) | ||
.text( | .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 = | ||
'preload-editor- | this.root && | ||
) | this.root.find( | ||
. | '.preload-editor-analysis' | ||
); | |||
if (container && container.length) { | |||
this.renderAnalysis(container); | |||
} | |||
}, | |||
renderVariables(container) { | |||
container.empty(); | |||
const analysis = | |||
this. | 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. | this.state.config.fields || {} | ||
).forEach(name => { | |||
const upper = name.toUpperCase(); | |||
if (!ordered.includes(upper)) { | |||
ordered.push(upper); | |||
} | |||
}); | |||
this. | ordered.forEach(name => { | ||
const field = | |||
this.state.config.fields[name] || | |||
this.createDefaultField( | |||
name, | |||
analysis | |||
); | |||
this.state.config.fields[name] = field; | |||
container.append( | |||
this.createVariableCard( | |||
); | name, | ||
field, | |||
analysis | |||
) | |||
); | |||
}); | |||
}, | |||
renderVariablesBySelector() { | |||
const container = | |||
this.root && | |||
this.root.find( | |||
'.preload-editor-variables' | |||
); | |||
if (container && container.length) { | |||
this. | 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', | 'change.preloadEditor', | ||
() => { | () => { | ||
field. | field.type = typeSelect.val(); | ||
this.markDirty(); | |||
); | card.replaceWith( | ||
this.createVariableCard( | |||
name, | |||
field, | |||
analysis | |||
) | |||
); | |||
this.renderConfigPreviewBySelector(); | this.renderConfigPreviewBySelector(); | ||
} | } | ||
| Line 2,140: | Line 1,772: | ||
card.append( | card.append( | ||
this.fieldRow( | 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. | 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 = | |||
field | this.checkbox( | ||
field['allow-new'] === true | |||
); | |||
allowNew.on( | |||
'change.preloadEditor', | |||
' | |||
() => { | () => { | ||
field['allow-new'] = | |||
allowNew.prop('checked'); | |||
this.markDirty(); | this.markDirty(); | ||
| Line 2,259: | Line 1,866: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Allow new page', | ||
allowNew | |||
) | ) | ||
); | ); | ||
return card; | |||
} | } | ||
const required = | |||
this.checkbox( | |||
field.required === true | |||
const | |||
this. | |||
field. | |||
); | ); | ||
required.on( | |||
' | 'change.preloadEditor', | ||
() => { | () => { | ||
field.required = | |||
required.prop('checked'); | |||
this.markDirty(); | |||
this.markDirty(); | |||
this.renderConfigPreviewBySelector(); | this.renderConfigPreviewBySelector(); | ||
} | } | ||
| Line 2,311: | Line 1,892: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Required', | ||
required | |||
) | ) | ||
); | ); | ||
const | const placeholder = | ||
this.textInput( | this.textInput( | ||
field | field.placeholder || '' | ||
); | ); | ||
placeholder.on( | |||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
const value = | const value = placeholder.val(); | ||
if (value) { | if (value) { | ||
field | field.placeholder = value; | ||
} else { | } else { | ||
delete field | delete field.placeholder; | ||
} | } | ||
| Line 2,345: | Line 1,920: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Placeholder', | ||
placeholder | |||
) | ) | ||
); | ); | ||
const | const defaultInput = | ||
this.textInput( | this.textInput( | ||
field. | field.default === undefined | ||
? '' | |||
? | : field.default | ||
: | |||
); | ); | ||
defaultInput.on( | |||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
const value = | const value = defaultInput.val(); | ||
if (value) { | if (value) { | ||
field. | field.default = value; | ||
} else { | |||
delete field.default; | |||
} else | |||
delete field. | |||
} | } | ||
| Line 2,396: | Line 1,950: | ||
card.append( | card.append( | ||
this.fieldRow( | 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. | 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', | 'input.preloadEditor', | ||
() => { | () => { | ||
const | const value = source.val(); | ||
if ( | if (value) { | ||
field.source = value; | |||
} else { | } else { | ||
field. | delete field.source; | ||
} | } | ||
| Line 2,512: | Line 2,034: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Source', | ||
source, | |||
' | 'Currently supported source format: category:Category Name' | ||
) | ) | ||
); | ); | ||
const sourcePrefix = | |||
this.textInput( | |||
field['source-prefix'] || '' | |||
); | |||
const | |||
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. | this.fieldRow( | ||
'Source prefix', | |||
sourcePrefix, | |||
'Only source values beginning with this text are included.' | |||
) | |||
); | |||
); | |||
const | const excludeCategory = | ||
this.textInput( | 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 ( | if ( | ||
!Object.keys( | |||
field.exclude | |||
).length | |||
) { | ) { | ||
delete field.exclude; | |||
} | } | ||
} | |||
this.markDirty(); | |||
} | |||
this.markDirty(); | |||
this.renderConfigPreviewBySelector(); | this.renderConfigPreviewBySelector(); | ||
}; | } | ||
); | |||
card.append( | |||
this.fieldRow( | |||
'Exclude category', | |||
excludeCategory | |||
) | |||
); | ); | ||
card.append( | |||
this.createTransformControls( | |||
field, | |||
'value', | |||
'Value transformation' | |||
), | |||
this.createTransformControls( | |||
field, | |||
'label', | |||
'Label transformation' | |||
this. | |||
' | |||
) | ) | ||
); | ); | ||
const | 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 | const options = | ||
this. | this.textarea(optionsValue) | ||
' | .addClass( | ||
'preload-editor-small-textarea' | |||
); | |||
options.on( | |||
'input.preloadEditor', | |||
() => { | () => { | ||
const | 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 | 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( | |||
this. | existing.to || '' | ||
); | |||
const controls = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-transform-controls' | |||
); | |||
const prefixRow = | |||
this.fieldRow( | |||
'Prefix', | |||
prefix | |||
const | |||
this. | |||
' | |||
); | ); | ||
const suffixRow = | |||
this. | this.fieldRow( | ||
'Suffix', | |||
suffix | |||
); | ); | ||
const | const fromRow = | ||
this. | this.fieldRow( | ||
'From', | |||
' | from | ||
); | ); | ||
const toRow = | |||
this.fieldRow( | |||
'To', | |||
to | |||
this. | |||
); | ); | ||
const renderInputs = () => { | |||
const type = transform.val(); | |||
prefixRow.toggle( | |||
type === 'remove-prefix' | |||
); | |||
suffixRow.toggle( | |||
type === 'remove-suffix' | |||
); | |||
fromRow.toggle( | |||
type === 'replace' | |||
); | |||
toRow.toggle( | |||
type === 'replace' | |||
); | |||
}; | |||
const update = () => { | |||
const type = transform.val(); | |||
let object = null; | |||
if (type === 'remove-prefix') { | |||
if (prefix.val()) { | |||
object = { | |||
transform: type, | |||
prefix: prefix.val() | |||
}; | |||
} | |||
} else if (type === 'remove-suffix') { | |||
if (suffix.val()) { | |||
object = { | |||
transform: type, | |||
suffix: suffix.val() | |||
}; | |||
} | |||
} else if (type === 'replace') { | |||
if (from.val()) { | |||
object = { | |||
transform: type, | |||
from: from.val(), | |||
to: to.val() | |||
}; | |||
} | |||
} | |||
if (object) { | |||
field[property] = object; | |||
} else { | |||
delete field[property]; | |||
} | |||
this.markDirty(); | |||
this.renderConfigPreviewBySelector(); | |||
}; | |||
this. | |||
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 | |||
); | |||
}, | |||
if (!title) { | renderConfigPreview(container) { | ||
if (!container) { | |||
return; | |||
} | |||
container.text( | |||
this.getConfigJSON() | |||
); | |||
}, | |||
renderConfigPreviewBySelector() { | |||
const preview = | |||
this.root && | |||
this.root.find( | |||
'.preload-editor-config-preview' | |||
); | |||
if (preview && preview.length) { | |||
this.renderConfigPreview(preview); | |||
} | |||
this.renderAnalysisBySelector(); | |||
}, | |||
markDirty() { | |||
this.state.dirty = true; | |||
}, | |||
// Save | |||
async savePreloadOnly() { | |||
let title = | |||
String( | |||
this.state.preloadTitle || '' | |||
).trim(); | |||
if (!title) { | |||
const result = | const result = | ||
await this.createPromptDialog( | await this.createPromptDialog( | ||
| Line 2,953: | Line 2,477: | ||
title = result; | title = result; | ||
this.state.preloadTitle = title; | |||
} | |||
this.state.preloadContent = | |||
this.getEditorContent(); | |||
const validation = | const validation = | ||
this.validateBeforeSave(); | this.validateBeforeSave(); | ||
if ( | if (validation.errors.length) { | ||
this.showValidationErrors(validation); | |||
this.showValidationErrors( | |||
return; | return; | ||
} | } | ||
this.setStatus( | this.setStatus( | ||
'Saving preload | 'Saving preload...', | ||
'loading' | 'loading' | ||
); | ); | ||
try { | try { | ||
const | const exists = | ||
await this.pageExists( | await this.pageExists(title); | ||
await this.savePage( | await this.savePage( | ||
title, | title, | ||
this.state.preloadContent, | this.state.preloadContent, | ||
exists | |||
? 'Updated preload with Preload Editor' | ? 'Updated preload with Preload Editor' | ||
: 'Created preload with Preload Editor', | : 'Created preload with Preload Editor', | ||
! | !exists | ||
); | ); | ||
this.state.mode = 'edit'; | this.state.mode = 'edit'; | ||
this.state.dirty = false; | this.state.dirty = false; | ||
this.setStatus( | this.setStatus( | ||
'Preload | 'Preload saved successfully.', | ||
'success' | 'success' | ||
); | ); | ||
mw.notify( | mw.notify( | ||
'Preload | 'Preload saved.', | ||
{ | { type: 'success' } | ||
); | ); | ||
} catch (error) { | } catch (error) { | ||
| Line 3,036: | Line 2,529: | ||
this.setStatus( | this.setStatus( | ||
error.message || | error.message || | ||
'Could not save preload | 'Could not save preload.', | ||
'error' | 'error' | ||
); | ); | ||
| Line 3,042: | Line 2,535: | ||
mw.notify( | mw.notify( | ||
error.message || | error.message || | ||
'Could not save preload | 'Could not save preload.', | ||
{ | { type: 'error' } | ||
); | ); | ||
} | } | ||
}, | }, | ||
async saveAll() { | |||
let title = | |||
String( | String( | ||
this.state.preloadTitle || | this.state.preloadTitle || '' | ||
).trim(); | ).trim(); | ||
if (!title) { | 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 | 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 = | |||
this. | 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. | this.state.dirty = false; | ||
this. | 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' } | |||
); | ); | ||
} | |||
errors.push( | }, | ||
... | |||
); | validateBeforeSave() { | ||
const errors = []; | |||
warnings.push( | const warnings = []; | ||
...configValidation. | |||
); | const title = | ||
String( | |||
this.state.preloadTitle || '' | |||
).trim(); | |||
let depth = 0; | if (!title) { | ||
errors.push( | |||
analysis.conditions.forEach( | 'A preload title is required.' | ||
); | |||
} | |||
if ( | |||
title && | |||
!/\/Preload$/i.test(title) | |||
) { | |||
warnings.push( | |||
'The title does not end with "/Preload".' | |||
); | |||
} | |||
const content = | |||
this.getEditorContent(); | |||
this.state.preloadContent = content; | |||
if (!content.trim()) { | |||
errors.push( | |||
'The preload cannot be empty.' | |||
); | |||
} | |||
if (!/<includeonly>/i.test(content)) { | |||
warnings.push( | |||
'The preload does not contain an <includeonly> section.' | |||
); | |||
} | |||
if (!/<noinclude>/i.test(content)) { | |||
warnings.push( | |||
'The preload does not contain a <noinclude> section.' | |||
} | ); | ||
} | } | ||
); | |||
const analysis = | |||
this.analyzePreload( | |||
this.extractIncludeonly(content) | |||
); | |||
const configValidation = | |||
this.validateConfig( | |||
this.state.config, | |||
analysis | |||
); | |||
errors.push( | |||
...configValidation.errors | |||
); | |||
warnings.push( | |||
...configValidation.warnings | |||
); | |||
let depth = 0; | |||
analysis.conditions.forEach(condition => { | |||
if (condition.type === 'if') { | |||
depth++; | |||
} else if ( | |||
condition.type === 'endif' | |||
) { | |||
depth--; | |||
if (depth < 0) { | |||
errors.push( | |||
'Found @endif without a matching @if.' | |||
); | |||
depth = 0; | |||
} | |||
} else if ( | |||
condition.type === 'elseif' || | |||
condition.type === 'else' | |||
) { | |||
if (depth === 0) { | |||
errors.push( | |||
'@' + | |||
condition.type + | |||
' exists outside an @if block.' | |||
); | |||
} | |||
} | |||
}); | |||
if (depth > 0) { | if (depth > 0) { | ||
| Line 3,175: | Line 2,744: | ||
return { | return { | ||
errors, | |||
warnings, | |||
valid: | valid: errors.length === 0 | ||
}; | }; | ||
}, | }, | ||
showValidationErrors(validation) { | showValidationErrors(validation) { | ||
this. | this.renderAnalysisBySelector(); | ||
this.setStatus( | this.setStatus( | ||
validation.errors.join( | validation.errors.join(' '), | ||
'error' | 'error' | ||
); | ); | ||
mw.notify( | mw.notify( | ||
validation.errors.join( | validation.errors.join('\n'), | ||
{ type: 'error' } | |||
{ | |||
); | ); | ||
} | } | ||
}; | }; | ||
mw.PreloadEditor = PreloadEditor; | |||
mw.PreloadEditor = | |||
$(function () { | $(function () { | ||
const title = | const title = | ||
mw.config.get( | mw.config.get('wgPageName'); | ||
if ( | if ( | ||
Revision as of 07:37, 21 September 2026
(function (mw, $) {
'use strict';
const PreloadEditor = {
version: '0.2.0',
state: {
preloadTitle: '',
preloadSource: '',
preloadContent: '',
config: null,
variables: [],
conditions: [],
mode: 'idle',
dirty: false
},
editor: null,
editorView: null,
analysis: null,
root: null,
workspace: null,
status: null,
defaults: {
preload:
'<includeonly>\n' +
'<!-- preload here -->\n' +
'</includeonly>\n' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
},
// API
async getPage(title) {
const api = new mw.Api();
const response = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
});
const page =
response &&
response.query &&
response.query.pages &&
response.query.pages[0];
if (!page || page.missing) {
return null;
}
const content =
page.revisions &&
page.revisions[0] &&
page.revisions[0].slots &&
page.revisions[0].slots.main &&
page.revisions[0].slots.main.content;
return {
title: page.title || title,
content:
typeof content === 'string'
? content
: ''
};
},
async pageExists(title) {
return !!await this.getPage(title);
},
async searchPages(query) {
const api = new mw.Api();
const response = await api.get({
action: 'query',
list: 'search',
srsearch: query,
srlimit: 10,
srnamespace: 10,
formatversion: 2
});
return (
response &&
response.query &&
response.query.search
? response.query.search
: []
);
},
async savePage(title, content, summary, createOnly) {
const api = new mw.Api();
const params = {
action: 'edit',
title: title,
text: content,
summary: summary || 'Updated with Preload Editor',
formatversion: 2
};
if (createOnly) {
params.createonly = true;
}
return api.postWithToken('csrf', params);
},
// Preload parsing
extractIncludeonly(source) {
const match = String(source || '').match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
return match
? match[1].trim()
: String(source || '').trim();
},
findVariables(content) {
const variables = [];
const pattern = /%%([A-Za-z0-9_-]+)%%/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const name = match[1].toUpperCase();
if (!variables.includes(name)) {
variables.push(name);
}
}
return variables;
},
findConditions(content) {
const conditions = [];
const pattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
let match;
while ((match = pattern.exec(content)) !== null) {
conditions.push({
type: match[1].toLowerCase(),
expression: match[2].trim(),
index: match.index
});
}
return conditions;
},
findConditionVariables(content) {
const variables = [];
const conditions = this.findConditions(content);
conditions.forEach(condition => {
if (
condition.type !== 'if' &&
condition.type !== 'elseif'
) {
return;
}
const matches =
condition.expression.match(
/[A-Za-z0-9_-]+/g
) || [];
matches.forEach(name => {
const upper = name.toUpperCase();
if (!variables.includes(upper)) {
variables.push(upper);
}
});
});
return variables;
},
analyzePreload(content) {
const variables = this.findVariables(content);
const conditions = this.findConditions(content);
const conditionVariables =
this.findConditionVariables(content);
return {
variables,
conditions,
conditionVariables
};
},
// Config
emptyConfig() {
return {
settings: {
incomplete: 'add-stub'
},
fields: {
PAGE: {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
}
}
};
},
normalizeConfig(config) {
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
config = this.emptyConfig();
}
if (
!config.settings ||
typeof config.settings !== 'object' ||
Array.isArray(config.settings)
) {
config.settings = {};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
config.fields = {};
}
if (!config.fields.PAGE) {
config.fields.PAGE = {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
}
return config;
},
createDefaultField(name, analysis) {
const isConditionVariable =
analysis.conditionVariables.includes(name);
return {
label: this.humanizeName(name),
description: '',
type: isConditionVariable ? 'boolean' : 'text'
};
},
humanizeName(name) {
return String(name || '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, char => char.toUpperCase());
},
prepareConfigForVariables(config, analysis) {
config = this.normalizeConfig(config);
const existing = config.fields || {};
const fields = {};
fields.PAGE = existing.PAGE || {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
analysis.variables.forEach(name => {
if (name === 'PAGE') {
return;
}
fields[name] =
existing[name] ||
this.createDefaultField(name, analysis);
});
Object.keys(existing).forEach(name => {
const upper = name.toUpperCase();
if (!fields[upper]) {
fields[upper] = existing[name];
}
});
config.fields = fields;
return config;
},
validateConfig(config, analysis) {
const errors = [];
const warnings = [];
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
return {
valid: false,
errors: ['Config must be an object.'],
warnings: []
};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
errors.push('"fields" must be an object.');
return {
valid: false,
errors,
warnings
};
}
const configured =
Object.keys(config.fields).map(
name => name.toUpperCase()
);
analysis.variables.forEach(name => {
if (!configured.includes(name)) {
errors.push(
'Variable "' +
name +
'" is not configured.'
);
}
});
configured.forEach(name => {
if (!analysis.variables.includes(name)) {
warnings.push(
'Field "' +
name +
'" is not used by the preload.'
);
}
});
if (!configured.includes('PAGE')) {
errors.push('PAGE must always be configured.');
}
Object.entries(config.fields).forEach(
([name, field]) => {
if (
!field ||
typeof field !== 'object' ||
Array.isArray(field)
) {
errors.push(
'Field "' +
name +
'" must be an object.'
);
return;
}
if (!field.type) {
warnings.push(
'Field "' +
name +
'" has no type.'
);
}
if (
(
field.type === 'select' ||
field.type === 'multiselect'
) &&
!field.source &&
!Array.isArray(field.options)
) {
warnings.push(
'Field "' +
name +
'" is a ' +
field.type +
' without a source or manual options.'
);
}
}
);
return {
valid: errors.length === 0,
errors,
warnings
};
},
// Transformation helpers
transformationObject(
transform,
prefix,
suffix,
from,
to
) {
if (!transform) {
return null;
}
switch (transform) {
case 'remove-prefix':
return prefix
? {
transform: 'remove-prefix',
prefix
}
: null;
case 'remove-suffix':
return suffix
? {
transform: 'remove-suffix',
suffix
}
: null;
case 'replace':
return from === ''
? null
: {
transform: 'replace',
from,
to: to || ''
};
default:
return null;
}
},
// UI helpers
createSection(title, className) {
const section =
$('<section>')
.addClass('preload-editor-section');
if (className) {
section.addClass(className);
}
$('<h2>')
.addClass('preload-editor-section-title')
.text(title)
.appendTo(section);
return section;
},
createButton(label, className, callback) {
const button =
$('<button>')
.attr('type', 'button')
.addClass('preload-editor-button')
.text(label);
if (className) {
button.addClass(className);
}
if (callback) {
button.on(
'click.preloadEditor',
callback
);
}
return button;
},
fieldRow(label, input, description) {
const row =
$('<div>')
.addClass('preload-editor-field');
$('<label>')
.addClass('preload-editor-label')
.text(label)
.appendTo(row);
if (description) {
$('<div>')
.addClass('preload-editor-help')
.text(description)
.appendTo(row);
}
row.append(input);
return row;
},
textInput(value, placeholder) {
return $('<input>')
.attr({
type: 'text',
value:
value === undefined ||
value === null
? ''
: value,
placeholder: placeholder || ''
})
.addClass('preload-editor-input');
},
textarea(value) {
return $('<textarea>')
.val(
value === undefined ||
value === null
? ''
: value
)
.addClass('preload-editor-textarea');
},
checkbox(checked) {
return $('<input>')
.attr('type', 'checkbox')
.prop('checked', !!checked)
.addClass('preload-editor-checkbox');
},
select(options, value) {
const select =
$('<select>')
.addClass('preload-editor-select');
options.forEach(option => {
$('<option>')
.attr('value', option.value)
.text(option.label)
.prop(
'selected',
String(option.value) ===
String(
value === undefined
? ''
: value
)
)
.appendTo(select);
});
return select;
},
// CodeMirror
destroyEditor() {
if (!this.editor) {
this.editorView = null;
return;
}
const {
view,
inputHandler
} = this.editor;
if (view && inputHandler) {
view.dom.removeEventListener(
'input',
inputHandler
);
}
if (view) {
view.destroy();
}
this.editor = null;
this.editorView = null;
},
getEditorView() {
return this.editorView;
},
getEditorContent() {
if (
this.editorView &&
this.editorView.state
) {
return this.editorView.state.doc.toString();
}
return this.state.preloadContent || '';
},
setEditorContent(content) {
content = String(content || '');
if (!this.editorView) {
this.state.preloadContent = content;
return;
}
const current =
this.editorView.state.doc.toString();
if (current === content) {
return;
}
this.editorView.dispatch({
changes: {
from: 0,
to: this.editorView.state.doc.length,
insert: content
}
});
},
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 = cm => {
if (
settled ||
!cm ||
cm.textarea !== textarea ||
!cm.view
) {
return;
}
settled = true;
cleanup();
const view = cm.view;
const inputHandler = () => {
this.state.preloadContent =
view.state.doc.toString();
this.state.dirty = true;
this.reanalyze();
this.renderVariablesBySelector();
this.renderAnalysisBySelector();
this.renderConfigPreviewBySelector();
};
view.dom.addEventListener(
'input',
inputHandler
);
view.dom.classList.add(
'preload-editor-codemirror'
);
const wrapper =
view.dom.closest(
'.ext-codemirror-wrapper'
);
if (wrapper) {
wrapper.classList.add(
'preload-editor-codemirror-wrapper'
);
}
this.editor = {
cm,
view,
textarea,
inputHandler
};
this.editorView = view;
this.state.preloadContent =
view.state.doc.toString();
resolve(view);
};
const onLoad = loader => {
if (
settled ||
typeof loader !== 'function'
) {
return;
}
try {
loader(textarea);
} catch (error) {
settled = true;
cleanup();
reject(error);
}
};
mw.hook('dev.CCM.ready').add(onReady);
mw.hook('dev.CCM.load').add(onLoad);
});
},
// Main UI
init() {
const root = $('#mw-content-text');
if (!root.length) {
return;
}
this.root =
$('<div>')
.attr('id', 'preload-editor')
.addClass('preload-editor');
root.append(this.root);
this.renderShell();
},
renderShell() {
this.destroyEditor();
this.root.empty();
const loader =
this.createSection('Preload');
const titleInput =
this.textInput(
this.state.preloadTitle,
'Template:Example/Preload'
).attr(
'id',
'preload-editor-title-input'
);
const loadButton =
this.createButton(
'Load',
'preload-editor-button-primary',
() => this.loadExisting(titleInput.val())
);
const newButton =
this.createButton(
'New',
'',
() => this.startNew()
);
const sampleButton =
this.createButton(
'Load Sample Page',
'',
() => this.loadSamplePage()
);
const loadRow =
$('<div>')
.addClass('preload-editor-toolbar')
.append(
titleInput,
loadButton,
newButton,
sampleButton
);
loader.append(loadRow);
this.root.append(loader);
this.status =
$('<div>')
.addClass('preload-editor-status');
this.root.append(this.status);
this.workspace =
$('<div>')
.addClass('preload-editor-workspace');
this.root.append(this.workspace);
this.renderWelcome();
},
renderWelcome() {
this.workspace.empty();
const section =
this.createSection('Get started');
$('<p>')
.text(
'Load an existing preload, create a new one, or start from a sample wiki page.'
)
.appendTo(section);
const tips =
$('<ul>')
.addClass('preload-editor-tips');
[
'Existing preload: loads the preload and its config.json if available.',
'New preload: starts with the standard Preload Template structure.',
'Sample page: loads normal wiki page content so you can turn it into a preload.'
].forEach(text => {
$('<li>')
.text(text)
.appendTo(tips);
});
section.append(tips);
this.workspace.append(section);
},
setStatus(message, type) {
if (!this.status) {
return;
}
this.status
.removeClass(
'is-error',
'is-success',
'is-warning',
'is-loading'
);
if (type) {
this.status.addClass('is-' + type);
}
this.status.text(message || '');
},
// Load / create
async loadExisting(title) {
title = String(title || '').trim();
if (!title) {
mw.notify(
'Enter a preload page title first.',
{ type: 'warn' }
);
return;
}
this.setStatus(
'Loading preload...',
'loading'
);
try {
const page = await this.getPage(title);
if (!page) {
const suggestions =
await this.findSimilarPreloads(title);
this.renderMissingPage(
title,
suggestions
);
this.setStatus(
'Preload not found.',
'warning'
);
return;
}
await this.loadDefinition(
page.title,
page.content
);
this.setStatus(
'Preload loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor:',
error
);
this.setStatus(
error.message ||
'Could not load preload.',
'error'
);
}
},
async findSimilarPreloads(title) {
let query =
title
.replace(/\/Preload$/i, '')
.trim();
if (!query) {
query = title;
}
try {
const results =
await this.searchPages(query);
return results.filter(
result =>
/\/Preload$/i.test(
result.title
)
);
} catch (error) {
console.error(
'Preload Editor search:',
error
);
return [];
}
},
renderMissingPage(title, suggestions) {
this.workspace.empty();
const section =
this.createSection(
'Preload not found'
);
$('<p>')
.text(
'No preload exists at "' +
title +
'".'
)
.appendTo(section);
if (suggestions.length) {
$('<h3>')
.text('Similar preloads')
.appendTo(section);
const list =
$('<div>')
.addClass(
'preload-editor-suggestions'
);
suggestions.forEach(result => {
list.append(
this.createButton(
result.title,
'preload-editor-suggestion',
() =>
this.loadExisting(
result.title
)
)
);
});
section.append(list);
}
section.append(
this.createButton(
'Create this preload',
'preload-editor-button-primary',
() => this.startNew(title)
)
);
this.workspace.append(section);
},
async startNew(title) {
title = String(title || '').trim();
this.state = {
preloadTitle: title,
preloadSource: '',
preloadContent: this.defaults.preload,
config: this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.renderEditor();
this.setStatus(
'Creating a new preload.',
'success'
);
},
async loadSamplePage() {
const result =
await this.createPromptDialog(
'Load Sample Page',
'Enter the title of a normal wiki page to use as the starting point.'
);
if (!result) {
return;
}
this.setStatus(
'Loading sample page...',
'loading'
);
try {
const page =
await this.getPage(result);
if (!page) {
throw new Error(
'Sample page not found: ' +
result
);
}
const sample =
this.convertSampleToPreload(
page.content
);
this.state = {
preloadTitle: '',
preloadSource: '',
preloadContent: sample,
config: this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.reanalyze();
this.renderEditor();
this.setStatus(
'Sample page loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor sample:',
error
);
this.setStatus(
error.message ||
'Could not load sample page.',
'error'
);
}
},
convertSampleToPreload(source) {
let content =
String(source || '').trim();
const include =
content.match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
if (include) {
content = include[1].trim();
}
content =
content
.replace(
/<noinclude>[\s\S]*?<\/noinclude>/gi,
''
)
.trim();
return (
'<includeonly>\n' +
content +
'\n</includeonly>\n' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
);
},
createPromptDialog(title, message) {
return new Promise(resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt'
);
$('<h2>')
.text(title)
.appendTo(box);
$('<p>')
.text(message)
.appendTo(box);
const input = this.textInput();
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const confirm =
this.createButton(
'Load',
'preload-editor-button-primary',
() => {
const value =
String(
input.val() || ''
).trim();
if (!value) {
return;
}
overlay.remove();
resolve(value);
}
);
buttons.append(cancel, confirm);
box.append(input, buttons);
overlay.append(box);
$(document.body).append(overlay);
input.trigger('focus');
input.on(
'keydown.preloadEditor',
event => {
if (event.key === 'Enter') {
confirm.trigger('click');
}
if (event.key === 'Escape') {
cancel.trigger('click');
}
}
);
});
},
async loadDefinition(title, source) {
const content =
this.extractIncludeonly(source);
let config = this.emptyConfig();
const configTitle =
title.replace(/\/+$/, '') +
'/config.json';
const configPage =
await this.getPage(configTitle);
if (configPage) {
try {
config =
JSON.parse(
configPage.content
);
} catch (error) {
throw new Error(
'The existing config.json contains invalid JSON: ' +
error.message
);
}
}
const analysis =
this.analyzePreload(content);
config =
this.prepareConfigForVariables(
config,
analysis
);
this.state = {
preloadTitle: title,
preloadSource: source,
preloadContent: source,
config,
variables: analysis.variables,
conditions: analysis.conditions,
mode: 'edit',
dirty: false
};
this.renderEditor();
},
// Editor
renderEditor() {
this.destroyEditor();
this.workspace.empty();
const layout =
$('<div>')
.addClass(
'preload-editor-layout'
);
const left =
$('<div>')
.addClass(
'preload-editor-left'
);
const right =
$('<div>')
.addClass(
'preload-editor-right'
);
const editorSection =
this.createSection(
'Preload',
'preload-editor-main-section'
);
const meta =
$('<div>')
.addClass(
'preload-editor-meta'
);
$('<strong>')
.text(
this.state.preloadTitle ||
'New preload'
)
.appendTo(meta);
if (this.state.mode === 'new') {
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('NEW')
.appendTo(meta);
}
editorSection.append(meta);
const textarea =
this.textarea(
this.state.preloadContent
)
.attr(
'id',
'preload-editor-content'
)
.addClass(
'preload-editor-content'
);
const editorMount =
$('<div>')
.addClass(
'preload-editor-codemirror-mount'
)
.append(textarea);
editorSection.append(
editorMount
);
left.append(editorSection);
const analysisSection =
this.createSection('Analysis');
const analysisContainer =
$('<div>')
.addClass(
'preload-editor-analysis'
);
analysisSection.append(
analysisContainer
);
const variablesSection =
this.createSection('Variables');
const variableContainer =
$('<div>')
.addClass(
'preload-editor-variables'
);
variablesSection.append(
variableContainer
);
const configSection =
this.createSection(
'Generated configuration'
);
const configPreview =
$('<pre>')
.addClass(
'preload-editor-config-preview'
);
configSection.append(configPreview);
const actions =
$('<div>')
.addClass(
'preload-editor-actions'
);
const save =
this.createButton(
'Save Preload + Config',
'preload-editor-button-primary',
() => this.saveAll()
);
const savePreload =
this.createButton(
'Save Preload Only',
'',
() => this.savePreloadOnly()
);
actions.append(save, savePreload);
right.append(
analysisSection,
variablesSection,
configSection,
actions
);
layout.append(left, right);
this.workspace.append(layout);
this.reanalyze();
this.renderVariables(variableContainer);
this.renderAnalysis(analysisContainer);
this.renderConfigPreview(configPreview);
this.mountCodeMirror(textarea.get(0))
.catch(error => {
console.error(
'Preload Editor CodeMirror:',
error
);
this.setStatus(
error.message ||
'Could not initialize CodeMirror.',
'error'
);
});
},
reanalyze() {
const source = this.getEditorContent();
const content = this.extractIncludeonly(source);
const analysis = this.analyzePreload(content);
this.state.preloadContent = source;
this.state.variables = analysis.variables;
this.state.conditions = analysis.conditions;
this.state.config =
this.prepareConfigForVariables(
this.state.config ||
this.emptyConfig(),
analysis
);
this.analysis = analysis;
},
renderAnalysis(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variableCount =
analysis.variables.length;
const conditionCount =
analysis.conditions.length;
const summary =
$('<div>')
.addClass(
'preload-editor-analysis-summary'
);
summary.append(
$('<span>').text(
variableCount +
' variable' +
(variableCount === 1 ? '' : 's')
),
$('<span>').text(
conditionCount +
' condition marker' +
(conditionCount === 1 ? '' : 's')
)
);
container.append(summary);
if (analysis.conditionVariables.length) {
container.append(
$('<div>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'Condition variables: ' +
analysis.conditionVariables.join(', ')
)
);
}
const validation =
this.validateConfig(
this.state.config,
analysis
);
if (validation.errors.length) {
const errors =
$('<ul>')
.addClass(
'preload-editor-validation-errors'
);
validation.errors.forEach(error => {
$('<li>')
.text(error)
.appendTo(errors);
});
container.append(errors);
}
if (validation.warnings.length) {
const warnings =
$('<ul>')
.addClass(
'preload-editor-validation-warnings'
);
validation.warnings.forEach(warning => {
$('<li>')
.text(warning)
.appendTo(warnings);
});
container.append(warnings);
}
if (
!validation.errors.length &&
!validation.warnings.length
) {
container.append(
$('<div>')
.addClass(
'preload-editor-validation-success'
)
.text(
'Preload and configuration are in sync.'
)
);
}
},
renderAnalysisBySelector() {
const container =
this.root &&
this.root.find(
'.preload-editor-analysis'
);
if (container && container.length) {
this.renderAnalysis(container);
}
},
renderVariables(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variables =
analysis.variables.slice();
const ordered = ['PAGE'];
variables.forEach(name => {
if (
name !== 'PAGE' &&
!ordered.includes(name)
) {
ordered.push(name);
}
});
Object.keys(
this.state.config.fields || {}
).forEach(name => {
const upper = name.toUpperCase();
if (!ordered.includes(upper)) {
ordered.push(upper);
}
});
ordered.forEach(name => {
const field =
this.state.config.fields[name] ||
this.createDefaultField(
name,
analysis
);
this.state.config.fields[name] = field;
container.append(
this.createVariableCard(
name,
field,
analysis
)
);
});
},
renderVariablesBySelector() {
const container =
this.root &&
this.root.find(
'.preload-editor-variables'
);
if (container && container.length) {
this.renderVariables(container);
}
},
createVariableCard(name, field, analysis) {
const card =
$('<div>')
.addClass(
'preload-editor-variable'
)
.attr(
'data-variable',
name
);
const header =
$('<div>')
.addClass(
'preload-editor-variable-header'
);
header.append(
$('<h3>').text(name)
);
if (name === 'PAGE') {
header.append(
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('AUTOMATIC')
);
} else if (
!analysis.variables.includes(name)
) {
header.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text('UNUSED')
);
}
card.append(header);
const label =
this.textInput(field.label || '');
label.on(
'input.preloadEditor',
() => {
field.label = label.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow('Label', label)
);
const description =
this.textarea(
field.description || ''
)
.addClass(
'preload-editor-small-textarea'
);
description.on(
'input.preloadEditor',
() => {
field.description = description.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Description',
description
)
);
const types = [
['text', 'Text'],
['textarea', 'Textarea'],
['number', 'Number'],
['boolean', 'Boolean'],
['select', 'Select'],
['multiselect', 'Multi-select'],
['page', 'Page'],
['user', 'User'],
['date', 'Date']
];
const typeSelect =
this.select(
types.map(
([value, label]) => ({
value,
label
})
),
field.type || 'text'
);
typeSelect.on(
'change.preloadEditor',
() => {
field.type = typeSelect.val();
this.markDirty();
card.replaceWith(
this.createVariableCard(
name,
field,
analysis
)
);
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Type',
typeSelect
)
);
if (name === 'PAGE') {
field.type = 'page';
const required =
this.checkbox(
field.required !== false
);
required.on(
'change.preloadEditor',
() => {
field.required =
required.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
const namespace =
this.select(
[
{
value: 'Main',
label: 'Main'
},
{
value: 'Template',
label: 'Template'
},
{
value: 'File',
label: 'File'
},
{
value: 'Category',
label: 'Category'
},
{
value: 'User',
label: 'User'
}
],
field.namespace || 'Main'
);
namespace.on(
'change.preloadEditor',
() => {
field.namespace =
namespace.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Namespace',
namespace
)
);
const allowNew =
this.checkbox(
field['allow-new'] === true
);
allowNew.on(
'change.preloadEditor',
() => {
field['allow-new'] =
allowNew.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Allow new page',
allowNew
)
);
return card;
}
const required =
this.checkbox(
field.required === true
);
required.on(
'change.preloadEditor',
() => {
field.required =
required.prop('checked');
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
const placeholder =
this.textInput(
field.placeholder || ''
);
placeholder.on(
'input.preloadEditor',
() => {
const value = placeholder.val();
if (value) {
field.placeholder = value;
} else {
delete field.placeholder;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Placeholder',
placeholder
)
);
const defaultInput =
this.textInput(
field.default === undefined
? ''
: field.default
);
defaultInput.on(
'input.preloadEditor',
() => {
const value = defaultInput.val();
if (value) {
field.default = value;
} else {
delete field.default;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Default',
defaultInput,
'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
)
);
if (field.type === 'textarea') {
const maxRows =
this.textInput(
field.maxRows || ''
).attr(
'type',
'number'
);
maxRows.on(
'input.preloadEditor',
() => {
const value =
parseInt(
maxRows.val(),
10
);
if (
Number.isFinite(value) &&
value > 0
) {
field.maxRows = value;
} else {
delete field.maxRows;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Maximum rows',
maxRows
)
);
}
if (
field.type === 'select' ||
field.type === 'multiselect'
) {
this.renderSelectConfiguration(
card,
field
);
}
return card;
},
renderSelectConfiguration(card, field) {
const source =
this.textInput(
field.source || '',
'category:Example'
);
source.on(
'input.preloadEditor',
() => {
const value = source.val();
if (value) {
field.source = value;
} else {
delete field.source;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source',
source,
'Currently supported source format: category:Category Name'
)
);
const sourcePrefix =
this.textInput(
field['source-prefix'] || ''
);
sourcePrefix.on(
'input.preloadEditor',
() => {
const value =
sourcePrefix.val();
if (value) {
field['source-prefix'] = value;
} else {
delete field['source-prefix'];
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source prefix',
sourcePrefix,
'Only source values beginning with this text are included.'
)
);
const excludeCategory =
this.textInput(
field.exclude &&
field.exclude.category
? field.exclude.category
: ''
);
excludeCategory.on(
'input.preloadEditor',
() => {
const value =
excludeCategory.val();
if (value) {
field.exclude =
Object.assign(
{},
field.exclude || {},
{ category: value }
);
} else if (field.exclude) {
delete field.exclude.category;
if (
!Object.keys(
field.exclude
).length
) {
delete field.exclude;
}
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Exclude category',
excludeCategory
)
);
card.append(
this.createTransformControls(
field,
'value',
'Value transformation'
),
this.createTransformControls(
field,
'label',
'Label transformation'
)
);
const optionsValue =
Array.isArray(field.options)
? field.options
.map(option => {
if (
typeof option === 'string'
) {
return option;
}
return (
String(
option.value || ''
) +
' | ' +
String(
option.label ||
option.value ||
''
)
);
})
.join('\n')
: '';
const options =
this.textarea(optionsValue)
.addClass(
'preload-editor-small-textarea'
);
options.on(
'input.preloadEditor',
() => {
const lines =
options.val()
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (!lines.length) {
delete field.options;
} else {
field.options =
lines.map(line => {
const parts =
line.split(
/\s*\|\s*/
);
if (parts.length === 1) {
return parts[0];
}
return {
value: parts[0],
label:
parts
.slice(1)
.join(' | ')
};
});
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Manual options',
options,
'One per line. Optional label format: value | label. Leave empty when using a source.'
)
);
},
createTransformControls(field, property, title) {
const wrapper =
$('<div>')
.addClass(
'preload-editor-transform'
);
$('<h4>')
.text(title)
.appendTo(wrapper);
const existing =
field[property] &&
typeof field[property] === 'object'
? field[property]
: {};
const transform =
this.select(
[
{
value: '',
label: 'No transformation'
},
{
value: 'remove-prefix',
label: 'Remove prefix'
},
{
value: 'remove-suffix',
label: 'Remove suffix'
},
{
value: 'replace',
label: 'Replace'
}
],
existing.transform || ''
);
const prefix =
this.textInput(
existing.prefix || ''
);
const suffix =
this.textInput(
existing.suffix || ''
);
const from =
this.textInput(
existing.from || ''
);
const to =
this.textInput(
existing.to || ''
);
const controls =
$('<div>')
.addClass(
'preload-editor-transform-controls'
);
const prefixRow =
this.fieldRow(
'Prefix',
prefix
);
const suffixRow =
this.fieldRow(
'Suffix',
suffix
);
const fromRow =
this.fieldRow(
'From',
from
);
const toRow =
this.fieldRow(
'To',
to
);
const renderInputs = () => {
const type = transform.val();
prefixRow.toggle(
type === 'remove-prefix'
);
suffixRow.toggle(
type === 'remove-suffix'
);
fromRow.toggle(
type === 'replace'
);
toRow.toggle(
type === 'replace'
);
};
const update = () => {
const type = transform.val();
let object = null;
if (type === 'remove-prefix') {
if (prefix.val()) {
object = {
transform: type,
prefix: prefix.val()
};
}
} else if (type === 'remove-suffix') {
if (suffix.val()) {
object = {
transform: type,
suffix: suffix.val()
};
}
} else if (type === 'replace') {
if (from.val()) {
object = {
transform: type,
from: from.val(),
to: to.val()
};
}
}
if (object) {
field[property] = object;
} else {
delete field[property];
}
this.markDirty();
this.renderConfigPreviewBySelector();
};
transform.on(
'change.preloadEditor',
() => {
update();
renderInputs();
}
);
[
prefix,
suffix,
from,
to
].forEach(input => {
input.on(
'input.preloadEditor',
update
);
});
controls.append(
this.fieldRow(
'Transformation',
transform
),
prefixRow,
suffixRow,
fromRow,
toRow
);
renderInputs();
wrapper.append(controls);
return wrapper;
},
// Config output
getCleanConfig() {
const config =
JSON.parse(
JSON.stringify(
this.state.config
)
);
const fields = config.fields || {};
const orderedFields = {};
if (fields.PAGE) {
orderedFields.PAGE = fields.PAGE;
}
Object.keys(fields)
.filter(
name =>
name.toUpperCase() !== 'PAGE'
)
.forEach(name => {
orderedFields[name] = fields[name];
});
config.fields = orderedFields;
return config;
},
getConfigJSON() {
return JSON.stringify(
this.getCleanConfig(),
null,
4
);
},
renderConfigPreview(container) {
if (!container) {
return;
}
container.text(
this.getConfigJSON()
);
},
renderConfigPreviewBySelector() {
const preview =
this.root &&
this.root.find(
'.preload-editor-config-preview'
);
if (preview && preview.length) {
this.renderConfigPreview(preview);
}
this.renderAnalysisBySelector();
},
markDirty() {
this.state.dirty = true;
},
// Save
async savePreloadOnly() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle = title;
}
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(validation);
return;
}
this.setStatus(
'Saving preload...',
'loading'
);
try {
const exists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
exists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!exists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.setStatus(
'Preload saved successfully.',
'success'
);
mw.notify(
'Preload saved.',
{ type: 'success' }
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload.',
'error'
);
mw.notify(
error.message ||
'Could not save preload.',
{ type: 'error' }
);
}
},
async saveAll() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle = title;
}
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(validation);
return;
}
const configTitle =
title.replace(/\/+$/, '') +
'/config.json';
this.setStatus(
'Saving preload and config...',
'loading'
);
try {
const preloadExists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
preloadExists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!preloadExists
);
const configExists =
await this.pageExists(configTitle);
await this.savePage(
configTitle,
this.getConfigJSON(),
configExists
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor',
!configExists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.state.preloadTitle = title;
this.setStatus(
'Preload and config saved successfully.',
'success'
);
mw.notify(
'Preload and config saved.',
{ type: 'success' }
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload/config.',
'error'
);
mw.notify(
error.message ||
'Could not save preload/config.',
{ type: 'error' }
);
}
},
validateBeforeSave() {
const errors = [];
const warnings = [];
const title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
errors.push(
'A preload title is required.'
);
}
if (
title &&
!/\/Preload$/i.test(title)
) {
warnings.push(
'The title does not end with "/Preload".'
);
}
const content =
this.getEditorContent();
this.state.preloadContent = content;
if (!content.trim()) {
errors.push(
'The preload cannot be empty.'
);
}
if (!/<includeonly>/i.test(content)) {
warnings.push(
'The preload does not contain an <includeonly> section.'
);
}
if (!/<noinclude>/i.test(content)) {
warnings.push(
'The preload does not contain a <noinclude> section.'
);
}
const analysis =
this.analyzePreload(
this.extractIncludeonly(content)
);
const configValidation =
this.validateConfig(
this.state.config,
analysis
);
errors.push(
...configValidation.errors
);
warnings.push(
...configValidation.warnings
);
let depth = 0;
analysis.conditions.forEach(condition => {
if (condition.type === 'if') {
depth++;
} else if (
condition.type === 'endif'
) {
depth--;
if (depth < 0) {
errors.push(
'Found @endif without a matching @if.'
);
depth = 0;
}
} else if (
condition.type === 'elseif' ||
condition.type === 'else'
) {
if (depth === 0) {
errors.push(
'@' +
condition.type +
' exists outside an @if block.'
);
}
}
});
if (depth > 0) {
errors.push(
'One or more @if blocks are missing @endif.'
);
}
return {
errors,
warnings,
valid: errors.length === 0
};
},
showValidationErrors(validation) {
this.renderAnalysisBySelector();
this.setStatus(
validation.errors.join(' '),
'error'
);
mw.notify(
validation.errors.join('\n'),
{ type: 'error' }
);
}
};
mw.PreloadEditor = PreloadEditor;
$(function () {
const title =
mw.config.get('wgPageName');
if (
title !==
'Petit_Planet:Preload_Editor'
) {
return;
}
PreloadEditor.init();
});
}(mediaWiki, jQuery));