code:
/***********************************************************************************************************************

	markup/lexer.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

var { // eslint-disable-line no-var
	/* eslint-disable no-unused-vars */
	EOF,
	Lexer
	/* eslint-enable no-unused-vars */
} = (() => {
	'use strict';

	// End of file (string, actually).
	const EOF = -1;

	/*******************************************************************************************************************
		Lexer Class.
	*******************************************************************************************************************/
	class Lexer {
		constructor(source, initialState) {
			if (arguments.length < 2) {
				throw new Error('Lexer constructor called with too few parameters (source:string , initialState:function)');
			}

			/*
				this.source  → the string to be scanned
				this.initial → initial state
				this.state   → current state
				this.start   → start position of an item
				this.pos     → current position in the source string
				this.depth   → current brace/bracket/parenthesis nesting depth
				this.items   → scanned item queue
				this.data    → lexing data
			*/
			Object.defineProperties(this, {
				source : {
					value : source
				},

				initial : {
					value : initialState
				},

				state : {
					writable : true,
					value    : initialState
				},

				start : {
					writable : true,
					value    : 0
				},

				pos : {
					writable : true,
					value    : 0
				},

				depth : {
					writable : true,
					value    : 0
				},

				items : {
					writable : true,
					value    : []
				},

				data : {
					writable : true,
					value    : {}
				}
			});
		}

		reset() {
			this.state  = this.initial;
			this.start  = 0;
			this.pos    = 0;
			this.depth  = 0;
			this.items  = [];
			this.data   = {};
		}

		run() {
			// scan the source string until no states remain
			while (this.state !== null) {
				this.state = this.state(this);
			}

			// return the array of items
			return this.items;
		}

		nextItem() {
			// scan the source string until we have an item or no states remain
			while (this.items.length === 0 && this.state !== null) {
				this.state = this.state(this);
			}

			// return the current item
			return this.items.shift();
		}

		next() {
			if (this.pos >= this.source.length) {
				return EOF;
			}

			return this.source[this.pos++];
		}

		peek() {
			if (this.pos >= this.source.length) {
				return EOF;
			}

			return this.source[this.pos];
		}

		backup(num) {
			// if (num) {
			// 	this.pos -= num;
			// }
			// else {
			// 	--this.pos;
			// }
			this.pos -= num || 1;
		}

		forward(num) {
			// if (num) {
			// 	this.pos += num;
			// }
			// else {
			// 	++this.pos;
			// }
			this.pos += num || 1;
		}

		ignore() {
			this.start = this.pos;
		}

		accept(valid) {
			const ch = this.next();

			if (ch === EOF) {
				return false;
			}

			if (valid.includes(ch)) {
				return true;
			}

			this.backup();
			return false;
		}

		acceptRe(validRe) {
			const ch = this.next();

			if (ch === EOF) {
				return false;
			}

			if (validRe.test(ch)) {
				return true;
			}

			this.backup();
			return false;
		}

		acceptRun(valid) {
			for (;;) {
				const ch = this.next();

				if (ch === EOF) {
					return;
				}

				if (!valid.includes(ch)) {
					break;
				}
			}

			this.backup();
		}

		acceptRunRe(validRe) {
			for (;;) {
				const ch = this.next();

				if (ch === EOF) {
					return;
				}

				if (!validRe.test(ch)) {
					break;
				}
			}

			this.backup();
		}

		emit(type) {
			this.items.push({
				type,
				text  : this.source.slice(this.start, this.pos),
				start : this.start,
				pos   : this.pos
			});
			this.start = this.pos;
		}

		error(type, message) {
			if (arguments.length < 2) {
				throw new Error('Lexer.prototype.error called with too few parameters (type:number , message:string)');
			}

			this.items.push({
				type,
				message,
				text  : this.source.slice(this.start, this.pos),
				start : this.start,
				pos   : this.pos
			});
			return null;
		}

		static enumFromNames(names) {
			const obj = names.reduce((obj, name, i) => {
				obj[name] = i; // eslint-disable-line no-param-reassign
				return obj;
			}, {});
			return Object.freeze(Object.assign(Object.create(null), obj));
		}
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return {
		EOF,
		Lexer
	};
})();


code:
/***********************************************************************************************************************

	markup/scripting.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Engine, Patterns, State, Story, Util, stringFrom */

var Scripting = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	/* eslint-disable no-unused-vars */

	/*******************************************************************************************************************
		Deprecated Legacy Functions.
	*******************************************************************************************************************/
	/*
		[DEPRECATED] Returns the jQuery-wrapped target element(s) after making them accessible
		clickables (ARIA compatibility).

		NOTE: Unused, included only for compatibility.
	*/
	function addAccessibleClickHandler(targets, selector, handler, one, namespace) {
		if (arguments.length < 2) {
			throw new Error('addAccessibleClickHandler insufficient number of parameters');
		}

		let fn;
		let opts;

		if (typeof selector === 'function') {
			fn = selector;
			opts = {
				namespace : one,
				one       : !!handler
			};
		}
		else {
			fn = handler;
			opts = {
				namespace,
				one : !!one,
				selector
			};
		}

		if (typeof fn !== 'function') {
			throw new TypeError('addAccessibleClickHandler handler parameter must be a function');
		}

		return jQuery(targets).ariaClick(opts, fn);
	}

	/*
		[DEPRECATED] Returns a new DOM element, optionally appending it to the passed DOM element, if any.

		NOTE: Unused, included only for compatibility.
	*/
	function insertElement(place, type, id, classNames, text, title) { // eslint-disable-line max-params
		const $el = jQuery(document.createElement(type));

		// Add attributes/properties.
		if (id) {
			$el.attr('id', id);
		}

		if (classNames) {
			$el.addClass(classNames);
		}

		if (title) {
			$el.attr('title', title);
		}

		// Add text content.
		if (text) {
			$el.text(text);
		}

		// Append it to the given node.
		if (place) {
			$el.appendTo(place);
		}

		return $el[0];
	}

	/*
		[DEPRECATED] Creates a new text node and appends it to the passed DOM element.

		NOTE: Unused, included only for compatibility.
	*/
	function insertText(place, text) {
		jQuery(place).append(document.createTextNode(text));
	}

	/*
		[DEPRECATED] Removes all children from the passed DOM node.

		NOTE: Unused, included only for compatibility.
	*/
	function removeChildren(node) {
		jQuery(node).empty();
	}

	/*
		[DEPRECATED] Removes the passed DOM node.

		NOTE: Unused, included only for compatibility.
	*/
	function removeElement(node) {
		jQuery(node).remove();
	}

	/*
		[DEPRECATED] Fades a DOM element in or out.

		NOTE: Unused, included only for compatibility.
	*/
	function fade(el, options) {
		/* eslint-disable no-param-reassign */
		const direction = options.fade === 'in' ? 1 : -1;
		let current;
		let proxy      = el.cloneNode(true);
		let intervalId; // eslint-disable-line prefer-const

		function tick() {
			current += 0.05 * direction;
			setOpacity(proxy, Math.easeInOut(current));

			if (direction === 1 && current >= 1 || direction === -1 && current <= 0) {
				el.style.visibility = options.fade === 'in' ? 'visible' : 'hidden';
				proxy.parentNode.replaceChild(el, proxy);
				proxy = null;
				window.clearInterval(intervalId);

				if (options.onComplete) {
					options.onComplete();
				}
			}
		}

		function setOpacity(el, opacity) {
			// Old IE.
			el.style.zoom = 1;
			el.style.filter = `alpha(opacity=${Math.floor(opacity * 100)})`;

			// CSS.
			el.style.opacity = opacity;
		}

		el.parentNode.replaceChild(proxy, el);

		if (options.fade === 'in') {
			current = 0;
			proxy.style.visibility = 'visible';
		}
		else {
			current = 1;
		}

		setOpacity(proxy, current);
		intervalId = window.setInterval(tick, 25);
		/* eslint-enable no-param-reassign */
	}

	/*
		[DEPRECATED] Scrolls the browser window to ensure that a DOM element is in view.

		NOTE: Unused, included only for compatibility.
	*/
	function scrollWindowTo(el, incrementBy) {
		/* eslint-disable no-param-reassign */
		let increment = incrementBy != null ? Number(incrementBy) : 0.1; // lazy equality for null

		if (Number.isNaN(increment) || !Number.isFinite(increment) || increment < 0) {
			increment = 0.1;
		}
		else if (increment > 1) {
			increment = 1;
		}

		const start     = window.scrollY ? window.scrollY : document.body.scrollTop;
		const end       = ensureVisible(el);
		const distance  = Math.abs(start - end);
		const direction = start > end ? -1 : 1;
		let progress   = 0;
		let intervalId; // eslint-disable-line prefer-const

		function tick() {
			progress += increment;
			window.scroll(0, start + direction * (distance * Math.easeInOut(progress)));

			if (progress >= 1) {
				window.clearInterval(intervalId);
			}
		}

		function findPosY(el) { // eslint-disable-line no-shadow
			let curtop = 0;

			while (el.offsetParent) {
				curtop += el.offsetTop;
				el = el.offsetParent;
			}

			return curtop;
		}

		function ensureVisible(el) { // eslint-disable-line no-shadow
			const posTop    = findPosY(el);
			const posBottom = posTop + el.offsetHeight;
			const winTop    = window.scrollY ? window.scrollY : document.body.scrollTop;
			const winHeight = window.innerHeight ? window.innerHeight : document.body.clientHeight;
			const winBottom = winTop + winHeight;

			return posTop >= winTop && posBottom > winBottom && el.offsetHeight < winHeight
				? posTop - (winHeight - el.offsetHeight) + 20
				: posTop;
		}

		intervalId = window.setInterval(tick, 25);
		/* eslint-enable no-param-reassign */
	}

	/*
		[DEPRECATED] Returns the simple string representation of the passed value or,
		if there is none, the passed default value.

		NOTE: Unused, included only for compatibility.
	*/
	function toStringOrDefault(value /* , defValue */) {
		return stringFrom(value);
	}

	/*******************************************************************************************************************
		User Functions.
	*******************************************************************************************************************/
	/*
		Returns a random value from its given arguments.
	*/
	function either(/* variadic */) {
		if (arguments.length === 0) {
			return;
		}

		return Array.prototype.concat.apply([], arguments).random();
	}

	/*
		Removes the given key, and its value, from the story metadata store.
	*/
	function forget(key) {
		if (typeof key !== 'string') {
			throw new TypeError(`forget key parameter must be a string (received: ${Util.getType(key)})`);
		}

		State.metadata.delete(key);
	}

	/*
		Returns whether a passage with the given title exists within the story
		history.  If multiple passage titles are given, returns the logical-AND
		aggregate of the set.
	*/
	function hasVisited(/* variadic */) {
		if (arguments.length === 0) {
			throw new Error('hasVisited called with insufficient parameters');
		}

		if (State.isEmpty()) {
			return false;
		}

		const needles = Array.prototype.concat.apply([], arguments);
		const played  = State.passages;

		for (let i = 0, iend = needles.length; i < iend; ++i) {
			if (!played.includes(needles[i])) {
				return false;
			}
		}

		return true;
	}

	/*
		Returns the number of turns that have passed since the last instance of the given passage
		occurred within the story history or `-1` if it does not exist.  If multiple passages are
		given, returns the lowest count (which can be `-1`).
	*/
	function lastVisited(/* variadic */) {
		if (arguments.length === 0) {
			throw new Error('lastVisited called with insufficient parameters');
		}

		if (State.isEmpty()) {
			return -1;
		}

		const needles = Array.prototype.concat.apply([], arguments);
		const played  = State.passages;
		const uBound  = played.length - 1;
		let turns = State.turns;

		for (let i = 0, iend = needles.length; i < iend && turns > -1; ++i) {
			const lastIndex = played.lastIndexOf(needles[i]);
			turns = Math.min(turns, lastIndex === -1 ? -1 : uBound - lastIndex);
		}

		return turns;
	}

	/*
		Sets the given key/value pair within the story metadata store.
	*/
	function memorize(key, value) {
		if (typeof key !== 'string') {
			throw new TypeError(`memorize key parameter must be a string (received: ${Util.getType(key)})`);
		}

		State.metadata.set(key, value);
	}

	/*
		Returns the title of the current passage.
	*/
	function passage() {
		return State.passage;
	}

	/*
		Returns the title of a previous passage, either the most recent one whose title does not
		match that of the active passage or the one at the optional offset, or an empty string,
		if there is no such passage.
	*/
	function previous(/* legacy: offset */) {
		const passages = State.passages;

		/* legacy: behavior with an offset */
		if (arguments.length > 0) {
			const offset = Number(arguments[0]);

			if (!Number.isSafeInteger(offset) || offset < 1) {
				throw new RangeError('previous offset parameter must be a positive integer greater than zero');
			}

			return passages.length > offset ? passages[passages.length - 1 - offset] : '';
		}
		/* /legacy */

		for (let i = passages.length - 2; i >= 0; --i) {
			if (passages[i] !== State.passage) {
				return passages[i];
			}
		}

		return '';
	}

	/*
		Returns a pseudo-random whole number (integer) within the range of the given bounds.
	*/
	function random(/* [min ,] max, useMath */) {
		let min = 0;
		let max;
		let useMath = false;

		switch (arguments.length) {
		case 0:
			throw new Error('random called with insufficient parameters');
		case 1:
				max = Math.trunc(arguments[0]);
				break;
		case 2:
			if (arguments[1] === true) {
				max = Math.trunc(arguments[0]);
				useMath = true;
				break;
			}
			// falls-through
		default:
			min = Math.trunc(arguments[0]);
			max = Math.trunc(arguments[1]);
			useMath = arguments[2] == true;
			break;
		}

		if (!Number.isInteger(min)) {
			throw new Error('random min parameter must be an integer');
		}
		if (!Number.isInteger(max)) {
			throw new Error('random max parameter must be an integer');
		}

		if (min > max) {
			[min, max] = [max, min];
		}
		return Math.floor((useMath ? Math.random() : State.random()) * (max - min + 1)) + min;
	}

	/*
		Returns a pseudo-random real number (floating-point) within the range of the given bounds.

		NOTE: Unlike with its sibling function `random()`, the `max` parameter
		is exclusive, not inclusive—i.e. the range goes to, but does not include,
		the given value.
	*/
	function randomFloat(/* [min ,] max, useMath */) {
		let min = 0.0;
		let max;
		let useMath = false;

		switch (arguments.length) {
		case 0:
			throw new Error('randomFloat called with insufficient parameters');
		case 1:
			min = 0.0;
			max = Number(arguments[0]);
			break;
		case 2:
			if (arguments[1] === true) {
				max = Number(arguments[0]);
				useMath = true;
				break;
			}
			// falls-through
		default:
			min = Number(arguments[0]);
			max = Number(arguments[1]);
			useMath = arguments[1] == true;
			break;
		}

		if (Number.isNaN(min) || !Number.isFinite(min)) {
			throw new Error('randomFloat min parameter must be a number');
		}
		if (Number.isNaN(max) || !Number.isFinite(max)) {
			throw new Error('randomFloat max parameter must be a number');
		}

		if (min > max) {
			[min, max] = [max, min];
		}
		return (useMath ? Math.random() : State.random()) * (max - min) + min;
	}

	/*
		Returns the value of the given key from the story metadata store
		or the given default value if the key does not exist.
	*/
	function recall(key, defaultValue) {
		if (typeof key !== 'string') {
			throw new TypeError(`recall key parameter must be a string (received: ${Util.getType(key)})`);
		}

		return State.metadata.has(key) ? State.metadata.get(key) : defaultValue;
	}

	/*
		Returns a new array consisting of all of the tags of the given passages.
	*/
	function tags(/* variadic */) {
		if (arguments.length === 0) {
			return Story.get(State.passage).tags.slice(0);
		}

		const passages = Array.prototype.concat.apply([], arguments);
		let tags = [];

		for (let i = 0, iend = passages.length; i < iend; ++i) {
			tags = tags.concat(Story.get(passages[i]).tags);
		}

		return tags;
	}

	/*
		Returns a reference to the current temporary _variables store.
	*/
	function temporary() {
		return State.temporary;
	}

	/*
		Returns the number of milliseconds which have passed since the current passage was rendered.
	*/
	function time() {
		return Engine.lastPlay === null ? 0 : Util.now() - Engine.lastPlay;
	}

	/*
		Returns the number of passages that the player has visited.

		NOTE: Passages which were visited but have been undone—e.g. via the backward
		button or the `<<back>>` macro—are no longer part of the in-play story
		history and thus are not tallied.  Passages which were visited but have
		expired from the story history, on the other hand, are tallied.
	*/
	function turns() {
		return State.turns;
	}

	/*
		Returns a reference to the current story $variables store.
	*/
	function variables() {
		return State.variables;
	}

	/*
		Returns the number of times that the passage with the given title exists within the story
		history.  If multiple passage titles are given, returns the lowest count.
	*/
	function visited(/* variadic */) {
		if (State.isEmpty()) {
			return 0;
		}

		const needles = Array.prototype.concat.apply([], arguments.length === 0 ? [State.passage] : arguments);
		const played  = State.passages;
		let count = State.turns;

		for (let i = 0, iend = needles.length; i < iend && count > 0; ++i) {
			count = Math.min(count, played.count(needles[i]));
		}

		return count;
	}

	/*
		Returns the number of passages within the story history which are tagged with all of the given tags.
	*/
	function visitedTags(/* variadic */) {
		if (arguments.length === 0) {
			throw new Error('visitedTags called with insufficient parameters');
		}

		if (State.isEmpty()) {
			return 0;
		}

		const needles = Array.prototype.concat.apply([], arguments);
		const nLength = needles.length;
		const played  = State.passages;
		const seen    = new Map();
		let count = 0;

		for (let i = 0, iend = played.length; i < iend; ++i) {
			const title = played[i];

			if (seen.has(title)) {
				if (seen.get(title)) {
					++count;
				}
			}
			else {
				const tags = Story.get(title).tags;

				if (tags.length > 0) {
					let found = 0;

					for (let j = 0; j < nLength; ++j) {
						if (tags.includes(needles[j])) {
							++found;
						}
					}

					if (found === nLength) {
						++count;
						seen.set(title, true);
					}
					else {
						seen.set(title, false);
					}
				}
			}
		}

		return count;
	}

	/* eslint-enable no-unused-vars */

	/*******************************************************************************************************************
		Import Functions.
	*******************************************************************************************************************/
	var { // eslint-disable-line no-var
		/* eslint-disable no-unused-vars */
		importScripts,
		importStyles
		/* eslint-enable no-unused-vars */
	} = (() => {
		// Slugify the given URL.
		function slugifyUrl(url) {
			return Util.parseUrl(url).path
				.replace(/^[^\w]+|[^\w]+$/g, '')
				.replace(/[^\w]+/g, '-')
				.toLocaleLowerCase();
		}

		// Add a <script> element which will load the script from the given URL.
		function addScript(url) {
			return new Promise((resolve, reject) => {
				/*
					WARNING: The ordering of the code within this function is important,
					as some browsers don't play well with different arrangements, so
					be careful when mucking around with it.

					The best supported ordering seems be: events → DOM append → attributes.
				*/
				jQuery(document.createElement('script'))
					.one('load abort error', ev => {
						jQuery(ev.target).off();

						if (ev.type === 'load') {
							resolve(ev.target);
						}
						else {
							reject(new Error(`importScripts failed to load the script "${url}".`));
						}
					})
					.appendTo(document.head)
					.attr({
						id   : `script-imported-${slugifyUrl(url)}`,
						type : 'text/javascript',
						src  : url
					});
			});
		}

		// Add a <link> element which will load the stylesheet from the given URL.
		function addStyle(url) {
			return new Promise((resolve, reject) => {
				/*
					WARNING: The ordering of the code within this function is important,
					as some browsers don't play well with different arrangements, so
					be careful when mucking around with it.

					The best supported ordering seems be: events → DOM append → attributes.
				*/
				jQuery(document.createElement('link'))
					.one('load abort error', ev => {
						jQuery(ev.target).off();

						if (ev.type === 'load') {
							resolve(ev.target);
						}
						else {
							reject(new Error(`importStyles failed to load the stylesheet "${url}".`));
						}
					})
					.appendTo(document.head)
					.attr({
						id   : `style-imported-${slugifyUrl(url)}`,
						rel  : 'stylesheet',
						href : url
					});
			});
		}

		// Turn a list of callbacks into a sequential chain of `Promise` objects.
		function sequence(callbacks) {
			return callbacks.reduce((seq, fn) => seq = seq.then(fn), Promise.resolve()); // eslint-disable-line no-param-reassign
		}

		/*
			Import scripts from a URL.
		*/
		function importScripts(...urls) {
			return Promise.all(urls.map(oneOrSeries => {
				// Array of URLs to be imported in sequence.
				if (Array.isArray(oneOrSeries)) {
					return sequence(oneOrSeries.map(url => () => addScript(url)));
				}

				// Single URL to be imported.
				return addScript(oneOrSeries);
			}));
		}

		/*
			Import stylesheets from a URL.
		*/
		function importStyles(...urls) {
			return Promise.all(urls.map(oneOrSeries => {
				// Array of URLs to be imported in sequence.
				if (Array.isArray(oneOrSeries)) {
					return sequence(oneOrSeries.map(url => () => addStyle(url)));
				}

				// Single URL to be imported.
				return addStyle(oneOrSeries);
			}));
		}

		// Exports.
		return {
			importScripts,
			importStyles
		};
	})();

	/*******************************************************************************************************************
		Parsing Functions.
	*******************************************************************************************************************/
	/*
		Returns the given string after converting all TwineScript syntactical sugars to
		their native JavaScript counterparts.
	*/
	const parse = (() => {
		const tokenTable = Util.toEnum({
			/* eslint-disable quote-props */
			// Story $variable sigil-prefix.
			'$'     : 'State.variables.',
			// Temporary _variable sigil-prefix.
			'_'     : 'State.temporary.',
			// Assignment operators.
			'to'    : '=',
			// Equality operators.
			'eq'    : '==',
			'neq'   : '!=',
			'is'    : '===',
			'isnot' : '!==',
			// Relational operators.
			'gt'    : '>',
			'gte'   : '>=',
			'lt'    : '<',
			'lte'   : '<=',
			// Logical operators.
			'and'   : '&&',
			'or'    : '||',
			// Unary operators.
			'not'   : '!',
			'def'   : '"undefined" !== typeof',
			'ndef'  : '"undefined" === typeof'
			/* eslint-enable quote-props */
		});
		const parseRe = new RegExp([
			'(?:""|\'\'|``)',                                     //   Empty quotes (incl. template literal)
			'(?:"(?:\\\\.|[^"\\\\])+")',                          //   Double quoted, non-empty
			"(?:'(?:\\\\.|[^'\\\\])+')",                          //   Single quoted, non-empty
			'(`(?:\\\\.|[^`\\\\])+`)',                            // 1=Template literal, non-empty
			'(?:[=+\\-*\\/%<>&\\|\\^~!?:,;\\(\\)\\[\\]{}]+)',     //   Operator delimiters
			'([^"\'=+\\-*\\/%<>&\\|\\^~!?:,;\\(\\)\\[\\]{}\\s]+)' // 2=Barewords
		].join('|'), 'g');
		const notSpaceRe      = /\S/;
		const varTest         = new RegExp(`^${Patterns.variable}`);
		const withColonTestRe = /^\s*:/;
		const withNotTestRe   = /^\s+not\b/;

		function parse(rawCodeString) {
			if (parseRe.lastIndex !== 0) {
				throw new RangeError('Scripting.parse last index is non-zero at start');
			}

			let code  = rawCodeString;
			let match;

			while ((match = parseRe.exec(code)) !== null) {
				// no-op: Empty quotes | Double quoted | Single quoted | Operator delimiters

				// Template literal, non-empty.
				if (match[1]) {
					const rawTemplate = match[1];
					const parsedTemplate = parseTemplate(rawTemplate);

					if (parsedTemplate !== rawTemplate) {
						code = code.splice(
							match.index,        // starting index
							rawTemplate.length, // replace how many
							parsedTemplate      // replacement string
						);
						parseRe.lastIndex += parsedTemplate.length - rawTemplate.length;
					}
				}

				// Barewords.
				else if (match[2]) {
					let token = match[2];

					// If the token is simply a dollar-sign or underscore, then it's either
					// just the raw character or, probably, a function alias, so skip it.
					if (token === '$' || token === '_') {
						continue;
					}

					// If the token is a story $variable or temporary _variable, reset it
					// to just its sigil—for later mapping.
					else if (varTest.test(token)) {
						token = token[0];
					}

					// If the token is `is`, check to see if it's followed by `not`, if so,
					// convert them into the `isnot` operator.
					//
					// NOTE: This is a safety feature, since `$a is not $b` probably sounds
					// reasonable to most users.
					else if (token === 'is') {
						const start = parseRe.lastIndex;
						const ahead = code.slice(start);

						if (withNotTestRe.test(ahead)) {
							code = code.splice(start, ahead.search(notSpaceRe));
							token = 'isnot';
						}
					}

					// If the token is followed by a colon, then it's likely to be an object
					// property, so skip it.
					else {
						const ahead = code.slice(parseRe.lastIndex);

						if (withColonTestRe.test(ahead)) {
							continue;
						}
					}

					// If the finalized token has a mapping, replace it within the code string
					// with its counterpart.
					if (tokenTable[token]) {
						code = code.splice(
							match.index,      // starting index
							token.length,     // replace how many
							tokenTable[token] // replacement string
						);
						parseRe.lastIndex += tokenTable[token].length - token.length;
					}
				}
			}

			return code;
		}

		const templateGroupStartRe = /\$\{/g;
		const templateGroupParseRe = new RegExp([
			'(?:""|\'\')',               //   Empty quotes
			'(?:"(?:\\\\.|[^"\\\\])+")', //   Double quoted, non-empty
			"(?:'(?:\\\\.|[^'\\\\])+')", //   Single quoted, non-empty
			'(\\{)',                     // 1=Opening curly brace
			'(\\})'                      // 2=Closing curly brace
		].join('|'), 'g');

		function parseTemplate(rawTemplateLiteral) {
			if (templateGroupStartRe.lastIndex !== 0) {
				throw new RangeError('Scripting.parse last index is non-zero at start of template literal');
			}

			let template   = rawTemplateLiteral;
			let startMatch;

			while ((startMatch = templateGroupStartRe.exec(template)) !== null) {
				const startIdx = startMatch.index + 2;
				let endIdx   = startIdx;
				let depth    = 1;
				let endMatch;

				templateGroupParseRe.lastIndex = startIdx;

				while ((endMatch = templateGroupParseRe.exec(template)) !== null) {
					// Opening curly brace.
					if (endMatch[1]) {
						++depth;
					}
					// Closing curly brace.
					else if (endMatch[2]) {
						--depth;
					}

					if (depth === 0) {
						endIdx = endMatch.index;
						break;
					}
				}

				// If the group is not empty, replace it within the template
				// with its parsed counterpart.
				if (endIdx > startIdx) {
					const parseIndex = parseRe.lastIndex;
					const rawGroup   = template.slice(startIdx, endIdx);

					parseRe.lastIndex = 0;
					const parsedGroup = parse(rawGroup);
					parseRe.lastIndex = parseIndex;

					template = template.splice(
						startIdx,        // starting index
						rawGroup.length, // replace how many
						parsedGroup      // replacement string
					);
					templateGroupStartRe.lastIndex += parsedGroup.length - rawGroup.length;
				}
			}

			return template;
		}

		return parse;
	})();

	/*******************************************************************************************************************
		Eval Functions.
	*******************************************************************************************************************/
	/* eslint-disable no-eval, no-extra-parens, no-unused-vars */
	/*
		Evaluates the given JavaScript code and returns the result, throwing if there were errors.
	*/
	function evalJavaScript(code, output, data) {
		return (function (code, output, evalJavaScript$Data$) {
			return eval(code);
		}).call(output ? { output } : null, String(code), output, data);
	}

	/*
		Evaluates the given TwineScript code and returns the result, throwing if there were errors.
	*/
	function evalTwineScript(code, output, data) {
		// NOTE: Do not move the dollar sign to the front of `evalTwineScript$Data$`,
		// as `parse()` will break references to it within the code string.
		return (function (code, output, evalTwineScript$Data$) {
			return eval(code);
		}).call(output ? { output } : null, parse(String(code)), output, data);
	}
	/* eslint-enable no-eval, no-extra-parens, no-unused-vars */

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		parse           : { value : parse },
		evalJavaScript  : { value : evalJavaScript },
		evalTwineScript : { value : evalTwineScript }
	}));
})();


