Creating your own Twine 2 Story Format: Part 5: Packaging a Story Format

Creating your own Twine 2 Story Format

Part 5: Packaging a Story Format

The Twine 2 Story Formats specification details how to package a story format in such a way that Twine 2 (and other Twee tools like Extwee) can read and use.

To start, a story format has a structure similar to JavaScript Object Notation (JSON). It must have a version, but should a name, description, and version properties as well. Its source is HTML that also includes the JavaScript code of the story format itself. During the compiling process, the Twine 2 editor (or Twee tools) will use this to produce a HTML file with the story format and the <tw-storydata> element added into it.

Story Format HTML

In general, a story format should use the placeholders,{{STORY_NAME}} and {{STORY_DATA}}, within its HTML.

Consider the following example HTML code:

<!DOCTYPE html>
<html>
	<head>
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<meta charset="utf-8">
		<title>{{STORY_NAME}}</title>
	</head>
	<body>
		{{STORY_DATA}}
		<script>
                // Story Format JavaScript
		</script>
	</body>
</html>

Using the existing story format code from the previous part, it can be combined together to generate the following code:

<!DOCTYPE html>
<html>
	<head>
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<meta charset="utf-8">
		<title>{{STORY_NAME}}</title>
	</head>
	<body>
		{{STORY_DATA}}
		<script>
      (function(window){

              // Add an 'example' global property
              window.example = {};

              // Extend the window.example with a parse()
              // Parse the text
              // - Change any &lt; to <
              // - Change any &gt; to >
              // - Run any <script>
              // - Include any passages using <include>
              // - Convert string emphasis
              // - Convert emphasis
              // - Create hyperlinks (and add event listeners)
              window.example.parse = function parse(result) {

                  // Look for &lt;
                  // If found, convert into '<'
                  result = result.replace(/&lt;/g, function (match, target) {

                      return "<";

                  });

                  // Look for &gt;
                  // If found, convert into '>'
                  result = result.replace(/&gt;/g, function (match, target) {

                      return ">";

                  });

                  // Using innerHTML will not run any SCRIPT tags.
                  // Look for SCRIPT tags
                  // If found, run eval(text)
                  result = result.replace(/<script>((?:.|\n)*?)<\/script>/g, function(match, target) {
                      // Run the code
                      eval(target);
                      // Return an empty string
                      return "";
                  });

                  // Look for <include> elements
                  // If found, find the passage, parse it, and return its contents
                  result = result.replace(/<include>(.*?)<\/include>/g, function(match, target) {

                      // Look for the passage by its name
                      let passage = example.findPassageByName(target);

                      // Save a result
                      let result = "";

                      // Was it found?
                      if(passage !== null) {
                          // If so, parse it and save the result
                          result = example.parse(passage.innerHTML)

                      }

                      // Return result.
                      // It will either be an empty string or the parsed content.
                      return result;
                  });


                  // Look for any text in the pattern of __text__.
                  // If found, replace with <em>text</em>
                  result = result.replace(/\_\_(.*?)\_\_/g, function (match, target) {

                      return `<em>${target}</em>`;

                  });

                  // Look for any text in the pattern of **text**.
                  // If found, replace with <strong>text</strong>
                  result = result.replace(/\*\*(.*?)\*\*/g, function (match, target) {

                      return `<strong>${target}</strong>`;

                  });

                  /* Classic [[links]]  */
                  result = result.replace(/\[\[(.*?)\]\]/g, function (match, target) {
                      var display = target;

                      /* display|target format */
                      var barIndex = target.indexOf('|');

                      if (barIndex !== -1) {

                          display = target.substr(0, barIndex);
                          target = target.substr(barIndex + 1);

                      } else {
                          /* display->target format */
                          var rightArrIndex = target.indexOf('->');

                          if (rightArrIndex !== -1) {

                              display = target.substr(0, rightArrIndex);
                              target = target.substr(rightArrIndex + 2);

                          } else {

                              /* target<-display format */
                              var leftArrIndex = target.indexOf('<-');

                              if (leftArrIndex !== -1) {
                                  display = target.substr(leftArrIndex + 2);
                                  target = target.substr(0, leftArrIndex);
                              }
                          }
                      }

                      return '<a href="javascript:void(0)" data-passage="' +
                             target + '">' + display + '</a>';

                  });

                  return result;

              }

              window.example.findPassageByPid = function findPassageByPid(pid) {
                  // Find the element by its 'pid'
                  return document.querySelector(`[pid="${pid}"]`);
              }

              window.example.findPassageByName = function findPassageByName(name) {
                  // Find the element by its 'name'
                  return document.querySelector(`[name="${name}"]`);
              }

              window.example.showPassage = function showPassage(passagedata) {

                  // Look for the 'tw-passage'
                  let passage = document.querySelector('tw-passage');

                  // If it is null, it does not exist yet
                  if(passage === null) {
                      // Create it
                      passage = document.createElement('tw-passage');
                  }

                  // Save the passage contents
                  let passageContents = passagedata.innerHTML;

                  // Parse contents
                  passageContents = example.parse(passageContents);

                  // Set the 'innerHTML' to parsed text
                  passage.innerHTML = passageContents;

                  // Find all links in the current passage
                  let links = passage.querySelectorAll('a[data-passage]');

                  // For each link, add an event listener
                  for(let link of links) {
                      // If a link is clicked...
                      link.addEventListener('click', function() {
                          // Get the name of the passage to load.
                          let passagename = this.attributes["data-passage"].value;
                          // Find the passage by its name
                          let passagedata = example.findPassageByName(passagename);
                          // Show the passage
                          example.showPassage(passagedata);
                      });
                  }

                  // Append the child element to the HTML <body>
                  // (If it already exists, it will not be appended.)
                  document.body.appendChild(passage);

              }

              // Find the 'tw-storydata' element.
              let storydata = document.querySelector('tw-storydata');

              // Get the 'startnode' attribute. Save its 'value'.
              let startnode = storydata.attributes['startnode'].value;

              // Find the element with a 'pid' of the startnode
              let passagedata = window.example.findPassageByPid(startnode);

              // Show the passage matching the startnode
              example.showPassage(passagedata);

          }(window))
		</script>
	</body>
