Creating your own Twine 2 Story Format: Part 4: Adding Functionality

Creating your own Twine 2 Story Format

Part 4: Adding Functionality

Most story formats go beyond what was shown in the previous part. Yes, they parse links and provide the ability to load passages based on the link clicked, but they also provide their own style of markup for users to style their text and functions to perform common tasks.

Adding Markup

Adding new Regular Expressions (RegEx), like what was used in the previous part to look for passage link syntax, is not a huge step. In fact, the pattern shown in the previous part could be re-used to write more markup rules.

Consider the pattern shown for passage link syntax:

/\[\[(.*?)\]\]/g

At first glance, for those not familiar with RegEx, it can look confusing, but it has five parts:

  • “/” – Start at the beginning of a line
  • “\[\[” – Look for two opening square brackets. Because programming languages like JavaScript use square brackets, they also need to be ‘escaped’ (tell the programming language to ignore them), so they also have a forward slash in front of them.
  • “(.*?)” – Capture any characters between the previous symbols and the next set of symbols. The period (.) implies any character. The asterisk (*) means any number of the previous symbol. The question mark (?) tells it to match exactly one of the previous symbol.

    In other words, find any number of character and save them.
  • “\]\]” – Look for two closing square brackets. Because programming languages like JavaScript use square brackets, they also need to be ‘escaped’ (tell the programming language to ignore them), so they also have a forward slash in front of them.
  • “/g” – Look “globally,” or, in other words, find all matches in the current text.

This patterns looks for any number of characters between two opening and closing square brackets. It then uses them to compose the links shown in the previous part.

However, like was mentioned, this is also a good pattern for adding in functionality like markup to add strong emphasis and emphasis to text.

Adding Strong Emphasis

Reducing the previous createHyperlinks() function to only a few lines, it can be re-used to create a new function addStrongEmphasis(). Instead of looking for opening and closing square brackets, it could look for two asterisks on each side of some text.

/\*\*(.*?)\*\*/g

Using the same general pattern of createHyperlinks(), it would look like the following:

// Look for any text in the pattern of **text**.
// If found, replace with <strong>text</strong>
function addStrongEmphasis(result) {

  result = result.replace(/\*\*(.*?)\*\*/g, function (match, target) {

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

  });

  return result;

}

Note: The above code uses template literals in JavaScript.

Adding Emphasis

The pattern for adding emphasis would look very similar. Instead of looking for asterisks, it could look for underscores.

/\_\_(.*?)\_\_/g

Using the same function pattern, it would look like the following:

// Look for any text in the pattern of __text__.
// If found, replace with <em>text</em>
function addEmphasis(result) {

  result = result.replace(/\_\_(.*?)\_\_/g, function (match, target) {

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

  });

  return result;

}

Parsing Text

With these additional functions added, the original passage storage contents needs to change in order to test it.

<tw-storydata 
   name="Example HTML"
   startnode="1" hidden>
   <tw-passagedata 
      pid="1" 
      name="Start">
      **This will be bold!** __This will have emphasis__.
      [[Another Passage]]</tw-passagedata>
   <tw-passagedata 
      pid="2" 
      name="Another Passage">
      [[Start]]</tw-passagedata>
</tw-storydata>

With the new text, the emphasis functions need to be added in order to filter and switch out contents in different places before showing the final result to the user. These should go before the final part where the innerHTML content is set.

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

// Filter for emphasis
passageContents = addEmphasis(passageContents);
// Filter for strong emphasis
passageContents = addStrongEmphasis(passageContents);
// Filter for passage link syntax
passageContents = createHyperlinks(passageContents);

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

However, while more functions could be added to search for and react to passage contents, it might make more sense to combine all of these into a single function that receives the content of a passage, replaces content according to a set of rules, and then returns the final result. This could be parse().

function parse(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;

}

Now, all of the “parsing” is part of one function that will look for whatever markup is added, switch the contents, and then return the result to be added and shown to the user.

Adding Common Functions

All of the built-in story formats of Twine 2 share some common things:

  • Including the contents of other passage
  • Providing macro-like functionality

Escaping Symbols

To begin to introduce macro-like functionality also means being aware of a central issue of writing code in an editor and then running it in another setting: escaping symbols.

Normally, when writing text in the Twine 2 editor, the less-than symbol, <, is typed as part of, say, <p>. However, internally, because the less-than symbol is also part of how HTML is written, it is escaped into ‘&lt;’. The same for the greater-than symbol, >, as well. It is converted into ‘&gt;’. Therefore, what is written in the editor as <p> is, when saved as text in a passage, actually ‘&lt;p&gt;’.

The first step, then, is to un-escape any less-than or greater-then signs in the text of a passage to allow for writing HTML and begin to introduce macro-like functionality.

// 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 ">";

});

With un-escaping HTML, this also means that the <script> element can be used in a passage. However, as it will not be run as part of changing the value of innerHTML, it has to be manually found and run with a passage.

