extwee
Version:
A story compiler tool using Twine-compatible formats
934 lines (805 loc) • 25.8 kB
JavaScript
import Passage from './Passage.js';
import { generate as generateIFID } from './IFID/generate.js';
import { encode } from 'html-entities';
// Set the creator name.
// This is used to identify the program that created the story.
const creatorName = 'extwee';
// Set the creator version.
const creatorVersion = '2.3.12';
/**
* Story class.
* @class
* @classdesc Represents a Twine story.
* @property {string} name - Name of the story.
* @property {string} IFID - Interactive Fiction ID (IFID) of Story.
* @property {string} start - Name of start passage.
* @property {string} format - Story format of Story.
* @property {string} formatVersion - Story format version of Story.
* @property {number} zoom - Zoom level.
* @property {Array} passages - Array of Passage objects. @see {@link Passage}
* @property {string} creator - Program used to create Story.
* @property {string} creatorVersion - Version used to create Story.
* @property {object} metadata - Metadata of Story.
* @property {object} tagColors - Tag Colors
* @property {string} storyJavaScript - Story JavaScript
* @property {string} storyStylesheet - Story Stylesheet
* @method {number} addPassage - Add a passage to the story and returns the new length of the passages array.
* @method {number} removePassageByName - Remove a passage from the story by name and returns the new length of the passages array.
* @method {Array} getPassagesByTag - Find passages by tag.
* @method {Array} getPassageByName - Find passage by name.
* @method {number} size - Size (number of passages).
* @method {string} toJSON - Export Story as JSON representation.
* @method {string} toTwee - Return Twee representation.
* @method {string} toTwine2HTML - Return Twine 2 HTML representation.
* @method {string} toTwine1HTML - Return Twine 1 HTML representation.
* @example
* const story = new Story('My Story');
* story.IFID = '12345678-1234-5678-1234-567812345678';
* story.start = 'Start';
* story.format = 'SugarCube';
* story.formatVersion = '2.31.0';
* story.zoom = 1;
* story.creator = 'extwee';
* story.creatorVersion = '2.2.1';
*/
class Story {
/**
* Internal name of story
* @private
*/
#_name = 'Untitled Story';
/**
* Internal start
* @private
*/
#_start = '';
/**
* Internal IFID
* @private
*/
#_IFID = '';
/**
* Internal story format
* @private
*/
#_format = '';
/**
* Internal version of story format
*/
#_formatVersion = '';
/**
* Internal zoom level
* Default is 1 (100%)
*/
#_zoom = 1;
/**
* Passages
* @private
*/
#_passages = [];
/**
* Creator
* @private
*/
#_creator = '';
/**
* CreatorVersion
* @private
*/
#_creatorVersion = '';
/**
* Metadata
* @private
*/
#_metadata = null;
/**
* Tag Colors
* @private
*/
#_tagColors = {};
/**
* Story JavaScript
* @private
*/
#_storyJavaScript = '';
/**
* Story Stylesheet
* @private
*/
#_storyStylesheet = '';
/**
* Creates a story.
* @param {string} name - Name of the story.
*/
constructor (name = 'Untitled Story') {
// Every story has a name.
this.name = name;
// Store the creator.
this.#_creator = creatorName;
// Store the creator version.
this.#_creatorVersion = creatorVersion;
// Set metadata to an object.
this.#_metadata = {};
}
/**
* Each story has a name
* @returns {string} Name
*/
get name () { return this.#_name; }
/**
* @param {string} a - Replacement story name
*/
set name (a) {
if (typeof a === 'string') {
this.#_name = a;
} else {
throw new Error('Story name must be a string');
}
}
/**
* Tag Colors object (each property is a tag and its color)
* @returns {object} tag colors array
*/
get tagColors () { return this.#_tagColors; }
/**
* @param {object} a - Replacement tag colors
*/
set tagColors (a) {
if (a instanceof Object) {
this.#_tagColors = a;
} else {
throw new Error('Tag colors must be an object!');
}
}
/**
* Interactive Fiction ID (IFID) of Story.
* @returns {string} IFID
*/
get IFID () { return this.#_IFID; }
/**
* @param {string} i - Replacement IFID.
*/
set IFID (i) {
if (typeof i === 'string') {
this.#_IFID = i;
} else {
throw new Error('IFID must be a String!');
}
}
/**
* Name of start passage.
* @returns {string} start
*/
get start () { return this.#_start; }
/**
* @param {string} s - Replacement start
*/
set start (s) {
if (typeof s === 'string') {
this.#_start = s;
} else {
throw new Error('start (passage name) must be a String!');
}
}
/**
* Story format version of Story.
* @returns {string} story format version
*/
get formatVersion () { return this.#_formatVersion; }
/**
* @param {string} f - Replacement format version
*/
set formatVersion (f) {
if (typeof f === 'string') {
this.#_formatVersion = f;
} else {
throw new Error('Story format version must be a String!');
}
}
/**
* Metadata of Story.
* @returns {object} metadata of story
*/
get metadata () { return this.#_metadata; }
/**
* @param {object} o - Replacement metadata
*/
set metadata (o) {
if (typeof o === 'object') {
this.#_metadata = o;
} else {
throw new Error('Story metadata must be Object!');
}
}
/**
* Story format of Story.
* @returns {string} format
*/
get format () { return this.#_format; }
/**
* @param {string} f - Replacement format
*/
set format (f) {
if (typeof f === 'string') {
this.#_format = f;
} else {
throw new Error('Story format must be a String!');
}
}
/**
* Program used to create Story.
* @returns {string} Creator Program
*/
get creator () { return this.#_creator; }
/**
* @param {string} c - Creator Program of Story
*/
set creator (c) {
if (typeof c === 'string') {
this.#_creator = c;
} else {
throw new Error('Creator must be String');
}
}
/**
* Version used to create Story.
* @returns {string} Version
*/
get creatorVersion () { return this.#_creatorVersion; }
/**
* @param {string} c - Version of creator program
*/
set creatorVersion (c) {
if (typeof c === 'string') {
this.#_creatorVersion = c;
} else {
throw new Error('Creator version must be a string!');
}
}
/**
* Zoom level.
* @returns {number} Zoom level
*/
get zoom () { return this.#_zoom; }
/**
* @param {number} n - Replacement zoom level
*/
set zoom (n) {
if (typeof n === 'number') {
// Parse float with a fixed length and then force into Number
this.#_zoom = Number(Number.parseFloat(n).toFixed(2));
} else {
throw new Error('Zoom level must be a Number!');
}
}
/**
* Passages in Story.
* @returns {Array} Passages
* @property {Array} passages - Passages
*/
get passages () { return this.#_passages; }
/**
* Set passages in Story.
* @param {Array} p - Replacement passages
* @property {Array} passages - Passages
* @throws {Error} Passages must be an Array!
* @throws {Error} Passages must be an Array of Passage objects!
*/
set passages (p) {
if (Array.isArray(p)) {
if (p.every((passage) => passage instanceof Passage)) {
this.#_passages = p;
} else {
throw new Error('Passages must be an Array of Passage objects!');
}
} else {
throw new Error('Passages must be an Array!');
}
}
/**
* Story stylesheet data can be set as a passage, property value, or both.
* @returns {string} storyStylesheet
*/
get storyStylesheet () {
return this.#_storyStylesheet;
}
/**
* @param {string} s - Replacement story stylesheet
*/
set storyStylesheet (s) {
if (typeof s === 'string') {
this.#_storyStylesheet = s;
} else {
throw new Error('Story stylesheet must be a string!');
}
}
/**
* Get story JavaScript.
* @returns {string} storyJavaScript
*/
get storyJavaScript () {
return this.#_storyJavaScript;
}
/**
* Set story JavaScript.
* @param {string} s - Replacement story JavaScript
*/
set storyJavaScript (s) {
if (typeof s === 'string') {
this.#_storyJavaScript = s;
} else {
throw new Error('Story JavaScript must be a string!');
}
}
/**
* Add a passage to the story.
* Passing `StoryData` will override story metadata and `StoryTitle` will override story name.
* @method addPassage
* @param {Passage} p - Passage to add to Story.
* @returns {number} Return new length of passages array.
*/
addPassage (p) {
// Check if passed argument is a Passage.
if (!(p instanceof Passage)) {
// We can only add passages to array.
throw new Error('Can only add Passages to the story!');
}
// Does this passage already exist in the collection?
// If it does, we ignore it and return.
if (this.getPassageByName(p.name) !== null) {
// Warn user
console.warn(`Warning: A passage with the name "${p.name}" already exists!`);
//
return this.#_passages.length;
}
// Parse StoryData.
if (p.name === 'StoryData') {
// Try to parse JSON.
try {
// Attempt to parse storyData JSON.
const metadata = JSON.parse(p.text);
// IFID.
if (Object.prototype.hasOwnProperty.call(metadata, 'ifid')) {
this.IFID = metadata.ifid;
}
// Format.
if (Object.prototype.hasOwnProperty.call(metadata, 'format')) {
this.format = metadata.format;
}
// formatVersion.
if (Object.prototype.hasOwnProperty.call(metadata, 'format-version')) {
this.formatVersion = metadata['format-version'];
}
// Zoom.
if (Object.prototype.hasOwnProperty.call(metadata, 'zoom')) {
this.zoom = metadata.zoom;
}
// Start.
if (Object.prototype.hasOwnProperty.call(metadata, 'start')) {
this.start = metadata.start;
}
// Tag colors.
if (Object.prototype.hasOwnProperty.call(metadata, 'tag-colors')) {
this.tagColors = metadata['tag-colors'];
}
} catch {
// Ignore errors.
}
// Don't add StoryData to passages.
return this.#_passages.length;
}
// Parse StoryTitle.
if (p.name === 'StoryTitle') {
// If there is a StoryTitle passage, we accept the name.
// Set internal name based on StoryTitle.
this.name = p.text;
// Once we override story.name, return.
return this.#_passages.length;
}
// Parse Start
if (p.name === 'Start') {
// Have we already encountered StoryData?
if (this.start == '') {
// Set internal start based on Start.
/**
* Four possible scenarios:
* 1. StoryData has already been encountered, and we will never get here.
* 2. StoryData exists and will be encountered after Start.
* 3. StoryData does not exist.
* 4. Start is the first and only passage.
*/
this.start = p.name;
}
}
// Parse passages with "script" tag
if (p.tags.includes('script')) {
// Add the passage text to storyJavaScript
this.#_storyJavaScript += p.text;
// Don't add script-tagged passages to the passages array
return this.#_passages.length;
}
// Parse passages with "stylesheet" tag
if (p.tags.includes('stylesheet')) {
// Add the passage text to storyJavaScript
this.#_storyStylesheet += p.text;
// Don't add script-tagged passages to the passages array
return this.#_passages.length;
}
// This is not StoryData or StoryTitle.
// Push the passage to the array.
return this.#_passages.push(p);
}
/**
* Remove a passage from the story by name.
* @method removePassageByName
* @param {string} name - Passage name to remove.
* @returns {number} Return new length of passages array.
*/
removePassageByName (name) {
this.#_passages = this.#_passages.filter(passage => passage.name !== name);
return this.#_passages.length;
}
/**
* Find passages by tag.
* @method getPassagesByTag
* @param {string} t - Passage name to search for
* @returns {Array} Return array of passages
*/
getPassagesByTag (t) {
// Look through passages
return this.#_passages.filter((passage) => {
// Look through each passage's tags
return passage.tags.some((tag) => t === tag);
});
}
/**
* Find passage by name.
* @method getPassageByName
* @param {string} name - Passage name to search for
* @returns {Passage | null} Return passage or null
*/
getPassageByName (name) {
// Look through passages
const results = this.#_passages.find((passage) => passage.name === name);
// Return entry or null, if not found
return results !== undefined ? results : null;
}
/**
* Size (number of passages).
* @method size
* @returns {number} Return number of passages
*/
size () {
return this.#_passages.length;
}
/**
* Export Story as JSON representation.
* @method toJSON
* @returns {string} JSON string.
*/
toJSON () {
// Create an initial object for later serialization.
const s = {
name: this.name,
tagColors: this.tagColors,
ifid: this.IFID,
start: this.start,
formatVersion: this.formatVersion,
metadata: this.metadata,
format: this.format,
creator: this.creator,
creatorVersion: this.creatorVersion,
zoom: this.zoom,
style: this.storyStylesheet,
script: this.storyJavaScript,
passages: []
};
// For each passage, convert into simple object.
this.passages.forEach((p) => {
s.passages.push({
name: p.name,
tags: p.tags,
metadata: p.metadata,
text: p.text
});
});
// Return stringified Story object.
return JSON.stringify(s, null, 4);
}
/**
* Return Twee representation.
*
* See: Twee 3 Specification
* (https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md)
*
* @method toTwee
* @returns {string} Twee String
*/
toTwee () {
// Write the StoryData first.
let outputContents = ':: StoryData\n';
// Create default object.
const metadata = {};
/**
* ifid: (string) Required. Maps to <tw-storydata ifid>.
*/
// Test if IFID is in UUID format.
if (this.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/) === null) {
// Generate a new IFID for this work.
metadata.ifid = generateIFID();
// Write the existing IFID.
console.warn('Warning: IFID is not in UUIDv4 format! A new IFID was generated.');
} else {
// Write the IFID.
metadata.ifid = this.IFID;
}
/**
* format: (string) Optional. Maps to <tw-storydata format>.
*/
// Does format exist?
if (this.format !== '') {
// Write the existing format.
metadata.format = this.format;
}
/**
* format-version: (string) Optional. Maps to <tw-storydata format-version>.
*/
// Does formatVersion exist?
if (this.formatVersion !== '') {
// Write the existing formatVersion.
metadata['format-version'] = this.formatVersion;
}
/**
* zoom: (decimal) Optional. Maps to <tw-storydata zoom>.
*/
// Does zoom exist?
if (this.zoom !== 0) {
// Write the existing zoom.
metadata.zoom = this.zoom;
}
/**
* start: (string) Optional.
* Maps to <tw-passagedata name> of the node whose pid matches <tw-storydata startnode>.
*
* If there is no start value, the "Start" passage is assumed to be the starting passage.
*/
// Does start exist?
if (this.start !== '') {
// Write the existing start.
metadata.start = this.start;
}
/**
* tag-colors: (object of tag(string):color(string) pairs) Optional.
* Pairs map to <tw-tag> nodes as <tw-tag name>:<tw-tag color>.
*/
const numberOfColors = Object.keys(this.tagColors).length;
// Are there any colors?
if (numberOfColors > 0) {
// Add a tag-colors property
metadata['tag-colors'] = this.tagColors;
}
// Write out the story metadata.
outputContents += `${JSON.stringify(metadata, undefined, 2)}`;
// Add two newlines.
outputContents += '\n\n';
// Write story name as StoryTitle.
outputContents += ':: StoryTitle\n' + this.name;
// Add two newlines.
outputContents += '\n\n';
// Write out the story stylesheet, if any.
if (this.#_storyStylesheet.length > 0) {
outputContents += ':: StoryStylesheet [stylesheet]\n' + this.#_storyStylesheet + '\n\n';
}
// Write out the story JavaScript, if any.
if (this.#_storyJavaScript.length > 0) {
outputContents += ':: StoryJavaScript [script]\n' + this.#_storyJavaScript + '\n\n';
}
// For each passage, append it to the output.
this.passages.forEach((passage) => {
outputContents += passage.toTwee();
});
// Return the Twee string.
return outputContents;
}
/**
* Return Twine 2 HTML.
*
* See: Twine 2 HTML Output
* (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md)
*
* The only required attributes are `name` and `ifid` of the `<tw-storydata>` element. All others are optional.
*
* The `<tw-storydata>` element may have any number of optional attributes, which are:
* - `startnode`: (integer) Optional. The PID of the starting passage.
* - `creator`: (string) Optional. The name of the program that created the story.
* - `creator-version`: (string) Optional. The version of the program that created the story.
* - `zoom`: (decimal) Optional. The zoom level of the story.
* - `format`: (string) Optional. The format of the story.
* - `format-version`: (string) Optional. The version of the format of the story.
*
* Because story stylesheet data can be represented as a passage, property value, or both, all approaches are encoded.
*
* Because story JavaScript can be represented as a passage, property value, or both, all approaches are encoded.
*
* @method toTwine2HTML
* @returns {string} Twine 2 HTML string
*/
toTwine2HTML () {
// Get the passages.
// Make a local copy, as we might be modifying it.
let passages = this.passages;
// Twine 2 HTML starts with a <tw-storydata> element.
// See: Twine 2 HTML Output
// name: (string) Required. The name of the story.
//
// Maps to <tw-storydata name>.
//
let storyData = `<tw-storydata name="${ encode( this.name ) }"`;
// ifid: (string) Required.
// An IFID is a sequence of between 8 and 63 characters,
// each of which shall be a digit, a capital letter or a
// hyphen that uniquely identify a story (see Treaty of Babel).
//
// Maps to <tw-storydata ifid>.
//
// Check if IFID exists.
if (this.IFID !== '') {
// Write the existing IFID.
storyData += ` ifid="${ this.IFID }"`;
} else {
// Generate a new IFID.
// Twine 2 uses v4 (random) UUIDs, using only capital letters.
storyData += ` ifid="${ generateIFID() }"`;
}
// Passage Identification (PID) counter.
// (Twine 2 starts with 1, so we mirror that.)
let PIDcounter = 1;
// Set initial PID value.
let startPID = 1;
// We have to do a bit of nonsense here.
// Twine 2 HTML cares about PID values.
passages.forEach((p) => {
// Have we found the starting passage?
if (p.name === this.start) {
// If so, set the PID based on index.
startPID = PIDcounter;
}
// Increase and keep looking.
PIDcounter++;
});
// Are there any passages?
if (passages.length === 0) {
// No passages, so we can't set a startnode.
startPID = 0;
}
/**
* Multiple possible scenarios:
* 1. No passages. (StartPID is 0.)
* 2. Start is the first or only passage. (StartPID is 1.)
* 3. Starting passage is not the first passage. (StartPID is > 1.)
*/
// startnode: (integer) Optional. The PID of the starting passage.
storyData += ` startnode="${startPID}"`;
// creator: (string) Optional. The name of the program that created the story.
// Maps to <tw-storydata creator>.
if(this.creator !== '') {
// Write existing creator.
storyData += ` creator="${ encode( this.creator ) }"`;
}
// creator-version: (string) Optional. The version of the program that created the story.
// Maps to <tw-storydata creator-version>.
if(this.creatorVersion !== '') {
// Default to extwee version.
storyData += ` creator-version="${this.creatorVersion}"`;
}
// zoom: (decimal) Optional. The zoom level of the story.
// Maps to <tw-storydata zoom>.
if(this.zoom !== 1) {
// Write existing or default value.
storyData += ` zoom="${this.zoom}"`;
}
// format: (string) Optional. The format of the story.
// Maps to <tw-storydata format>.
if(this.format !== '') {
// Write existing or default value.
storyData += ` format="${this.format}"`;
}
// format-version: (string) Optional. The version of the format of the story.
// Maps to <tw-storydata format-version>.
if(this.formatVersion !== '') {
// Write existing or default value.
storyData += ` format-version="${this.formatVersion}"`;
}
// Add the default attributes.
storyData += ' options hidden>\n';
// We may have passages with tags of 'stylesheet', story stylesheet data, both, or none.
// Step 1: Add all passages with tag of 'stylesheet' to the stylesheet element.
// Filter out passages with tag of 'stylesheet'.
const stylesheetPassages = passages.filter((passage) => passage.tags.includes('stylesheet'));
// Remove stylesheet passages from the main array.
passages = passages.filter(p => !p.tags.includes('stylesheet'));
// Were there any stylesheet passages?
if (stylesheetPassages.length > 0) {
// Start the STYLE.
storyData += '\t<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">';
// Concatenate passages.
stylesheetPassages.forEach((passage) => {
// Add text of passages.
storyData += passage.text;
});
// Close the STYLE.
storyData += '</style>\n';
}
// Step 2: Check if the internal stylesheet data is empty.
// If it is not empty, add it to the stylesheet element.
if (this.#_storyStylesheet.length > 0) {
// Add the internal stylesheet.
storyData += `\t<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${this.#_storyStylesheet}</style>\n`;
}
// We may have passages with tags of 'script', story JavaScript data, both, or none.
// Step 1: Add all passages with tag of 'script' to the script element.
// Filter out passages with tag of 'script'.
const scriptPassages = passages.filter((passage) => passage.tags.includes('script'));
// Remove script passages from the main array.
passages = passages.filter(p => !p.tags.includes('script'));
// Were there any script passages?
if (scriptPassages.length > 0) {
// Start the SCRIPT.
storyData += '\t<script role="script" id="twine-user-script" type="text/twine-javascript">';
// Concatenate passages.
scriptPassages.forEach((passage) => {
// Add text of passages.
storyData += passage.text;
});
// Close SCRIPT.
storyData += '</script>\n';
}
// Step 2: Check if the internal JavaScript data is empty.
// If it is not empty, add it to the script element.
if (this.#_storyJavaScript.length > 0) {
// Add the internal JavaScript.
storyData += `\t<script role="script" id="twine-user-script" type="text/twine-javascript">${this.#_storyJavaScript}</script>\n`;
}
// Reset the PID counter.
PIDcounter = 1;
// Build the passages HTML.
this.passages.forEach((passage) => {
// Append each passage element using the PID counter.
storyData += passage.toTwine2HTML(PIDcounter);
// Increase counter inside loop.
PIDcounter++;
});
// Generate <tw-tag> elements for each tag, if any.
const tagList = Object.keys(this.tagColors);
// For each tag, generate a <tw-tag> element.
tagList.forEach((tag) => {
// Add the <tw-tag> element.
storyData += `\t<tw-tag name="${tag}" color="${this.tagColors[tag]}"></tw-tag>\n`;
});
// Close the HTML element.
storyData += '</tw-storydata>';
// Return HTML contents.
return storyData;
}
/**
* Return Twine 1 HTML.
*
* See: Twine 1 HTML Output
* (https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md)
*
* @method toTwine1HTML
* @returns {string} Twine 1 HTML string.
*/
toTwine1HTML () {
// Begin HTML output.
let outputContents = '';
// Process passages (if any).
this.passages.forEach((p) => {
// Output HTML output per passage.
outputContents += `\t${p.toTwine1HTML()}`;
});
// Return Twine 1 HTML content.
return outputContents;
}
}
export { Story, creatorName, creatorVersion };