</html>

Using Node to Package Files

While most of the development so far has been done using the browser and editing the code, packaging the story format should best be done using another tool, Node.

Saving the Story Properties

The story properties can be saved in a JSON file.

story.json

{
  "name": "Example",
  "version": "0.0.2",
  "source": "",
  "description": "An example story format"
}

This can be read in Node through its fs.readFileSync() function and parsed using the JSON.parse() function. (This will turn the string contents into a JavaScript object.)

index.js

const fs = require('fs');

const storyFile = fs.readFileSync("story.json", 'utf8');
const story = JSON.parse(storyFile);

The HTML file for the story can also be saved in another file and read using Node’s fs.readFileSync().

index.html

<!DOCTYPE html>
<html>
	<head>
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<meta charset="utf-8">
		<title>{{STORY_NAME}}</title>
	</head>
	<body>
		{{STORY_DATA}}
		<script>
      (function(window){

              // Add an 'example' global property
              window.example = {};

              // Extend the window.example with a parse()
              // Parse the text
              // - Change any &lt; to <
              // - Change any &gt; to >
              // - Run any <script>
              // - Include any passages using <include>
              // - Convert string emphasis
              // - Convert emphasis
              // - Create hyperlinks (and add event listeners)
              window.example.parse = function parse(result) {

                  // Look for &lt;
                  // If found, convert into '<'
                  result = result.replace(/&lt;/g, function (match, target) {

                      return "<";

                  });

                  // Look for &gt;
                  // If found, convert into '>'
                  result = result.replace(/&gt;/g, function (match, target) {

                      return ">";

                  });

                  // Using innerHTML will not run any SCRIPT tags.
                  // Look for SCRIPT tags
                  // If found, run eval(text)
                  result = result.replace(/<script>((?:.|\n)*?)<\/script>/g, function(match, target) {
                      // Run the code
                      eval(target);
                      // Return an empty string
                      return "";
                  });

                  // Look for <include> elements
                  // If found, find the passage, parse it, and return its contents
                  result = result.replace(/<include>(.*?)<\/include>/g, function(match, target) {

                      // Look for the passage by its name
                      let passage = example.findPassageByName(target);

                      // Save a result
                      let result = "";

                      // Was it found?
                      if(passage !== null) {
                          // If so, parse it and save the result
                          result = example.parse(passage.innerHTML)

                      }

                      // Return result.
                      // It will either be an empty string or the parsed content.
                      return result;
                  });


                  // Look for any text in the pattern of __text__.
                  // If found, replace with <em>text</em>
                  result = result.replace(/\_\_(.*?)\_\_/g, function (match, target) {

                      return `<em>${target}</em>`;

                  });

                  // Look for any text in the pattern of **text**.
                  // If found, replace with <strong>text</strong>
                  result = result.replace(/\*\*(.*?)\*\*/g, function (match, target) {

                      return `<strong>${target}</strong>`;

                  });

                  /* Classic [[links]]  */
                  result = result.replace(/\[\[(.*?)\]\]/g, function (match, target) {
                      var display = target;

                      /* display|target format */
                      var barIndex = target.indexOf('|');

                      if (barIndex !== -1) {

                          display = target.substr(0, barIndex);
                          target = target.substr(barIndex + 1);

                      } else {
                          /* display->target format */
                          var rightArrIndex = target.indexOf('->');

                          if (rightArrIndex !== -1) {

                              display = target.substr(0, rightArrIndex);
                              target = target.substr(rightArrIndex + 2);

                          } else {

                              /* target<-display format */
                              var leftArrIndex = target.indexOf('<-');

                              if (leftArrIndex !== -1) {
                                  display = target.substr(leftArrIndex + 2);
                                  target = target.substr(0, leftArrIndex);
                              }
                          }
                      }

                      return '<a href="javascript:void(0)" data-passage="' +
                             target + '">' + display + '</a>';

                  });

                  return result;

              }

              window.example.findPassageByPid = function findPassageByPid(pid) {
                  // Find the element by its 'pid'
                  return document.querySelector(`[pid="${pid}"]`);
              }

              window.example.findPassageByName = function findPassageByName(name) {
                  // Find the element by its 'name'
                  return document.querySelector(`[name="${name}"]`);
              }

              window.example.showPassage = function showPassage(passagedata) {

                  // Look for the 'tw-passage'
                  let passage = document.querySelector('tw-passage');

                  // If it is null, it does not exist yet
                  if(passage === null) {
                      // Create it
                      passage = document.createElement('tw-passage');
                  }

                  // Save the passage contents
                  let passageContents = passagedata.innerHTML;

                  // Parse contents
                  passageContents = example.parse(passageContents);

                  // Set the 'innerHTML' to parsed text
                  passage.innerHTML = passageContents;

                  // Find all links in the current passage
                  let links = passage.querySelectorAll('a[data-passage]');

                  // For each link, add an event listener
                  for(let link of links) {
                      // If a link is clicked...
                      link.addEventListener('click', function() {
                          // Get the name of the passage to load.
                          let passagename = this.attributes["data-passage"].value;
                          // Find the passage by its name
                          let passagedata = example.findPassageByName(passagename);
                          // Show the passage
                          example.showPassage(passagedata);
                      });
                  }

                  // Append the child element to the HTML <body>
                  // (If it already exists, it will not be appended.)
                  document.body.appendChild(passage);

              }

              // Find the 'tw-storydata' element.
              let storydata = document.querySelector('tw-storydata');

              // Get the 'startnode' attribute. Save its 'value'.
              let startnode = storydata.attributes['startnode'].value;

              // Find the element with a 'pid' of the startnode
              let passagedata = window.example.findPassageByPid(startnode);

              // Show the passage matching the startnode
              example.showPassage(passagedata);

          }(window))
		</script>
	</body>
