UNPKG

extwee

Version:

A story compiler tool using Twine-compatible formats

345 lines (302 loc) 10.2 kB
import { parse as HtmlParser } from 'node-html-parser'; import { Story } from '../Story.js'; import Passage from '../Passage.js'; import { decode } from 'html-entities'; /** * Parse Twine 2 HTML into Story object. * * See: Twine 2 HTML Output Specification * (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md) * * Produces warnings for: * - Missing name attribute on `<tw-storydata>` element. * - Missing IFID attribute on `<tw-storydata>` element. * - Malformed IFID attribute on `<tw-storydata>` element. * @function parse * @param {string} content - Twine 2 HTML content to parse. * @returns {Story} Story object based on Twine 2 HTML content. * @throws {TypeError} Content is not a string. * @throws {Error} Not Twine 2 HTML content! * @throws {Error} Cannot parse passage data without name! * @throws {Error} Passages are required to have PID! */ function parse (content) { // Create new story. const story = new Story(); // Can only parse string values. if (typeof content !== 'string') { throw new TypeError('TypeError: Content is not a string!'); } // Set default start node. let startNode = null; // Send to node-html-parser // Enable getting the content of 'script', 'style', and 'pre' elements // Get back a DOM const dom = new HtmlParser( content, { lowerCaseTagName: false, script: true, style: true, pre: true }); // Pull out the `<tw-storydata>` element. const storyDataElements = dom.getElementsByTagName('tw-storydata'); // Did we find any elements? if (storyDataElements.length === 0) { // If there is not a single `<tw-storydata>` element, this is not a Twine 2 story! throw new TypeError('TypeError: Not Twine 2 HTML content!'); } // We only parse the first element found. const storyData = storyDataElements[0]; /** * name: (string) Required. * The name of the story. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'name')) { // Set the story name story.name = storyData.attributes.name; } else { // Name is a required field. Warn user. console.warn('Warning: The name attribute is missing from tw-storydata!'); } /** * 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). */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'ifid')) { // Update story IFID. story.IFID = storyData.attributes.ifid; } else { // Name is a required filed. Warn user. console.warn('Warning: The ifid attribute is missing from tw-storydata!'); } // Check if the IFID has valid formatting. if (story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/) === null) { // IFID is not valid. console.warn('Warning: The IFID is not in valid UUIDv4 formatting on tw-storydata!'); } /** * creator: (string) Optional. * The name of program used to create the file. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'creator')) { // Update story creator story.creator = storyData.attributes.creator; } /** * creator-version: (string) Optional. * The version of the program used to create the file. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'creator-version')) { // Update story creator version story.creatorVersion = storyData.attributes['creator-version']; } /** * format: (string) Optional. * The story format used to create the story. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'format')) { // Update story format story.format = storyData.attributes.format; } /** * format-version: (string) Optional. * The version of the story format used to create the story. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'format-version')) { // Update story format version story.formatVersion = storyData.attributes['format-version']; } /** * zoom: (string) Optional. * The decimal level of zoom (i.e. 1.0 is 100% and 1.2 would be 120% zoom level). */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'zoom')) { // Update story zoom story.zoom = Number(Number.parseFloat(storyData.attributes.zoom).toFixed(2)); } /** * startnode: (string) Optional. * The PID matching a `<tw-passagedata>` element whose content should be displayed first. */ if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'startnode')) { // Take string value and convert to Int startNode = Number.parseInt(storyData.attributes.startnode, 10); } // Pull out the `<tw-passagedata>` element. const storyPassages = dom.querySelectorAll('tw-passagedata'); // Move through the passages for (const passage in storyPassages) { // Get the passage attributes const attr = storyPassages[passage].attributes; // Get the passage text const text = storyPassages[passage].rawText; /** * position: (string) Optional. * Comma-separated X and Y position of the upper-left * of the passage when viewed within the Twine 2 editor. */ // Set a default position. let position = null; // Does position exist? if (Object.prototype.hasOwnProperty.call(attr, 'position')) { // Update position. position = attr.position; } /** * size: (string) Optional. * Comma-separated width and height of the * passage when viewed within the Twine 2 editor. */ // Set a default size. let size = null; // Does size exist? if (Object.prototype.hasOwnProperty.call(attr, 'size')) { // Update size. size = attr.size; } /** * name: (string) Required. * The name of the passage. * * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages */ // Create a default value let name = 'Untitled Passage'; // Does name exist? if (Object.prototype.hasOwnProperty.call(attr, 'name')) { // Escape the name name = attr.name; } else { console.warn('Warning: name attribute is missing! Default passage name will be used.'); } /** * tags: (string) Optional. * A space-separated list of tags for the passage. * * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages */ // Create empty tag array. let tags = []; // Does the tags attribute exist? if (Object.prototype.hasOwnProperty.call(attr, 'tags')) { // Escape any tags // (Attributes can, themselves, be empty strings.) if (attr.tags.length > 0 && attr.tags !== '""') { // Escape the tags tags = attr.tags; // Split by spaces into an array tags = tags.split(' '); } // Remove any empty strings. tags = tags.filter(tag => tag !== ''); } /** * metadata: (object) Optional. * An object containing additional metadata about the passage. * * Twine 2 HTML does not support metadata, but other formats do. */ // Create metadata for passage. const metadata = {}; // Does position exist? if (position !== null) { // Add the property to metadata metadata.position = position; } // Does size exist? if (size !== null) { // Add the property to metadata metadata.size = size; } /** * pid: (string) Required. * The Passage ID (PID). * (Note: This is subject to change during editing with Twine 2.) * * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages */ // Create a default PID let pid = 1; // Does pid exist? if (Object.prototype.hasOwnProperty.call(attr, 'pid')) { // Parse string into int // Update PID pid = Number.parseInt(attr.pid, 10); } else { console.warn('Warning: pid attribute is missing! Default PID will be used.'); } // Check the current PID against startNode number. if (pid === startNode) { // These match. // Save the passage name. story.start = name; } // Add a new Passage into an array story.addPassage( new Passage( decode(name), decode(text), tags.map(tag => decode(tag)), metadata ) ); } // Look for the style element const styleElement = dom.querySelector('#twine-user-stylesheet'); // Does the style element exist? if (styleElement !== null) { // Check if there is any content. if (styleElement.rawText.length > 0) { // Update stylesheet passage story.addPassage(new Passage( 'UserStylesheet', styleElement.rawText, ['stylesheet']) ); } } // Look for the script element const scriptElement = dom.querySelector('#twine-user-script'); // Does the script element exist? if (scriptElement !== null) { // Check if there is any content. if (scriptElement.rawText.length > 0) { story.addPassage(new Passage( 'UserScript', scriptElement.rawText, ['script']) ); } } // Look for all <tw-tag> elements. const twTags = dom.querySelectorAll('tw-tag'); // Parse through the entries. twTags.forEach((tags) => { // Parse each tag element const attributes = tags.attributes; // Create default value for name let name = ''; // Create default value for color let color = ''; // Check for name if (Object.prototype.hasOwnProperty.call(attributes, 'name')) { name = attributes.name; } // Check for color if (Object.prototype.hasOwnProperty.call(attributes, 'color')) { color = attributes.color; } // If both are not empty strings, use them. if (name !== '' && color !== '') { // Add name and color to the object story.tagColors[name] = color; } }); // Return the parsed story. return story; } export { parse };