code:
/***********************************************************************************************************************

	markup/template.js

	Copyright © 2019–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Patterns */

var Template = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Template definitions.
	const _templates = new Map();

	// Valid template name regular expression.
	const _validNameRe = new RegExp(`^(?:${Patterns.templateName})$`);

	// Valid template type predicate.
	const _validType = template => {
		const templateType = typeof template;
		return templateType === 'function' || templateType === 'string';
	};

	/*******************************************************************************
		Template Functions.
	*******************************************************************************/

	function templateAdd(name, template) {
		if (
			   !_validType(template)
			&& !(template instanceof Array && template.length > 0 && template.every(_validType))
		) {
			throw new TypeError(`invalid template type (${name}); templates must be: functions, strings, or an array of either`);
		}

		(name instanceof Array ? name : [name]).forEach(name => {
			if (!_validNameRe.test(name)) {
				throw new Error(`invalid template name "${name}"`);
			}
			if (_templates.has(name)) {
				throw new Error(`cannot clobber existing template ?${name}`);
			}

			_templates.set(name, template);
		});
	}

	function templateDelete(name) {
		(name instanceof Array ? name : [name]).forEach(name => _templates.delete(name));
	}

	function templateGet(name) {
		return _templates.has(name) ? _templates.get(name) : null;
	}

	function templateHas(name) {
		return _templates.has(name);
	}

	function templateSize() {
		return _templates.size;
	}

	/*******************************************************************************
		Object Exports.
	*******************************************************************************/

	return Object.freeze(Object.defineProperties({}, {
		add    : { value : templateAdd },
		delete : { value : templateDelete },
		get    : { value : templateGet },
		has    : { value : templateHas },
		size   : { get : templateSize }
	}));
})();