</html>

Reading this file, its contents can be set to the source property of the story object.

index.js

// Require the 'fs' package
const fs = require('fs');

// Read the "story.json" file using 'utf8' encoding
const storyFile = fs.readFileSync("story.json", 'utf8');
// Parse the string into an object
const story = JSON.parse(storyFile);

// Read the "index.html" file using 'utf8' encoding
const indexFile = fs.readFileSync("index.html", 'utf8');
// Add the contents of "index.html" as the 'source'
story.source = indexFile;

Building a “format.js” File

Story formats are normally saved as a “format.js” file. Its contents is a function called window.storyFormat() and includes, as its sole argument, an object literal of the properties of the story.

For example, it will follow this pattern:

window.storyFormat({
   ...
});

Writing a “format.js” File

In Node, this can be written using the fs.writeFileSync() function to combine the window.storyFormat() pattern with the JSON contents of the story object.

// Build a "format.js" file contents
// Convert the 'story' back into a string
let format = "window.storyFormat(" + JSON.stringify(story) + ");";
// Write the "format.js" file using
fs.writeFileSync("dist/format.js", format);

When combined, the “format.js” file will look like the following:

window.storyFormat({"name":"Example","version":"0.0.2","source":"<!DOCTYPE html>\n<html>\n\t<head>\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\t\t<meta charset=\"utf-8\">\n\t\t<title>{{STORY_NAME}}</title>\n\t</head>\n\t<body>\n\t\t{{STORY_DATA}}\n\t\t<script>\n      (function(window){\n\n              // Add an 'example' global property\n              window.example = {};\n\n              // Extend the window.example with a parse()\n              // Parse the text\n              // - Change any &lt; to <\n              // - Change any &gt; to >\n              // - Run any <script>\n              // - Include any passages using <include>\n              // - Convert string emphasis\n              // - Convert emphasis\n              // - Create hyperlinks (and add event listeners)\n              window.example.parse = function parse(result) {\n\n                  // Look for &lt;\n                  // If found, convert into '<'\n                  result = result.replace(/&lt;/g, function (match, target) {\n\n                      return \"<\";\n\n                  });\n\n                  // Look for &gt;\n                  // If found, convert into '>'\n                  result = result.replace(/&gt;/g, function (match, target) {\n\n                      return \">\";\n\n                  });\n\n                  // Using innerHTML will not run any SCRIPT tags.\n                  // Look for SCRIPT tags\n                  // If found, run eval(text)\n                  result = result.replace(/<script>((?:.|\\n)*?)<\\/script>/g, function(match, target) {\n                      // Run the code\n                      eval(target);\n                      // Return an empty string\n                      return \"\";\n                  });\n\n                  // Look for <include> elements\n                  // If found, find the passage, parse it, and return its contents\n                  result = result.replace(/<include>(.*?)<\\/include>/g, function(match, target) {\n\n                      // Look for the passage by its name\n                      let passage = example.findPassageByName(target);\n\n                      // Save a result\n                      let result = \"\";\n\n                      // Was it found?\n                      if(passage !== null) {\n                          // If so, parse it and save the result\n                          result = example.parse(passage.innerHTML)\n\n                      }\n\n                      // Return result.\n                      // It will either be an empty string or the parsed content.\n                      return result;\n                  });\n\n\n                  // Look for any text in the pattern of __text__.\n                  // If found, replace with <em>text</em>\n                  result = result.replace(/\\_\\_(.*?)\\_\\_/g, function (match, target) {\n\n                      return `<em>${target}</em>`;\n\n                  });\n\n                  // Look for any text in the pattern of **text**.\n                  // If found, replace with <strong>text</strong>\n                  result = result.replace(/\\*\\*(.*?)\\*\\*/g, function (match, target) {\n\n                      return `<strong>${target}</strong>`;\n\n                  });\n\n                  /* Classic [[links]]  */\n                  result = result.replace(/\\[\\[(.*?)\\]\\]/g, function (match, target) {\n                      var display = target;\n\n                      /* display|target format */\n                      var barIndex = target.indexOf('|');\n\n                      if (barIndex !== -1) {\n\n                          display = target.substr(0, barIndex);\n                          target = target.substr(barIndex + 1);\n\n                      } else {\n                          /* display->target format */\n                          var rightArrIndex = target.indexOf('->');\n\n                          if (rightArrIndex !== -1) {\n\n                              display = target.substr(0, rightArrIndex);\n                              target = target.substr(rightArrIndex + 2);\n\n                          } else {\n\n                              /* target<-display format */\n                              var leftArrIndex = target.indexOf('<-');\n\n                              if (leftArrIndex !== -1) {\n                                  display = target.substr(leftArrIndex + 2);\n                                  target = target.substr(0, leftArrIndex);\n                              }\n                          }\n                      }\n\n                      return '<a href=\"javascript:void(0)\" data-passage=\"' +\n                             target + '\">' + display + '</a>';\n\n                  });\n\n                  return result;\n\n              }\n\n              window.example.findPassageByPid = function findPassageByPid(pid) {\n                  // Find the element by its 'pid'\n                  return document.querySelector(`[pid=\"${pid}\"]`);\n              }\n\n              window.example.findPassageByName = function findPassageByName(name) {\n                  // Find the element by its 'name'\n                  return document.querySelector(`[name=\"${name}\"]`);\n              }\n\n              window.example.showPassage = function showPassage(passagedata) {\n\n                  // Look for the 'tw-passage'\n                  let passage = document.querySelector('tw-passage');\n\n                  // If it is null, it does not exist yet\n                  if(passage === null) {\n                      // Create it\n                      passage = document.createElement('tw-passage');\n                  }\n\n                  // Save the passage contents\n                  let passageContents = passagedata.innerHTML;\n\n                  // Parse contents\n                  passageContents = example.parse(passageContents);\n\n                  // Set the 'innerHTML' to parsed text\n                  passage.innerHTML = passageContents;\n\n                  // Find all links in the current passage\n                  let links = passage.querySelectorAll('a[data-passage]');\n\n                  // For each link, add an event listener\n                  for(let link of links) {\n                      // If a link is clicked...\n                      link.addEventListener('click', function() {\n                          // Get the name of the passage to load.\n                          let passagename = this.attributes[\"data-passage\"].value;\n                          // Find the passage by its name\n                          let passagedata = example.findPassageByName(passagename);\n                          // Show the passage\n                          example.showPassage(passagedata);\n                      });\n                  }\n\n                  // Append the child element to the HTML <body>\n                  // (If it already exists, it will not be appended.)\n                  document.body.appendChild(passage);\n\n              }\n\n              // Find the 'tw-storydata' element.\n              let storydata = document.querySelector('tw-storydata');\n\n              // Get the 'startnode' attribute. Save its 'value'.\n              let startnode = storydata.attributes['startnode'].value;\n\n              // Find the element with a 'pid' of the startnode\n              let passagedata = window.example.findPassageByPid(startnode);\n\n              // Show the passage matching the startnode\n              example.showPassage(passagedata);\n\n          }(window))\n\t\t</script>\n\t</body>\n</html>\n","description":"An example story format"});

Loading in Twine 2

The story format’s “format.js” file can be loaded through using either a file:// or http(s):// URL in Twine 2. (The desktop version can use file:// whereas the http:// URL is needed for the online one.)

Go to Formats -> Add a New Format. Copy and paste the URL to the “format.js” file.

Note: The example story format created in this series can be loaded from a URL hosted on GitHub: “https://videlais.github.io/example-story-format/dist/format.js

Using the Example Story Format

Once loaded, the new example story format can be used through the Story Format window from the Story Menu using the Change Story Format option.

Like with using any other story format, clicking “Play” will open a new tab with the compiled results using the selected story format.

Reviewing

A story format can be packaged following the specification. A version is required, but other properties should also be used like name, version, and description.

Using Node, a JSON file of its properties can be combined with an “index.html” file. Because the story format needs the HTML, it uses the {{STORY_NAME}} and {{STORY_DATA}} placeholders for the name of the story and the eventual <tw-storydata> element created by Twine 2 or some other Twee tool.

Once the “format.js” file is created, it can be loaded in Twine 2. When the “play” button is clicked, Twine 2 will use the story format to create a new HTML file.

The final result can be found on GitHub.