Jump to content

MediaWiki:BlueprintLoader.js

From EverQuest Legends Wiki
Revision as of 00:49, 10 August 2026 by Maergoth (talk | contribs) (Created page with "/** * 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....")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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