code:
/***********************************************************************************************************************

	markup/wikifier.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global Config, EOF, Engine, Lexer, Patterns, Scripting, State, Story, TempState, Util, convertBreaks,
	       errorPrologRegExp
*/

/*
	TODO: The Wikifier, and associated code, could stand to receive a serious refactoring.
*/
/* eslint-disable max-len */
var Wikifier = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Wikifier call depth.
	let _callDepth = 0;

	/*******************************************************************************************************************
		Wikifier Class.
	*******************************************************************************************************************/
	class Wikifier {
		constructor(destination, source, options) {
			if (Wikifier.Parser.Profile.isEmpty()) {
				Wikifier.Parser.Profile.compile();
			}

			Object.defineProperties(this, {
				// General Wikifier properties.
				source : {
					value : String(source)
				},

				options : {
					writable : true,
					value    : Object.assign({
						profile : 'all'
					}, options)
				},

				nextMatch : {
					writable : true,
					value    : 0
				},

				output : {
					writable : true,
					value    : null
				},

				// Macro parser ('macro') related properties.
				_rawArgs : {
					writable : true,
					value    : ''
				}
			});

			// No destination specified.  Create a fragment to act as the output buffer.
			if (destination == null) { // lazy equality for null
				this.output = document.createDocumentFragment();
			}

			// jQuery-wrapped destination.  Grab the first element.
			else if (destination.jquery) { // cannot use `hasOwnProperty()` here as `jquery` is from jQuery's prototype
				this.output = destination[0];
			}

			// Normal destination.
			else {
				this.output = destination;
			}

			/*
				Wikify the source into the output buffer element, possibly converting line
				breaks into paragraphs.

				NOTE: There's no catch clause here because this try/finally exists solely
				to ensure that the call depth is properly restored in the event that an
				uncaught exception is thrown during the call to `subWikify()`.
			*/
			try {
				++_callDepth;

				this.subWikify(this.output);

				// Limit line break conversion to non-recursive calls.
				if (_callDepth === 1 && Config.cleanupWikifierOutput) {
					convertBreaks(this.output);
				}
			}
			finally {
				--_callDepth;
			}
		}

		subWikify(output, terminator, options) {
			// Placed at top to prevent any execution
			if (Wikifier.stopWikify) return;

			// Cache and temporarily replace the current output buffer.
			const oldOutput = this.output;
			this.output = output;

			let newOptions;
			let oldOptions;

			// Parser option overrides.
			if (Wikifier.Option.length > 0) {
				newOptions = Object.assign(newOptions || {}, Wikifier.Option.options);
			}
			// Local parameter option overrides.
			if (options !== null && typeof options === 'object') {
				newOptions = Object.assign(newOptions || {}, options);
			}
			// If new options exist, cache and temporarily replace the current options.
			if (newOptions) {
				oldOptions = this.options;
				this.options = Object.assign({}, this.options, newOptions);
			}

			const parsersProfile   = Wikifier.Parser.Profile.get(this.options.profile);
			const terminatorRegExp = terminator
				? new RegExp(`(?:${terminator})`, this.options.ignoreTerminatorCase ? 'gim' : 'gm')
				: null;
			let terminatorMatch;
			let parserMatch;

			do {
				// Prepare the RegExp match positions.
				parsersProfile.parserRegExp.lastIndex = this.nextMatch;

				if (terminatorRegExp) {
					terminatorRegExp.lastIndex = this.nextMatch;
				}

				// Get the first matches.
				parserMatch     = parsersProfile.parserRegExp.exec(this.source);
				terminatorMatch = terminatorRegExp ? terminatorRegExp.exec(this.source) : null;

				// Try for a terminator match, unless there's a closer parser match.
				if (terminatorMatch && (!parserMatch || terminatorMatch.index <= parserMatch.index)) {
					// Output any text before the match.
					if (terminatorMatch.index > this.nextMatch) {
						this.outputText(this.output, this.nextMatch, terminatorMatch.index);
					}

					// Set the match parameters.
					this.matchStart  = terminatorMatch.index;
					this.matchLength = terminatorMatch[0].length;
					this.matchText   = terminatorMatch[0];
					this.nextMatch   = terminatorRegExp.lastIndex;

					// Restore the original output buffer and options.
					this.output = oldOutput;

					if (oldOptions) {
						this.options = oldOptions;
					}

					// Exit.
					return;
				}

				// Try for a parser match.
				else if (parserMatch) {
					// Output any text before the match.
					if (parserMatch.index > this.nextMatch) {
						this.outputText(this.output, this.nextMatch, parserMatch.index);
					}

					// Set the match parameters.
					this.matchStart  = parserMatch.index;
					this.matchLength = parserMatch[0].length;
					this.matchText   = parserMatch[0];
					this.nextMatch   = parsersProfile.parserRegExp.lastIndex;

					// Figure out which parser matched.
					let matchingParser;

					for (let i = 1, iend = parserMatch.length; i < iend; ++i) {
						if (parserMatch[i]) {
							matchingParser = i - 1;
							break; // stop once we've found the matching parser
						}
					}

					// Call the parser.
					parsersProfile.parsers[matchingParser].handler(this);

					if (Wikifier.stopWikify) {
						return;
					}

					if (TempState.break != null) { // lazy equality for null
						break;
					}
				}
			} while (terminatorMatch || parserMatch);

			// Output any text after the last match.
			if (TempState.break == null) { // lazy equality for null
				if (this.nextMatch < this.source.length) {
					this.outputText(this.output, this.nextMatch, this.source.length);
					this.nextMatch = this.source.length;
				}
			}

			// In case of <<break>>/<<continue>>, remove the last <br>.
			else if (
				   this.output.lastChild
				&& this.output.lastChild.nodeType === Node.ELEMENT_NODE
				&& this.output.lastChild.nodeName.toUpperCase() === 'BR'
			) {
				jQuery(this.output.lastChild).remove();
			}

			// Restore the original output buffer and options.
			this.output = oldOutput;

			if (oldOptions) {
				this.options = oldOptions;
			}
		}

		outputText(destination, startPos, endPos) {
			destination.appendChild(document.createTextNode(this.source.substring(startPos, endPos)));
		}

		/*
			[DEPRECATED] Meant to be called by legacy macros, this returns the raw, unprocessed
			text given to the currently executing macro.
		*/
		rawArgs() {
			return this._rawArgs;
		}

		/*
			[DEPRECATED] Meant to be called by legacy macros, this returns the text given to
			the currently executing macro after doing TwineScript-to-JavaScript transformations.
		*/
		fullArgs() {
			return Scripting.parse(this._rawArgs);
		}

		/*
			Returns the output generated by wikifying the given text, throwing if there were errors.
		*/
		static wikifyEval(text) {
			const output = document.createDocumentFragment();

			new Wikifier(output, text);

			const errors = output.querySelector('.error');

			if (errors !== null) {
				throw new Error(errors.textContent.replace(errorPrologRegExp, ''));
			}

			return output;
		}

		/*
			Create and return an internal link.
		*/
		static createInternalLink(destination, passage, text, callback) {
			const $link = jQuery(document.createElement('a'));

			if (passage != null) { // lazy equality for null
				$link.attr('data-passage', passage);

				if (Story.has(passage)) {
					$link.addClass('link-internal');

					if (Config.addVisitedLinkClass && State.hasPlayed(passage)) {
						$link.addClass('link-visited');
					}
				}
				else {
					$link.addClass('link-broken');
				}

				$link.ariaClick({ one : true }, () => {
					if (typeof callback === 'function') {
						callback();
					}

					Engine.play(passage);
				});
			}

			if (text) {
				$link.append(document.createTextNode(text));
			}

			if (destination) {
				$link.appendTo(destination);
			}

			// For legacy-compatibility we must return the DOM node.
			return $link[0];
		}

		/*
			Create and return an external link.
		*/
		static createExternalLink(destination, url, text) {
			const $link = jQuery(document.createElement('a'))
				.attr('target', '_blank')
				.addClass('link-external')
				.text(text)
				.appendTo(destination);

			if (url != null) { // lazy equality for null
				$link.attr({
					href     : url,
					tabindex : 0 // for accessiblity
				});
			}

			// For legacy-compatibility we must return the DOM node.
			return $link[0];
		}

		/*
			Returns whether the given link source is external (probably).
		*/
		static isExternalLink(link) {
			if (Story.has(link)) {
				return false;
			}

			const urlRegExp = new RegExp(`^${Patterns.url}`, 'gim');
			return urlRegExp.test(link) || /[/.?#]/.test(link);
		}
	}

	/*******************************************************************************************************************
		Option Static Object.
	*******************************************************************************************************************/
	Object.defineProperty(Wikifier, 'Option', {
		value : (() => {
			// Options array (stack).
			let _optionsStack = [];

			/*
				GlobalOption Functions.
			*/
			function optionLength() {
				return _optionsStack.length;
			}

			function optionGetter() {
				return Object.assign({}, ..._optionsStack);
			}

			function optionClear() {
				_optionsStack = [];
			}

			function optionGet(idx) {
				return _optionsStack[idx];
			}

			function optionPop() {
				return _optionsStack.pop();
			}

			function optionPush(options) {
				if (typeof options !== 'object' || options === null) {
					throw new TypeError(`Wikifier.Option.push options parameter must be an object (received: ${Util.getType(options)})`);
				}

				return _optionsStack.push(options);
			}

			/*
				Exports.
			*/
			return Object.freeze(Object.defineProperties({}, {
				length  : { get : optionLength },
				options : { get : optionGetter },
				clear   : { value : optionClear },
				get     : { value : optionGet },
				pop     : { value : optionPop },
				push    : { value : optionPush }
			}));
		})()
	});

	/*******************************************************************************************************************
		Parser Static Object.
	*******************************************************************************************************************/
	Object.defineProperty(Wikifier, 'Parser', {
		value : (() => {
			// Parser definition array.  Ordering matters, so this must be an ordered list.
			const _parsers = [];

			// Parser profiles object.
			let _profiles;

			/*
				Parser Functions.
			*/
			function parsersGetter() {
				return _parsers;
			}

			function parsersAdd(parser) {
				// Parser object sanity checks.
				if (typeof parser !== 'object') {
					throw new Error('Wikifier.Parser.add parser parameter must be an object');
				}

				if (!parser.hasOwnProperty('name')) {
					throw new Error('parser object missing required "name" property');
				}
				else if (typeof parser.name !== 'string') {
					throw new Error('parser object "name" property must be a string');
				}

				if (!parser.hasOwnProperty('match')) {
					throw new Error('parser object missing required "match" property');
				}
				else if (typeof parser.match !== 'string') {
					throw new Error('parser object "match" property must be a string');
				}

				if (!parser.hasOwnProperty('handler')) {
					throw new Error('parser object missing required "handler" property');
				}
				else if (typeof parser.handler !== 'function') {
					throw new Error('parser object "handler" property must be a function');
				}

				if (parser.hasOwnProperty('profiles') && !Array.isArray(parser.profiles)) {
					throw new Error('parser object "profiles" property must be an array');
				}

				// Check for an existing parser with the same name.
				if (parsersHas(parser.name)) {
					throw new Error(`cannot clobber existing parser "${parser.name}"`);
				}

				// Add the parser to the end of the array.
				_parsers.push(parser);
			}

			function parsersDelete(name) {
				const parser = _parsers.find(parser => parser.name === name);

				if (parser) {
					_parsers.delete(parser);
				}
			}

			function parsersIsEmpty() {
				return _parsers.length === 0;
			}

			function parsersHas(name) {
				return !!_parsers.find(parser => parser.name === name);
			}

			function parsersGet(name) {
				return _parsers.find(parser => parser.name === name) || null;
			}

			/*
				Parser Profile Functions.
			*/
			function profilesGetter() {
				return _profiles;
			}

			function profilesCompile() {
				if (DEBUG) { console.log('[Wikifier.Parser/profilesCompile()]'); }

				const all  = _parsers;
				const core = all.filter(parser => !Array.isArray(parser.profiles) || parser.profiles.includes('core'));

				_profiles = Object.freeze({
					all : {
						parsers      : all,
						parserRegExp : new RegExp(all.map(parser => `(${parser.match})`).join('|'), 'gm')
					},
					core : {
						parsers      : core,
						parserRegExp : new RegExp(core.map(parser => `(${parser.match})`).join('|'), 'gm')
					}
				});

				return _profiles;
			}

			function profilesIsEmpty() {
				return typeof _profiles !== 'object' || Object.keys(_profiles).length === 0;
			}

			function profilesGet(profile) {
				if (typeof _profiles !== 'object' || !_profiles.hasOwnProperty(profile)) {
					throw new Error(`nonexistent parser profile "${profile}"`);
				}

				return _profiles[profile];
			}

			function profilesHas(profile) {
				return typeof _profiles === 'object' && _profiles.hasOwnProperty(profile);
			}

			/*
				Exports.
			*/
			return Object.freeze(Object.defineProperties({}, {
				/*
					Parser Containers.
				*/
				parsers : { get : parsersGetter },

				/*
					Parser Functions.
				*/
				add     : { value : parsersAdd },
				delete  : { value : parsersDelete },
				isEmpty : { value : parsersIsEmpty },
				has     : { value : parsersHas },
				get     : { value : parsersGet },

				/*
					Parser Profile.
				*/
				Profile : {
					value : Object.freeze(Object.defineProperties({}, {
						/*
							Profiles Containers.
						*/
						profiles : { get : profilesGetter },

						/*
							Profiles Functions.
						*/
						compile : { value : profilesCompile },
						isEmpty : { value : profilesIsEmpty },
						has     : { value : profilesHas },
						get     : { value : profilesGet }
					}))
				}
			}));
		})()
	});

	/*******************************************************************************************************************
		Additional Static Properties.
	*******************************************************************************************************************/
	Object.defineProperties(Wikifier, {
		helpers        : { value : {} },
		/* Global exit flag */
		stopWikify     : { value : false, writable : true },
		/*
			Legacy Aliases.
		*/
		getValue       : { value : State.getVar },              // SEE: `state.js`.
		setValue       : { value : State.setVar },              // SEE: `state.js`.
		parse          : { value : Scripting.parse },           // SEE: `markup/scripting.js`.
		evalExpression : { value : Scripting.evalTwineScript }, // SEE: `markup/scripting.js`.
		evalStatements : { value : Scripting.evalTwineScript }, // SEE: `markup/scripting.js`.
		textPrimitives : { value : Patterns }                   // SEE: `lib/patterns.js`.
	});

	/*******************************************************************************************************************
		Helper Static Methods.
	*******************************************************************************************************************/
	Object.defineProperties(Wikifier.helpers, {
		inlineCss : {
			value : (() => {
				const lookaheadRe = new RegExp(Patterns.inlineCss, 'gm');
				const idOrClassRe = new RegExp(`(${Patterns.cssIdOrClassSigil})(${Patterns.anyLetter}+)`, 'g');

				function helperInlineCss(w) {
					const css = { classes : [], id : '', styles : {} };
					let matched;

					do {
						lookaheadRe.lastIndex = w.nextMatch;

						const match = lookaheadRe.exec(w.source);

						matched = match && match.index === w.nextMatch;

						if (matched) {
							if (match[1]) {
								css.styles[Util.fromCssProperty(match[1])] = match[2].trim();
							}
							else if (match[3]) {
								css.styles[Util.fromCssProperty(match[3])] = match[4].trim();
							}
							else if (match[5]) {
								let subMatch;

								idOrClassRe.lastIndex = 0; // NOTE: Guard against buggy implementations.

								while ((subMatch = idOrClassRe.exec(match[5])) !== null) {
									if (subMatch[1] === '.') {
										css.classes.push(subMatch[2]);
									}
									else {
										css.id = subMatch[2];
									}
								}
							}

							w.nextMatch = lookaheadRe.lastIndex; // eslint-disable-line no-param-reassign
						}
					} while (matched);

					return css;
				}

				return helperInlineCss;
			})()
		},

		evalText : {
			value(text) {
				let result;

				try {
					result = Scripting.evalTwineScript(text);

					/*
						Attempt to prevent the leakage of auto-globals by enforcing that
						the resultant value be either a string or a number.

						NOTE: This is not a foolproof solution to the problem of auto-global
						leakage.  Various auto-globals, which return strings or numbers, can
						still leak through—e.g. `window.status` → string.
					*/
					switch (typeof result) {
					case 'string':
						if (result.trim() === '') {
							result = text;
						}
						break;
					case 'number':
						result = String(result);
						break;
					default:
						result = text;
						break;
					}
				}
				catch (ex) {
					result = text;
				}

				return result;
			}
		},

		evalPassageId : {
			value(passage) {
				if (passage == null || Story.has(passage)) { // lazy equality for null; `0` is a valid name, so we cannot simply evaluate `passage`
					return passage;
				}

				return Wikifier.helpers.evalText(passage);
			}
		},

		hasBlockContext : {
			value(nodes) {
				const hasGCS = typeof window.getComputedStyle === 'function';

				for (let i = nodes.length - 1; i >= 0; --i) {
					const node = nodes[i];

					switch (node.nodeType) {
					case Node.ELEMENT_NODE:
						{
							const tagName = node.nodeName.toUpperCase();

							if (tagName === 'BR') {
								return true;
							}

							const styles = hasGCS ? window.getComputedStyle(node, null) : node.currentStyle;

							if (styles && styles.display) {
								if (styles.display === 'none') {
									continue;
								}

								return styles.display === 'block';
							}

							/*
								WebKit/Blink-based browsers do not attach any computed style
								information to elements until they're inserted into the DOM
								(and probably visible), not even the default browser styles
								and any user styles.  So, we make an assumption based on the
								element.
							*/
							switch (tagName) {
							case 'ADDRESS':
							case 'ARTICLE':
							case 'ASIDE':
							case 'BLOCKQUOTE':
							case 'CENTER':
							case 'DIV':
							case 'DL':
							case 'FIGURE':
							case 'FOOTER':
							case 'FORM':
							case 'H1':
							case 'H2':
							case 'H3':
							case 'H4':
							case 'H5':
							case 'H6':
							case 'HEADER':
							case 'HR':
							case 'MAIN':
							case 'NAV':
							case 'OL':
							case 'P':
							case 'PRE':
							case 'SECTION':
							case 'TABLE':
							case 'UL':
								return true;
							}
						}

						return false;

					case Node.COMMENT_NODE:
						continue;

					default:
						return false;
					}
				}

				return true;
			}
		},

		createShadowSetterCallback : {
			value : (() => {
				let macroParser = null;

				function cacheMacroParser() {
					if (!macroParser) {
						macroParser = Wikifier.Parser.get('macro');

						if (!macroParser) {
							throw new Error('cannot find "macro" parser');
						}
					}

					return macroParser;
				}

				function getMacroContextShadowView() {
					const macro = macroParser || cacheMacroParser();
					const view  = new Set();

					for (let context = macro.context; context !== null; context = context.parent) {
						if (context._shadows) {
							context._shadows.forEach(name => view.add(name));
						}
					}

					return [...view];
				}

				function helperCreateShadowSetterCallback(code) {
					const shadowStore = {};

					getMacroContextShadowView().forEach(varName => {
						const varKey = varName.slice(1);
						const store  = varName[0] === '$' ? State.variables : State.temporary;
						shadowStore[varName] = store[varKey];
					});

					return function () {
						const shadowNames = Object.keys(shadowStore);
						const valueCache  = shadowNames.length > 0 ? {} : null;

						/*
							There's no catch clause because this try/finally is here simply to ensure that
							proper cleanup is done in the event that an exception is thrown during the
							evaluation.
						*/
						try {
							/*
								Cache the existing values of the variables to be shadowed and assign the
								shadow values.
							*/
							shadowNames.forEach(varName => {
								const varKey = varName.slice(1);
								const store  = varName[0] === '$' ? State.variables : State.temporary;

								if (store.hasOwnProperty(varKey)) {
									valueCache[varKey] = store[varKey];
								}

								store[varKey] = shadowStore[varName];
							});

							// Evaluate the JavaScript.
							return Scripting.evalJavaScript(code);
						}
						finally {
							// Revert the variable shadowing.
							shadowNames.forEach(varName => {
								const varKey = varName.slice(1);
								const store  = varName[0] === '$' ? State.variables : State.temporary;

								/*
									Update the shadow store with the variable's current value, in case it
									was modified during the callback.
								*/
								shadowStore[varName] = store[varKey];

								if (valueCache.hasOwnProperty(varKey)) {
									store[varKey] = valueCache[varKey];
								}
								else {
									delete store[varKey];
								}
							});
						}
					};
				}

				return helperCreateShadowSetterCallback;
			})()
		},

		parseSquareBracketedMarkup : {
			value : (() => {
				/* eslint-disable no-param-reassign */
				const Item = Lexer.enumFromNames([ // lex item types object (pseudo-enumeration)
					'Error',     // error
					'DelimLTR',  // '|' or '->'
					'DelimRTL',  // '<-'
					'InnerMeta', // ']['
					'ImageMeta', // '[img[', '[<img[', or '[>img['
					'LinkMeta',  // '[['
					'Link',      // link destination
					'RightMeta', // ']]'
					'Setter',    // setter expression
					'Source',    // image source
					'Text'       // link text or image alt text
				]);
				const Delim = Lexer.enumFromNames([ // delimiter state object (pseudo-enumeration)
					'None', // no delimiter encountered
					'LTR',  // '|' or '->'
					'RTL'   // '<-'
				]);

				// Lexing functions.
				function slurpQuote(lexer, endQuote) {
					loop: for (;;) {
						/* eslint-disable indent */
						switch (lexer.next()) {
						case '\\':
							{
								const ch = lexer.next();

								if (ch !== EOF && ch !== '\n') {
									break;
								}
							}
							/* falls through */
						case EOF:
						case '\n':
							return EOF;

						case endQuote:
							break loop;
						}
						/* eslint-enable indent */
					}

					return lexer.pos;
				}

				function lexLeftMeta(lexer) {
					if (!lexer.accept('[')) {
						return lexer.error(Item.Error, 'malformed square-bracketed markup');
					}

					// Is link markup.
					if (lexer.accept('[')) {
						lexer.data.isLink = true;
						lexer.emit(Item.LinkMeta);
					}

					// May be image markup.
					else {
						lexer.accept('<>'); // aligner syntax

						if (!lexer.accept('Ii') || !lexer.accept('Mm') || !lexer.accept('Gg') || !lexer.accept('[')) {
							return lexer.error(Item.Error, 'malformed square-bracketed markup');
						}

						lexer.data.isLink = false;
						lexer.emit(Item.ImageMeta);
					}

					lexer.depth = 2; // account for both initial left square brackets
					return lexCoreComponents;
				}

				function lexCoreComponents(lexer) {
					const what = lexer.data.isLink ? 'link' : 'image';
					let delim = Delim.None;

					for (;;) {
						switch (lexer.next()) {
						case EOF:
						case '\n':
							return lexer.error(Item.Error, `unterminated ${what} markup`);

						case '"':
							/*
								This is not entirely reliable within sections that allow raw strings, since
								it's possible, however unlikely, for a raw string to contain unpaired double
								quotes.  The likelihood is low enough, however, that I'm deeming the risk as
								acceptable—for now, at least.
							*/
							if (slurpQuote(lexer, '"') === EOF) {
								return lexer.error(Item.Error, `unterminated double quoted string in ${what} markup`);
							}
							break;

						case '|': // possible pipe ('|') delimiter
							if (delim === Delim.None) {
								delim = Delim.LTR;
								lexer.backup();
								lexer.emit(Item.Text);
								lexer.forward();
								lexer.emit(Item.DelimLTR);
								// lexer.ignore();
							}
							break;

						case '-': // possible right arrow ('->') delimiter
							if (delim === Delim.None && lexer.peek() === '>') {
								delim = Delim.LTR;
								lexer.backup();
								lexer.emit(Item.Text);
								lexer.forward(2);
								lexer.emit(Item.DelimLTR);
								// lexer.ignore();
							}
							break;

						case '<': // possible left arrow ('<-') delimiter
							if (delim === Delim.None && lexer.peek() === '-') {
								delim = Delim.RTL;
								lexer.backup();
								lexer.emit(lexer.data.isLink ? Item.Link : Item.Source);
								lexer.forward(2);
								lexer.emit(Item.DelimRTL);
								// lexer.ignore();
							}
							break;

						case '[':
							++lexer.depth;
							break;

						case ']':
							--lexer.depth;

							if (lexer.depth === 1) {
								switch (lexer.peek()) {
								case '[':
									++lexer.depth;
									lexer.backup();

									if (delim === Delim.RTL) {
										lexer.emit(Item.Text);
									}
									else {
										lexer.emit(lexer.data.isLink ? Item.Link : Item.Source);
									}

									lexer.forward(2);
									lexer.emit(Item.InnerMeta);
									// lexer.ignore();
									return lexer.data.isLink ? lexSetter : lexImageLink;

								case ']':
									--lexer.depth;
									lexer.backup();

									if (delim === Delim.RTL) {
										lexer.emit(Item.Text);
									}
									else {
										lexer.emit(lexer.data.isLink ? Item.Link : Item.Source);
									}

									lexer.forward(2);
									lexer.emit(Item.RightMeta);
									// lexer.ignore();
									return null;

								default:
									return lexer.error(Item.Error, `malformed ${what} markup`);
								}
							}
							break;
						}
					}
				}

				function lexImageLink(lexer) {
					const what = lexer.data.isLink ? 'link' : 'image';

					for (;;) {
						switch (lexer.next()) {
						case EOF:
						case '\n':
							return lexer.error(Item.Error, `unterminated ${what} markup`);

						case '"':
							/*
								This is not entirely reliable within sections that allow raw strings, since
								it's possible, however unlikely, for a raw string to contain unpaired double
								quotes.  The likelihood is low enough, however, that I'm deeming the risk as
								acceptable—for now, at least.
							*/
							if (slurpQuote(lexer, '"') === EOF) {
								return lexer.error(Item.Error, `unterminated double quoted string in ${what} markup link component`);
							}
							break;

						case '[':
							++lexer.depth;
							break;

						case ']':
							--lexer.depth;

							if (lexer.depth === 1) {
								switch (lexer.peek()) {
								case '[':
									++lexer.depth;
									lexer.backup();
									lexer.emit(Item.Link);
									lexer.forward(2);
									lexer.emit(Item.InnerMeta);
									// lexer.ignore();
									return lexSetter;

								case ']':
									--lexer.depth;
									lexer.backup();
									lexer.emit(Item.Link);
									lexer.forward(2);
									lexer.emit(Item.RightMeta);
									// lexer.ignore();
									return null;

								default:
									return lexer.error(Item.Error, `malformed ${what} markup`);
								}
							}
							break;
						}
					}
				}

				function lexSetter(lexer) {
					const what = lexer.data.isLink ? 'link' : 'image';

					for (;;) {
						switch (lexer.next()) {
						case EOF:
						case '\n':
							return lexer.error(Item.Error, `unterminated ${what} markup`);

						case '"':
							if (slurpQuote(lexer, '"') === EOF) {
								return lexer.error(Item.Error, `unterminated double quoted string in ${what} markup setter component`);
							}
							break;

						case "'":
							if (slurpQuote(lexer, "'") === EOF) {
								return lexer.error(Item.Error, `unterminated single quoted string in ${what} markup setter component`);
							}
							break;

						case '[':
							++lexer.depth;
							break;

						case ']':
							--lexer.depth;

							if (lexer.depth === 1) {
								if (lexer.peek() !== ']') {
									return lexer.error(Item.Error, `malformed ${what} markup`);
								}

								--lexer.depth;
								lexer.backup();
								lexer.emit(Item.Setter);
								lexer.forward(2);
								lexer.emit(Item.RightMeta);
								// lexer.ignore();
								return null;
							}
							break;
						}
					}
				}

				// Parse function.
				function parseSquareBracketedMarkup(w) {
					// Initialize the lexer.
					const lexer  = new Lexer(w.source, lexLeftMeta);

					// Set the initial positions within the source string.
					lexer.start = lexer.pos = w.matchStart;

					// Lex the raw argument string.
					const markup = {};
					const items  = lexer.run();
					const last   = items.last();

					if (last && last.type === Item.Error) {
						markup.error = last.message;
					}
					else {
						items.forEach(item => {
							const text = item.text.trim();

							switch (item.type) {
							case Item.ImageMeta:
								markup.isImage = true;

								if (text[1] === '<') {
									markup.align = 'left';
								}
								else if (text[1] === '>') {
									markup.align = 'right';
								}
								break;

							case Item.LinkMeta:
								markup.isLink = true;
								break;

							case Item.Link:
								if (text[0] === '~') {
									markup.forceInternal = true;
									markup.link = text.slice(1);
								}
								else {
									markup.link = text;
								}
								break;

							case Item.Setter:
								markup.setter = text;
								break;

							case Item.Source:
								markup.source = text;
								break;

							case Item.Text:
								markup.text = text;
								break;
							}
						});
					}

					markup.pos = lexer.pos;
					return markup;
				}

				return parseSquareBracketedMarkup;
				/* eslint-enable no-param-reassign */
			})()
		}
	});

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Wikifier;
})();
/* eslint-enable max-len */