Consider the following changed contents of the storage area with <script> and <p> elements:

<tw-storydata 
   name="Example HTML"
   startnode="1" hidden>
   <tw-passagedata 
      pid="1" 
      name="Start">
      **This will be bold!** __This will have emphasis__.
      [[Another Passage]]</tw-passagedata>
   <tw-passagedata 
       pid="2" 
       name="Another Passage">
       &lt;script&gt;
           console.log("Hi!");
       &lt;/script&gt;
       &lt;p&gt;This is a paragraph!&lt;/p&gt;
       [[Start]]</tw-passagedata>
</tw-storydata>

The updated code within the the parse() function would also look like the following to un-escape and run any <script> elements found in the text.

// 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 "";
});

Note: While all story formats use eval() internally, it comes with some security issues. It will run any JavaScript given to it, even malicious code! While not shown here, it should also be part of a try…catch block to prevent the story crashing when code problems happen.

Including Passages

Each built-in story format in Twine 2 includes other passages in different ways. Harlowe uses (display:), SugarCube uses <<include>>, Chapbook uses {embed passage}, and Snowman uses <%= window.story.render(passage) %>. However, a simple HTML-based solution could be added for this example <include>.

The text content of any <include> element will be treated as the name of a passage. It will attempt to be found and, if so, its content parsed and returned for inclusion where the element was written.

For example, consider the following changes to the storage area:

<tw-storydata 
   name="Example HTML"
   startnode="1" hidden>
   <tw-passagedata 
     pid="1" 
     name="Start">
     **This will be bold!** __This will have emphasis__.
     [[Another Passage]]
   </tw-passagedata>
   <tw-passagedata 
      pid="2" 
      name="Another Passage">
      &lt;script&gt;
        console.log("Hi!");
      &lt;/script&gt;
      &lt;p&gt;This is a paragraph!&lt;/p&gt;
      [[Start]]
      &lt;include&gt;A Third Passage&lt;/include&gt;
    </tw-passagedata>
    <tw-passagedata 
      pid="3" 
      name="A Third Passage">Hi, there!
    </tw-passagedata>
</tw-storydata>

There is now a third passage named “A Third Passage”. This is referenced in the passage “Another Passage” via the new element <include> that will, with some new code, “include” the other passage’s contents.

The new addition to the parse() function, then, would look like the following:

// 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 = findPassageByName(target);

   // Save a result
   let result = "";

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

   }

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

Making it Global

So far, all of the functions added have been part of an closure that wraps all functionality into its own function scope. This can be very helpful to control the variables used. However, when thinking of macro-like functionality, this is not as useful as it could be.

To add functionality that would match something like SugarCube, Chapbook, or Snowman, new global variables and functions need to be added. This means changing the closure to “export” out a variable that will carry the changes made within it.

Note: This is often known as the module pattern in JavaScript, where a closure accepts a variable, extends it, and then “exports” it again. It is useful in situations where the class keyword cannot be used.

<script title="Twine engine code" data-main="example">
(function(window){

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

Changing all the existing functions to be properties of a new global example property means that they can be called from within a any <script> elements in other passages, potentially allow a user much for flexibility when writing code.

Any future macro-like functions could also be added through either adding a new property to example or adding a new parsing rule like that of the <include> element used.

The new, revised code is the following:

<html>
    <body>
        <tw-storydata 
            name="Example HTML"
            startnode="1" hidden>
        <tw-passagedata 
            pid="1" 
            name="Start">
            **This will be bold!** __This will have emphasis__.
            [[Another Passage]]</tw-passagedata>
        <tw-passagedata 
            pid="2" 
            name="Another Passage">
            &lt;script&gt;
                // Show in the console the new global 'window.example'
                console.log(window.example);
            &lt;/script&gt;
            &lt;p&gt;This is a paragraph!&lt;/p&gt;
            [[Start]]
            &lt;include&gt;A Third Passage&lt;/include&gt;
        </tw-passagedata>
            <tw-passagedata 
            pid="3" 
            name="A Third Passage">Hi, there!</tw-passagedata>
        </tw-storydata>
    
        <script title="Twine engine code" data-main="example">
            (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>

Reviewing

Markup rules can be added through writing regular expressions to read and change the contents of some text. These can be added to add strong emphasis and emphasis to passages.

Adding HTML means being aware of escaping the less-than and greater-than symbols. Writing code to un-escape them into their ‘<‘ and ‘>’ forms opens up all HTML include the <script> element. However, to run code, it needs to be found and used with the eval() function in the browser.

Other passages can be included through introducing a new element, <include> whose text content is the passage to include. This can follow the pattern used with the <script> element.

Finally, to open the door to future functionality and allow users to access the story format’s functions inside of <script> elements in passages, all existing functions were added to a global window.example property that can be accessed anywhere in the page.