Creating your own Twine 2 Story Format: Part 3: Parsing Links

Creating your own Twine 2 Story Format

Part 3: Parsing Links

While different story format use different syntax, all of them supply the same functionality: converting passage link syntax into hyperlinks to other passages. This allows for a user to click a link and for the story format to load the next passage in sequence.

Instead of writing new code, it is possible to pull from an existing story format, Snowman, for how to parse and create hyperlinks. In Snowman, the text of each passage is parsed and its contents replaced or changed before finally copying the new results over to the passage element. The code for creating hyperlinks can easily be dropped into another project and used for the same purpose.

Breaking the code out and into its own function, then, it would look like the following:

function createHyperlinks(result) {
  /* 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;
}

Updating Basic Story Format

In the previous part, code was added to read from the <tw-storydata> element and use its startnode attribute to find the correct first passage to read. From there, the passage was found using its pid and its innerHTML content was set to the <tw-passage> created within the same code.

In order to move between passages, the extra code needs to be updated to more than one passage and using passage link syntax to connect them.

  <tw-storydata 
            name="Example HTML"
            startnode="1" hidden>
        <tw-passagedata 
            pid="1" 
            name="Start">
            [[Another Passage]]</tw-passagedata>
        <tw-passagedata 
            pid="2" 
            name="Another Passage">
            [[Start]]</tw-passagedata>
        </tw-storydata>

In the above code, the passage “Start” now has a passage link to another, “Another Passage”. From there, it has a link back to “Start”. Once the hyperlink system is in place, it should be possible to ‘move’ from one passage to another and back again.

Parsing Initial Links

With the addition of new passages and a function to parse links, the code can be updated to read the innerHTML of a passage and switch any usages of passage link syntax into hyperlinks.

<html>
    <body>
        <tw-storydata 
            name="Example HTML"
            startnode="1" hidden>
        <tw-passagedata 
            pid="1" 
            name="Start">
            [[Another Passage]]</tw-passagedata>
        <tw-passagedata 
            pid="2" 
            name="Another Passage">
            [[Start]]</tw-passagedata>
        </tw-storydata>
    
        <script title="Twine engine code" data-main="example">
            (function(){

                function createHyperlinks(result) {
                    /* 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;
                }



                // 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 = document.querySelector(`[pid="${startnode}"]`);
                
                // Create a new 'tw-passage' element
                let passage = document.createElement('tw-passage');

                // Set the 'innerHTML' to text with hyperlinks
                passage.innerHTML = createHyperlinks(passagedata.innerHTML);
                
                // Append the child element to the HTML <body>
                document.body.appendChild(passage);
            }())
        </script>
    </body>
</html>

Now, the initial passage is parsed, but this does not complete the story format. There needs to be an ability to click a link and have the code know to load a passage as a result. For that, the existing code needs to become more generalized.

findPassage*() Functions

The most common action for a passage will be to find and return it for parsing. To help with that, its two main attributes, pid and name, can be used to create separate functions: findPassageByPid() and findPassageByName().

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

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

showPassage() Function

Another common action will be to show a passage after it has been found. For this, another function can be written whose purpose is to call the previous function, createHyperlinks(), and to swap the contents of the <tw-passage>element.

Because code in the previous part created the element, a short conditional test could be written to test to see if the element exists. If it does not, this is the first time and the element should be created. If it does, change its contents. Either way, append the child. (If it is already appended, it will not be added a second time.)

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');
  }

  // Set the 'innerHTML' to text with hyperlinks
  passage.innerHTML = createHyperlinks(passagedata.innerHTML);

  // Append the child element to the HTML <body>
  document.body.appendChild(passage);

}

Adding in Event Listeners

There is one last step. The JavaScript code also needs to listen for all click events and then load the next passage based on the data-passage attribute of the link. (The data-passage attribute is created by the createHyperlink() function when parsing passage link syntax.)

For each time the showPassage() function is called, it also makes sense to add in new event listeners. The last step of the function could be to look through the existing elements with the data-passage attribute and set event listeners.

For any click event, the name of the target should be pulled from the element’s attributes. Using the name, it should be found using an attribute selector. Once found, it should be passed to showPassage() to show it to the user, and the whole processing cycle started again.

// 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 = findPassageByName(passagename);
     // Show the passage
     showPassage(passagedata);
  });
}

Putting it All Together

Using event listeners and new functions to show passages once found, the new code will load the first passage and setup new event listeners for every passage shown. It will also parse the contents for passage syntax links and change them into hyperlinks.

When a user clicks on a hyperlink, it will load the target passage. This will continue for as long as their are hyperlinks for the user to click in the story.

<html>
    <body>
        <tw-storydata 
            name="Example HTML"
            startnode="1" hidden>
        <tw-passagedata 
            pid="1" 
            name="Start">
            [[Another Passage]]</tw-passagedata>
        <tw-passagedata 
            pid="2" 
            name="Another Passage">
            [[Start]]</tw-passagedata>
        </tw-storydata>
    
        <script title="Twine engine code" data-main="example">
            (function(){

                function createHyperlinks(result) {
                    /* 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;
                }

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

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

                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');
                    }

                    // Set the 'innerHTML' to text with hyperlinks
                    passage.innerHTML = createHyperlinks(passagedata.innerHTML);

                    // 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 = findPassageByName(passagename);
                            // Show the passage
                            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 = findPassageByPid(startnode);
                
                // Show the passage matching the startnode
                showPassage(passagedata);
                
            }())
        </script>
    </body>
</html>

Reviewing

Existing code can be found to parse passage link syntax into hyperlinks. Using this code, an example can be put together that loads passages based on the target name encoded in an attribute like data-passage. Through parsing a passage’s content, new event listeners can be added so that a click on a link with the attribute data-passage will find a passage of the same name from the existing Twine 2 HTML structure, parse its content, and continue the cycle. For any new passage loaded, this process will repeat.