code:
/***********************************************************************************************************************

	passage.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Config, L10n, Util, Wikifier */

var Passage = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	let _tagsToSkip;
	let _twine1Unescape;

	/*
		Tags which should not be transformed into classes:
			debug      → special tag
			nobr       → special tag
			passage    → the default class
			script     → special tag (only in Twine 1)
			stylesheet → special tag (only in Twine 1)
			twine.*    → special tag
			widget     → special tag
	*/
	// For Twine 1
	if (TWINE1) {
		_tagsToSkip = /^(?:debug|nobr|passage|script|stylesheet|widget|twine\..*)$/i;
	}
	// For Twine 2
	else {
		_tagsToSkip = /^(?:debug|nobr|passage|widget|twine\..*)$/i;
	}

	// For Twine 1
	if (TWINE1) {
		/*
			Returns a decoded version of the passed Twine 1 passage store encoded string.
		*/
		const _twine1EscapesRe    = /(?:\\n|\\t|\\s|\\|\r)/g;
		const _hasTwine1EscapesRe = new RegExp(_twine1EscapesRe.source); // to drop the global flag
		const _twine1EscapesMap   = Object.freeze({
			'\\n' : '\n',
			'\\t' : '\t',
			'\\s' : '\\',
			'\\'  : '\\',
			'\r'  : ''
		});

		_twine1Unescape = function (str) {
			if (str == null) { // lazy equality for null
				return '';
			}

			const val = String(str);
			return val && _hasTwine1EscapesRe.test(val)
				? val.replace(_twine1EscapesRe, esc => _twine1EscapesMap[esc])
				: val;
		};
	}

	/*******************************************************************************************************************
		Passage Class.
	*******************************************************************************************************************/
	class Passage {
		constructor(title, el) {
			Object.defineProperties(this, {
				// Passage title/ID.
				title : {
					value : Util.unescape(title)
				},

				// Passage data element (within the story data element; i.e. T1: '[tiddler]', T2: 'tw-passagedata').
				element : {
					value : el || null
				},

				// Passage tags array (unique).
				tags : {
					value : Object.freeze(
						el && el.hasAttribute('tags')
							? Array.from(new Set(el.getAttribute('tags').trim().splitOrEmpty(/\s+/)))
							: []
					)
				},

				// Passage excerpt.  Used by the `description()` method.
				_excerpt : {
					writable : true,
					value    : null
				}
			});

			// Properties dependant upon the above set.
			Object.defineProperties(this, {
				// Passage DOM-compatible ID.
				domId : {
					value : `passage-${Util.slugify(this.title)}`
				},

				// Passage classes array (sorted and unique).
				classes : {
					value : Object.freeze(this.tags.length === 0 ? [] : (() =>
						/*
							Return the sorted list of unique classes.

							NOTE: The `this.tags` array is already sorted and unique,
							so we only need to filter and map here.
						*/
						this.tags
							.filter(tag => !_tagsToSkip.test(tag))
							.map(tag => Util.slugify(tag))
					)())
				}
			});
		}

		// Getters.
		get className() {
			return this.classes.join(' ');
		}

		// TODO: (v3) This should be → `get source`.
		get text() {
			if (this.element == null) { // lazy equality for null
				const passage = Util.escapeMarkup(this.title);
				const mesg    = `${L10n.get('errorTitle')}: ${L10n.get('errorNonexistentPassage', { passage })}`;
				return `<div class="error-view"><span class="error">${mesg}</span></div>`;
			}

			// For Twine 1
			if (TWINE1) {
				return _twine1Unescape(this.element.textContent);
			}
			// For Twine 2
			else { // eslint-disable-line no-else-return
				return this.element.textContent.replace(/\r/g, '');
			}
		}

		description() {
			const descriptions = Config.passages.descriptions;

			switch (typeof descriptions) {
			case 'boolean':
				if (descriptions) {
					return this.title;
				}
				break;

			case 'object':
				if (descriptions.hasOwnProperty(this.title)) {
					return descriptions[this.title];
				}
				break;

			case 'function':
				{
					const result = descriptions.call(this);

					if (result) {
						return result;
					}
				}
				break;
			}

			// Initialize the excerpt cache from the raw passage text, if necessary.
			if (this._excerpt === null) {
				this._excerpt = Passage.getExcerptFromText(this.text);
			}

			return this._excerpt;
		}

		// TODO: (v3) This should be → `get text`.
		processText() {
			if (this.element == null) { // lazy equality for null
				return this.text;
			}

			// Handle image passage transclusion.
			if (this.tags.includes('Twine.image')) {
				return `[img[${this.text}]]`;
			}

			let processed = this.text;

			// Handle `Config.passages.onProcess`.
			if (Config.passages.onProcess) {
				processed = Config.passages.onProcess.call(null, {
					title : this.title,
					tags  : this.tags,
					text  : processed
				});
			}

			// Handle `Config.passages.nobr` and the `nobr` tag.
			if (Config.passages.nobr || this.tags.includes('nobr')) {
				// Remove all leading & trailing newlines and compact all internal sequences
				// of newlines into single spaces.
				processed = processed.replace(/^\n+|\n+$/g, '').replace(/\n+/g, ' ');
			}

			// Reset stopWikify flag at the end of a passage
			Wikifier.stopWikify = 0;

			return processed;
		}

		render(options) {
			// Wikify the passage into a document fragment.
			const frag = document.createDocumentFragment();
			new Wikifier(frag, this.processText(), options);

			// Update the excerpt cache to reflect the rendered text, if we need it for the passage description
			if (Config.passages.descriptions == null) {
				this._excerpt = Passage.getExcerptFromNode(frag);
			}

			return frag;
		}

		static getExcerptFromNode(node, count) {
			if (DEBUG) { console.log(`[Passage.getExcerptFromNode(node=…, count=${count})]`, node); }

			if (!node.hasChildNodes()) {
				return '';
			}

			// WARNING: es5-shim's `<String>.trim()` can cause "too much recursion" errors
			// here on very large strings (e.g., ≥40 KiB), at least in Firefox, for unknown
			// reasons.
			//
			// To fix the issue, we're removed `\u180E` from es5-shim's whitespace pattern
			// to prevent it from erroneously shimming `<String>.trim()` in the first place.
			let excerpt = node.textContent.trim();

			if (excerpt !== '') {
				const excerptRe = new RegExp(`(\\S+(?:\\s+\\S+){0,${count > 0 ? count - 1 : 7}})`);
				excerpt = excerpt
					// Compact whitespace.
					.replace(/\s+/g, ' ')
					// Attempt to match the excerpt regexp.
					.match(excerptRe);
			}

			return excerpt ? `${excerpt[1]}\u2026` : '\u2026'; // horizontal ellipsis
		}

		static getExcerptFromText(text, count) {
			if (DEBUG) { console.log(`[Passage.getExcerptFromText(text=…, count=${count})]`, text); }

			if (text === '') {
				return '';
			}

			const excerptRe = new RegExp(`(\\S+(?:\\s+\\S+){0,${count > 0 ? count - 1 : 7}})`);
			const excerpt   = text
				// Strip macro tags (replace with a space).
				.replace(/<<.*?>>/g, ' ')
				// Strip html tags (replace with a space).
				.replace(/<.*?>/g, ' ')
				// The above might have left problematic whitespace, so trim.
				.trim()
				// Strip table markup.
				.replace(/^\s*\|.*\|.*?$/gm, '')
				// Strip image markup.
				.replace(/\[[<>]?img\[[^\]]*\]\]/g, '')
				// Clean link markup (remove all but the link text).
				.replace(/\[\[([^|\]]*?)(?:(?:\||->|<-)[^\]]*)?\]\]/g, '$1')
				// Clean heading markup.
				.replace(/^\s*!+(.*?)$/gm, '$1')
				// Clean bold/italic/underline/highlight styles.
				.replace(/'{2}|\/{2}|_{2}|@{2}/g, '')
				// A final trim.
				.trim()
				// Compact whitespace.
				.replace(/\s+/g, ' ')
				// Attempt to match the excerpt regexp.
				.match(excerptRe);
			return excerpt ? `${excerpt[1]}\u2026` : '\u2026'; // horizontal ellipsis
		}
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Passage;
})();


