MediaWiki:BlueprintLoader.js
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.
/**
* EQL Wiki - Dynamic Page Blueprint Loader
*
* On the source editor for a page that does not yet exist:
*
* 1. Reads blueprint headings dynamically from Help:Contents.
* 2. Displays a blueprint selector above the normal source editor.
* 3. Fetches the selected Help:Contents section on demand.
* 4. Extracts the first <pre>...</pre> block from that section.
* 5. Loads that exact wikitext into #wpTextbox1.
*
* Help:Contents remains the single source of truth.
*/
( function ( mw, $ ) {
'use strict';
var BLUEPRINT_PAGE = 'Help:Contents';
var PANEL_ID = 'eql-blueprint-loader';
/**
* Convert a small HTML heading string returned by tocdata
* into plain text.
*/
function htmlToText( html ) {
var div = document.createElement( 'div' );
div.innerHTML = String( html || '' );
return ( div.textContent || div.innerText || '' ).trim();
}
/**
* Turn headings such as:
*
* NPC Page Blueprints
* Merchant Page Blueprints
*
* into friendly dropdown labels.
*
* We deliberately retain the heading wording rather than
* maintaining a second hard-coded list.
*/
function makeLabel( heading ) {
return heading
.replace( /\s+Blueprints?\s*$/i, '' )
.trim() + ' Blueprint';
}
/**
* Get editor contents through textSelection.
*
* This works with the normal textarea, WikiEditor,
* and CodeMirror.
*/
function getEditorContents( $textarea ) {
try {
return String(
$textarea.textSelection( 'getContents' ) || ''
);
} catch ( e ) {
return String( $textarea.val() || '' );
}
}
/**
* Replace the complete editor contents.
*
* textSelection is intentionally used instead of .val()
* because it also works when CodeMirror is active.
*/
function setEditorContents( $textarea, text ) {
try {
$textarea.textSelection(
'setContents',
text
);
} catch ( e ) {
$textarea.val( text );
}
$textarea.trigger( 'input' );
$textarea.trigger( 'change' );
$textarea.trigger( 'focus' );
}
/**
* Extract the first <pre>...</pre> block from the raw
* section wikitext.
*
* This allows Help:Contents to contain explanatory prose
* around the blueprint without copying that prose into
* the new page.
*
* Also supports:
*
* <pre><nowiki>...</nowiki></pre>
*/
function extractBlueprint( sectionText ) {
var match;
var blueprint;
sectionText = String( sectionText || '' );
match = sectionText.match(
/<pre(?:\s[^>]*)?>([\s\S]*?)<\/pre>/i
);
if ( !match ) {
return null;
}
blueprint = match[ 1 ];
/*
* If somebody later wraps a blueprint in <nowiki>,
* remove only the outer wrapper.
*/
blueprint = blueprint.replace(
/^\s*<nowiki>([\s\S]*?)<\/nowiki>\s*$/i,
'$1'
);
/*
* Avoid leading blank lines while preserving the
* blueprint's internal formatting.
*/
blueprint = blueprint
.replace( /^\s*\n/, '' )
.replace( /\s+$/, '' );
return blueprint + '\n';
}
/**
* Read the Help:Contents table of contents.
*
* tocdata is preferred. A fallback to the older "sections"
* property is included so this remains tolerant of an
* older parser/API configuration.
*/
function fetchBlueprintSections( api ) {
return api.get( {
action: 'parse',
page: BLUEPRINT_PAGE,
prop: 'tocdata',
formatversion: 2
} ).then( function ( data ) {
var tocdata =
data &&
data.parse &&
data.parse.tocdata;
var sections =
tocdata &&
Array.isArray( tocdata.sections )
? tocdata.sections
: [];
if ( sections.length ) {
return sections;
}
return api.get( {
action: 'parse',
page: BLUEPRINT_PAGE,
prop: 'sections',
formatversion: 2
} ).then( function ( fallbackData ) {
return (
fallbackData &&
fallbackData.parse &&
Array.isArray(
fallbackData.parse.sections
)
)
? fallbackData.parse.sections
: [];
} );
} );
}
/**
* Return only real Blueprint headings.
*
* Any heading whose visible text ends in:
*
* Blueprint
* Blueprints
*
* is automatically eligible.
*/
function normalizeBlueprintSections( sections ) {
return sections
.map( function ( section ) {
var heading = htmlToText(
section.line || ''
);
return {
heading: heading,
label: makeLabel( heading ),
index: String(
section.index || ''
),
anchor:
section.linkAnchor ||
section.anchor ||
''
};
} )
.filter( function ( section ) {
return (
section.index &&
/\bBlueprints?\s*$/i.test(
section.heading
)
);
} );
}
/**
* Retrieve the raw wikitext of one specific section.
*/
function fetchBlueprintWikitext(
api,
sectionIndex
) {
return api.get( {
action: 'parse',
page: BLUEPRINT_PAGE,
prop: 'wikitext',
section: sectionIndex,
formatversion: 2
} ).then( function ( data ) {
var wikitext =
data &&
data.parse
? data.parse.wikitext
: '';
/*
* formatversion=2 normally returns a string.
* Keep compatibility with the older "*" wrapper.
*/
if (
wikitext &&
typeof wikitext === 'object' &&
Object.prototype.hasOwnProperty.call(
wikitext,
'*'
)
) {
wikitext = wikitext[ '*' ];
}
return String( wikitext || '' );
} );
}
function addStyles() {
if (
document.getElementById(
'eql-blueprint-loader-styles'
)
) {
return;
}
$( '<style>', {
id: 'eql-blueprint-loader-styles',
text:
'#' + PANEL_ID + ' {' +
'box-sizing:border-box;' +
'margin:0 0 1rem 0;' +
'padding:1rem 1.1rem;' +
'background:' +
'linear-gradient(' +
'180deg,' +
'rgba(16,21,29,.97),' +
'rgba(9,13,19,.98)' +
');' +
'border:1px solid rgba(216,183,92,.38);' +
'border-left:5px solid rgba(216,183,92,.82);' +
'border-radius:8px;' +
'box-shadow:' +
'0 9px 24px rgba(0,0,0,.3),' +
'inset 0 1px 0 rgba(255,255,255,.035);' +
'}' +
'#' + PANEL_ID + ' .eql-blueprint-title {' +
'margin:0 0 .35rem 0;' +
'color:#d8b75c;' +
'font-family:Georgia,' +
'Times New Roman,serif;' +
'font-size:1.2rem;' +
'font-weight:700;' +
'letter-spacing:.035em;' +
'}' +
'#' + PANEL_ID + ' .eql-blueprint-description {' +
'margin:0 0 .8rem 0;' +
'color:#aeb8c7;' +
'font-size:.93rem;' +
'line-height:1.4;' +
'}' +
'#' + PANEL_ID + ' .eql-blueprint-controls {' +
'display:flex;' +
'flex-wrap:wrap;' +
'align-items:center;' +
'gap:.65rem;' +
'}' +
'#' + PANEL_ID + ' select {' +
'box-sizing:border-box;' +
'min-width:260px;' +
'max-width:100%;' +
'min-height:2.35rem;' +
'padding:.35rem .55rem;' +
'background:#0b1017;' +
'border:1px solid rgba(216,183,92,.36);' +
'border-radius:5px;' +
'color:#e2e2cf;' +
'}' +
'#' + PANEL_ID + ' button {' +
'min-height:2.35rem;' +
'padding:.35rem .85rem;' +
'background:' +
'linear-gradient(' +
'180deg,' +
'#806d32,' +
'#594817' +
');' +
'border:1px solid #b69c4b;' +
'border-radius:5px;' +
'color:#fff5cc;' +
'font-weight:700;' +
'cursor:pointer;' +
'}' +
'#' + PANEL_ID + ' button:disabled {' +
'cursor:default;' +
'opacity:.55;' +
'}' +
'#' + PANEL_ID + ' .eql-blueprint-source {' +
'color:#b9c8ff;' +
'font-size:.9rem;' +
'}' +
'#' + PANEL_ID + ' .eql-blueprint-status {' +
'display:block;' +
'margin-top:.65rem;' +
'min-height:1.2em;' +
'color:#aeb8c7;' +
'font-size:.88rem;' +
'}'
} ).appendTo( document.head );
}
function buildPanel(
$textarea,
api,
blueprints
) {
var $panel;
var $select;
var $button;
var $status;
var $sourceLink;
var $editorHost;
if (
document.getElementById(
PANEL_ID
)
) {
return;
}
addStyles();
$panel = $( '<div>', {
id: PANEL_ID
} );
$( '<div>', {
class: 'eql-blueprint-title',
text: 'CREATE FROM A BLUEPRINT'
} ).appendTo( $panel );
$( '<div>', {
class: 'eql-blueprint-description',
text:
'Load a standard page skeleton from ' +
'Help:Contents, or continue with the ' +
'empty editor below.'
} ).appendTo( $panel );
var $controls = $( '<div>', {
class: 'eql-blueprint-controls'
} ).appendTo( $panel );
$select = $( '<select>', {
'aria-label': 'Select page blueprint'
} ).appendTo( $controls );
$( '<option>', {
value: '',
text: 'Select a blueprint...'
} ).appendTo( $select );
blueprints.forEach( function ( blueprint ) {
$( '<option>', {
value: blueprint.index,
text: blueprint.label
} )
.attr(
'data-anchor',
blueprint.anchor
)
.appendTo( $select );
} );
$button = $( '<button>', {
type: 'button',
text: 'Load Blueprint',
disabled: true
} ).appendTo( $controls );
$sourceLink = $( '<a>', {
class: 'eql-blueprint-source',
href: mw.util.getUrl(
BLUEPRINT_PAGE
),
text: 'View Blueprints',
target: '_blank'
} ).appendTo( $controls );
$status = $( '<span>', {
class: 'eql-blueprint-status',
'aria-live': 'polite'
} ).appendTo( $panel );
/*
* Put the selector ABOVE the actual editor UI,
* not inside or instead of the editor.
*/
$editorHost =
$textarea.closest( '.wikiEditor-ui' );
if ( !$editorHost.length ) {
$editorHost = $textarea;
}
$panel.insertBefore( $editorHost );
$select.on( 'change', function () {
var $option =
$select.find(
'option:selected'
);
var anchor =
$option.attr(
'data-anchor'
) || '';
$button.prop(
'disabled',
!$select.val()
);
if ( anchor ) {
$sourceLink.attr(
'href',
mw.util.getUrl(
BLUEPRINT_PAGE
) +
'#' +
anchor
);
$sourceLink.text(
'View This Blueprint'
);
} else {
$sourceLink.attr(
'href',
mw.util.getUrl(
BLUEPRINT_PAGE
)
);
$sourceLink.text(
'View Blueprints'
);
}
$status.text( '' );
} );
$button.on( 'click', function () {
var sectionIndex =
$select.val();
var existing;
if ( !sectionIndex ) {
return;
}
existing =
getEditorContents(
$textarea
);
/*
* Usually the panel only appears on an empty
* creation page. This protects work if the
* contributor typed something before selecting
* a blueprint.
*/
if (
existing.trim() &&
!window.confirm(
'The editor already contains text. ' +
'Replace it with the selected blueprint?'
)
) {
return;
}
$button.prop(
'disabled',
true
);
$select.prop(
'disabled',
true
);
$status.text(
'Loading current blueprint from Help:Contents...'
);
fetchBlueprintWikitext(
api,
sectionIndex
).then(
function ( sectionText ) {
var blueprint =
extractBlueprint(
sectionText
);
if ( blueprint === null ) {
throw new Error(
'No <pre> block found.'
);
}
setEditorContents(
$textarea,
blueprint
);
$status.text(
'Blueprint loaded. ' +
'Edit the fields below, then publish normally.'
);
}
).catch( function ( error ) {
if (
window.console &&
console.error
) {
console.error(
'EQL Blueprint Loader:',
error
);
}
$status.text(
'Could not load this blueprint. ' +
'Check Help:Contents and make ' +
'sure the section contains a <pre> block.'
);
} ).then( function () {
$select.prop(
'disabled',
false
);
$button.prop(
'disabled',
!$select.val()
);
} );
} );
}
function init() {
var $textarea;
var api;
var existing;
/*
* Only source editing / creation.
*/
if (
[
'edit',
'submit'
].indexOf(
mw.config.get( 'wgAction' )
) === -1
) {
return;
}
/*
* Existing articles should not get the creation panel.
*/
if (
Number(
mw.config.get(
'wgArticleId'
)
) !== 0
) {
return;
}
$textarea = $( '#wpTextbox1' );
if ( !$textarea.length ) {
return;
}
existing =
getEditorContents(
$textarea
);
/*
* Do not interfere with other preloaded creation
* workflows, such as the three-class Build Guide
* generator.
*
* The blueprint chooser is specifically for a truly
* empty creation editor.
*/
if ( existing.trim() ) {
return;
}
api = new mw.Api();
fetchBlueprintSections(
api
).then( function ( sections ) {
var blueprints =
normalizeBlueprintSections(
sections
);
if ( !blueprints.length ) {
return;
}
buildPanel(
$textarea,
api,
blueprints
);
} ).catch( function ( error ) {
if (
window.console &&
console.error
) {
console.error(
'EQL Blueprint Loader:',
error
);
}
} );
}
mw.loader.using( [
'mediawiki.api',
'mediawiki.util',
'jquery.textSelection'
] ).then( function () {
$( init );
} );
}( mediaWiki, jQuery ) );