code:
/***********************************************************************************************************************

	save.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Config, Dialog, Engine, L10n, State, Story, UI, Util, storage, idb */

var Save = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Save operation type pseudo-enumeration.
	const Type = Util.toEnum({
		Autosave  : 'autosave',
		Disk      : 'disk',
		Serialize : 'serialize',
		Slot      : 'slot'
	});

	// The upper bound of the saves slots.
	let _slotsUBound = -1;

	// extra save metadata
	let _meta = {};

	// Set of onLoad handlers.
	const _onLoadHandlers = new Set();

	// Set of onSave handlers.
	const _onSaveHandlers = new Set();

	/********************************
		split save stuff
	********************************/
	let useSplit = () => Story.domId === 'free-cities'; // todo: make it a proper toggle
	function indexGet() {
		return storage.get('index') ?? savesObjCreate();
	}

	function splitSave(slot, data) {
		storage.set(slot === 'autosave' ? slot : `slot${slot}`, data);
		const index = indexGet();
		delete data.state;
		slot === 'autosave' ? index.autosave = data : index.slots[slot] = data;
		try {
			storage.set('index', index);
		}
		catch (ex) {
			storage.delete(slot === 'autosave' ? 'autosave' : `slot${slot}`);
			// eslint-disable-next-line no-alert
			alert('Storage quota exceeded, try removing other saves first');
		}

		return true;
	}

	function splitDelete(slot) {
		storage.delete(slot === 'autosave' ? slot : `slot${slot}`);
		const index = indexGet();
		slot === 'autosave' ? index.autosave = null : index.slots[slot] = null;
		storage.set('index', index);
		return true;
	}

	/*******************************************************************************************************************
		Saves Functions.
	*******************************************************************************************************************/
	function savesInit() {
		if (DEBUG) { console.log('[Save/savesInit()]'); }

		// Disable save slots and the autosave when Web Storage is unavailable.
		if (storage.name === 'cookie') {
			savesObjClear();
			Config.saves.autoload = undefined;
			Config.saves.autosave = undefined;
			Config.saves.slots = 0;
			return false;
		}

		let saves   = savesObjGet();
		let updated = false;

		// Handle the author changing the number of save slots.
		if (Config.saves.slots !== saves.slots.length) {
			if (Config.saves.slots < saves.slots.length) {
				// Attempt to decrease the number of slots; this will only compact
				// the slots array, by removing empty slots, no saves will be deleted.
				saves.slots.reverse();

				saves.slots = saves.slots.filter(function (val) {
					if (val === null && this.count > 0) {
						--this.count;
						return false;
					}

					return true;
				}, { count : saves.slots.length - Config.saves.slots });

				saves.slots.reverse();
			}
			else if (Config.saves.slots > saves.slots.length) {
				// Attempt to increase the number of slots.
				_appendSlots(saves.slots, Config.saves.slots - saves.slots.length);
			}

			updated = true;
		}

		// If the saves object was updated, then update the store.
		if (updated) {
			_savesObjSave(saves);
		}

		_slotsUBound = saves.slots.length - 1;

		return true;
	}

	function savesObjCreate() {
		return {
			autosave : null,
			slots    : _appendSlots([], Config.saves.slots)
		};
	}

	function savesObjGet() {
		const saves = storage.get(useSplit() ? 'index' : 'saves');
		return saves === null ? savesObjCreate() : saves;
	}

	function savesObjClear() {
		storage.delete('saves');
		if (useSplit()) {
			storage.delete('index');
			storage.delete('autosave');
			for (let i = 0; i < Config.save.slots - 1; i++) storage.delete(`slot${i}`);
		}
		return true;
	}

	function savesOk() {
		return autosaveOk() || slotsOk();
	}

	/*******************************************************************************************************************
		Autosave Functions.
	*******************************************************************************************************************/
	function autosaveOk() {
		return storage.name !== 'cookie' && typeof Config.saves.autosave !== 'undefined';
	}

	function autosaveHas() {
		const saves = useSplit() ? { autosave : storage.get('autosave') } : savesObjGet();

		if (saves.autosave === null) {
			return false;
		}

		return true;
	}

	function autosaveGet() {
		const saves = useSplit() ? { autosave : storage.get('autosave') } : savesObjGet();
		return saves.autosave;
	}

	function autosaveLoad() {
		// idb intercept
		if (idb.active) {
			idb.loadState(0);
			return true;
		}

		const saves = useSplit() ? { autosave : storage.get('autosave') } : savesObjGet();

		if (saves.autosave === null) {
			return false;
		}

		return _unmarshal(saves.autosave);
	}

	function autosaveSave(title, metadata) {
		if (typeof Config.saves.isAllowed === 'function' && !Config.saves.isAllowed()) {
			return false;
		}

		// idb intercept
		if (idb.active) {
			idb.saveState(0, title, metadata);
			return true;
		}

		const saves        = savesObjGet();
		const supplemental = {
			title : title || Story.get(State.passage).description(),
			date  : Date.now()
		};

		if (metadata != null) { // lazy equality for null
			supplemental.metadata = metadata;
		}

		const saveData = _marshal(supplemental, { type : Type.Autosave });
		if (useSplit()) return splitSave('autosave', saveData);
		saves.autosave = saveData;

		return _savesObjSave(saves);
	}

	function autosaveDelete() {
		if (useSplit()) return splitDelete('autosave');

		const saves = savesObjGet();
		saves.autosave = null;
		return _savesObjSave(saves);
	}

	/*******************************************************************************************************************
		Slots Functions.
	*******************************************************************************************************************/
	function slotsOk() {
		return storage.name !== 'cookie' && _slotsUBound !== -1;
	}

	function slotsLength() {
		return _slotsUBound + 1;
	}

	function slotsCount() {
		if (!slotsOk()) {
			return 0;
		}

		const saves = savesObjGet();
		let count = 0;

		for (let i = 0, iend = saves.slots.length; i < iend; ++i) {
			if (saves.slots[i] !== null) {
				++count;
			}
		}
		return count;
	}

	function slotsIsEmpty() {
		return slotsCount() === 0;
	}

	function slotsHas(slot) {
		if (slot < 0 || slot > _slotsUBound) {
			return false;
		}

		const saves = savesObjGet();

		if (slot >= saves.slots.length || saves.slots[slot] === null) {
			return false;
		}

		return true;
	}

	function slotsGet(slot) {
		if (slot < 0 || slot > _slotsUBound) {
			return null;
		}

		const saves = savesObjGet();

		if (slot >= saves.slots.length) {
			return null;
		}

		return useSplit() ? storage.get(`slot${slot}`) : saves.slots[slot];
	}

	function slotsLoad(slot) {
		if (slot < 0 || slot > _slotsUBound) {
			return false;
		}

		const saves = savesObjGet();

		if (slot >= saves.slots.length || saves.slots[slot] === null) {
			return false;
		}

		return _unmarshal(useSplit() ? storage.get(`slot${slot}`) : saves.slots[slot]);
	}

	function slotsSave(slot, title, metadata) {
		if (typeof Config.saves.isAllowed === 'function' && !Config.saves.isAllowed()) {
			if (Dialog.isOpen()) {
				$(document).one(':dialogclosed', () => UI.alert(L10n.get('savesDisallowed')));
			}
			else {
				UI.alert(L10n.get('savesDisallowed'));
			}

			return false;
		}

		if (slot < 0 || slot > _slotsUBound) {
			return false;
		}

		const saves = savesObjGet();

		if (slot >= saves.slots.length) {
			return false;
		}

		const supplemental = {
			title : title || Story.get(State.passage).description(),
			date  : Date.now()
		};

		if (metadata != null) { // lazy equality for null
			supplemental.metadata = metadata;
		}

		const saveData = _marshal(supplemental, { type : Type.Slot });
		if (useSplit()) return splitSave(slot, saveData);

		saves.slots[slot] = saveData;

		return _savesObjSave(saves);
	}

	function slotsDelete(slot) {
		if (slot < 0 || slot > _slotsUBound) {
			return false;
		}

		const saves = savesObjGet();

		if (slot >= saves.slots.length) {
			return false;
		}

		if (useSplit()) return splitDelete(slot);

		saves.slots[slot] = null;
		return _savesObjSave(saves);
	}

	/*******************************************************************************************************************
		Disk Import/Export Functions.
	*******************************************************************************************************************/
	function exportToDisk(filename, metadata) {
		if (typeof Config.saves.isAllowed === 'function' && !Config.saves.isAllowed()) {
			if (Dialog.isOpen()) {
				$(document).one(':dialogclosed', () => UI.alert(L10n.get('savesDisallowed')));
			}
			else {
				UI.alert(L10n.get('savesDisallowed'));
			}

			return;
		}

		function getDatestamp() {
			const now = new Date();
			let MM = now.getMonth() + 1;
			let DD = now.getDate();
			let hh = now.getHours();
			let mm = now.getMinutes();
			let ss = now.getSeconds();

			if (MM < 10) { MM = `0${MM}`; }
			if (DD < 10) { DD = `0${DD}`; }
			if (hh < 10) { hh = `0${hh}`; }
			if (mm < 10) { mm = `0${mm}`; }
			if (ss < 10) { ss = `0${ss}`; }

			return `${now.getFullYear()}${MM}${DD}-${hh}${mm}${ss}`;
		}

		function getFilename(str) {
			return Util.sanitizeFilename(str)
				.replace(/[_\s\u2013\u2014-]+/g, '-'); // legacy
		}

		const baseName     = filename == null ? Story.domId : getFilename(filename); // lazy equality for null
		const saveName     = `${baseName}-${getDatestamp()}.save`;
		const supplemental = metadata == null ? {} : { metadata }; // lazy equality for null
		const data         = LZString.compressToBase64(JSON.stringify(_marshal(supplemental, { type : Type.Disk })));
		const saveObj      = data + LZString.compressToBase64(JSON.stringify({ [Story.domId] : data.length }));
		saveAs(new Blob([saveObj], { type : 'text/plain;charset=UTF-8' }), saveName);
	}

	function importFromDisk(event) {
		const file   = event.target.files[0];
		const reader = new FileReader();

		// Add the handler that will capture the file information once the load is finished.
		jQuery(reader).one('loadend', () => {
			if (reader.error) {
				const ex = reader.error;
				UI.alert(`${L10n.get('errorSaveDiskLoadFailed').toUpperFirst()} (${ex.name}: ${ex.message}).</p><p>${L10n.get('aborting')}.`);
				return;
			}

			deserialize(reader.result);
		});

		// Initiate the file load.
		reader.readAsText(file);
	}

	/*******************************************************************************************************************
		Serialization Functions.
	*******************************************************************************************************************/
	function serialize(metadata) {
		if (typeof Config.saves.isAllowed === 'function' && !Config.saves.isAllowed()) {
			if (Dialog.isOpen()) {
				$(document).one(':dialogclosed', () => UI.alert(L10n.get('savesDisallowed')));
			}
			else {
				UI.alert(L10n.get('savesDisallowed'));
			}

			return null;
		}

		const supplemental = metadata == null ? {} : { metadata }; // lazy equality for null
		supplemental.idx = State.qc;
		const data = LZString.compressToBase64(JSON.stringify(_marshal(supplemental, { type : Type.Serialize })));
		return data + LZString.compressToBase64(JSON.stringify({ [Story.domId] : data.length }));
	}

	function deserialize(base64Str) {
		/*
			NOTE: We purposefully do not attempt to catch parameter shenanigans
			here, instead relying on `_unmarshal()` to do the heavy lifting.
		*/

		let saveObj;

		try {
			const jsonstring = LZString.decompressFromBase64(base64Str);
			const offset = LZString.compressToBase64(jsonstring).length;
			const metadata = JSON.parse(LZString.decompressFromBase64(base64Str.slice(offset)));
			_meta = metadata;
			saveObj = JSON.parse(jsonstring);
			if (_meta?.[Story.domId] !== offset) saveObj.idx += '';
		}
		catch (ex) { /* no-op; `_unmarshal()` will handle the error */ }

		if (!_unmarshal(saveObj, 'file')) {
			return null;
		}

		return saveObj.metadata;
	}

	/*******************************************************************************************************************
		Event Functions.
	*******************************************************************************************************************/
	function onLoadAdd(handler) {
		const valueType = Util.getType(handler);

		if (valueType !== 'function') {
			throw new TypeError(`Save.onLoad.add handler parameter must be a function (received: ${valueType})`);
		}

		_onLoadHandlers.add(handler);
	}

	function onLoadClear() {
		_onLoadHandlers.clear();
	}

	function onLoadDelete(handler) {
		return _onLoadHandlers.delete(handler);
	}

	function onLoadSize() {
		return _onLoadHandlers.size;
	}

	function onSaveAdd(handler) {
		const valueType = Util.getType(handler);

		if (valueType !== 'function') {
			throw new TypeError(`Save.onSave.add handler parameter must be a function (received: ${valueType})`);
		}

		_onSaveHandlers.add(handler);
	}

	function onSaveClear() {
		_onSaveHandlers.clear();
	}

	function onSaveDelete(handler) {
		return _onSaveHandlers.delete(handler);
	}

	function onSaveSize() {
		return _onSaveHandlers.size;
	}

	/*******************************************************************************************************************
		Utility Functions.
	*******************************************************************************************************************/
	function _appendSlots(array, num) {
		for (let i = 0; i < num; ++i) {
			array.push(null);
		}

		return array;
	}

	function _savesObjIsEmpty(saves) {
		const slots = saves.slots;
		let isSlotsEmpty = true;

		for (let i = 0, iend = slots.length; i < iend; ++i) {
			if (slots[i] !== null) {
				isSlotsEmpty = false;
				break;
			}
		}

		return saves.autosave === null && isSlotsEmpty;
	}

	function _savesObjSave(saves) {
		if (_savesObjIsEmpty(saves)) {
			storage.delete('saves');
			return true;
		}

		return storage.set('saves', saves);
	}

	function _marshal(supplemental, details) {
		if (DEBUG) { console.log(`[Save/_marshal(…, { type : '${details.type}' })]`); }

		if (supplemental != null && typeof supplemental !== 'object') { // lazy equality for null
			throw new Error('supplemental parameter must be an object');
		}

		const saveObj = Object.assign({}, supplemental, {
			id    : Config.saves.id,
			state : State.marshalForSave(),
			idx   : State.qc
		});

		if (Config.saves.version) {
			saveObj.version = Config.saves.version;
		}

		_onSaveHandlers.forEach(fn => fn(saveObj, details));

		// Delta encode the state history and delete the non-encoded property.
		saveObj.state.delta = State.deltaEncode(saveObj.state.history);
		delete saveObj.state.history;

		return saveObj;
	}

	function _unmarshal(saveObj) {
		if (DEBUG) { console.log('[Save/_unmarshal()]'); }

		try {
			/* eslint-disable no-param-reassign */

			if (!saveObj || !saveObj.hasOwnProperty('id') || !saveObj.hasOwnProperty('state')) {
				throw new Error(L10n.get('errorSaveMissingData'));
			}

			// Delta decode the state history and delete the encoded property.
			if (!saveObj.state.history) {
				if (saveObj.state.jdelta) delete saveObj.state.jdelta;
				if (saveObj.state.delta) saveObj.state.history = State.deltaDecode(saveObj.state.delta);
				delete saveObj.state.delta;
			}

			_onLoadHandlers.forEach(fn => fn(saveObj));

			if (saveObj.id !== Config.saves.id) throw new Error(L10n.get('errorSaveIdMismatch'));
			saveObj.state.idx = saveObj.idx || '';

			// Restore the state.
			State.unmarshalForSave(saveObj.state); // may also throw exceptions

			// Show the active moment.
			Engine.show();
			/* eslint-enable no-param-reassign */
		}
		catch (ex) {
			UI.alert(`${ex.message.toUpperFirst()}.</p><p>${L10n.get('aborting')}.`);
			return false;
		}

		return true;
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		/*
			Save Functions.
		*/
		init  : { value : savesInit },
		get   : { value : savesObjGet },
		clear : { value : savesObjClear },
		ok    : { value : savesOk },

		/*
			Autosave Functions.
		*/
		autosave : {
			value : Object.freeze(Object.defineProperties({}, {
				ok     : { value : autosaveOk },
				has    : { value : autosaveHas },
				get    : { value : autosaveGet },
				load   : { value : autosaveLoad },
				save   : { value : autosaveSave },
				delete : { value : autosaveDelete }
			}))
		},

		/*
			Slots Functions.
		*/
		slots : {
			value : Object.freeze(Object.defineProperties({}, {
				ok      : { value : slotsOk },
				length  : { get : slotsLength },
				isEmpty : { value : slotsIsEmpty },
				count   : { value : slotsCount },
				has     : { value : slotsHas },
				get     : { value : slotsGet },
				load    : { value : slotsLoad },
				save    : { value : slotsSave },
				delete  : { value : slotsDelete }
			}))
		},

		/*
			Disk Import/Export Functions.
		*/
		export : { value : exportToDisk },
		import : { value : importFromDisk },

		/*
			Serialization Functions.
		*/
		serialize   : { value : serialize },
		deserialize : { value : deserialize },

		/*
			Event Functions.
		*/
		onLoad : {
			value : Object.freeze(Object.defineProperties({}, {
				add      : { value : onLoadAdd },
				clear    : { value : onLoadClear },
				delete   : { value : onLoadDelete },
				size     : { get : onLoadSize },
				handlers : { value : _onLoadHandlers }
			}))
		},
		onSave : {
			value : Object.freeze(Object.defineProperties({}, {
				add      : { value : onSaveAdd },
				clear    : { value : onSaveClear },
				delete   : { value : onSaveDelete },
				size     : { get : onSaveSize },
				handlers : { value : _onSaveHandlers }
			}))
		},
		meta : { get : () => _meta }
	}));
})();