Snowman Dev Update (2.2: 19 June 2022)

Snowman 2.2 Development Updates:


Restricting Underscore and now using EJS for templates

I have never been completely comfortable with Underscore.js in Snowman. It has nothing to do with the library, but the understanding it adds a great deal of functionality most users will never use. With jQuery already embedded in Snowman, my general belief has been anyone wanting to use Underscore.js should use generic JavaScript or jQuery’s methods to solve any problem they would have used an additional library. The issue for me comes from previous versions of Snowman, all of which included Underscore.js.

For the last two weeks, none of the testing builds included Underscore.js. In deciding to embrace testing examples for the Twine Cookbook, I noticed a large number of examples not only explicitly mention Underscore.js, they also use its functionality. This has meant, in trying not to break too much from past examples, I have had to re-include it in Snowman builds.

In originally removing Underscore.js, I decided to switch the template functionality to EJS. This is what Snowman 2.2 now uses. All template functionality still works, but the code is no longer beholden to Underscore.js, a library which no longer seems to be used or updated as much as it was years ago.

Code sandboxes and pseudo-global properties

When I took over Snowman, it was using the eval() function to process user JavaScript. This is profoundly dangerous for both authors and readers. However, it was, in 2014, the only way for any code an author might add to be run. In 2022, this is not as strictly true as it once was.

If you use a template library like EJS, the eval() function is not used. It will run any user-added JavaScript code, but does not directly invoke the eval() function to do so. In my changes to Snowman, I moved away from using eval() and, in Snowman 2.2, have fully embraced using the same sandbox concept EJS does. Now, any author-added code is run within a separate template sandbox. This has allowed me to do something I wanted to do two years ago but could not quite figure out how to do: pseudo-global properties.

In Snowman 2.0, the global functions renderToSelector() and visited() were introduced. These were added to the window global object, which code should generally avoid doing whenever possible. In Snowman 2.2, these functions are still available, but as pseudo-global properties within the template sandbox. They are passed into the EJS rendering process as part of a new Story.runScript() method used by both user JavaScript and for any template code in a passage:

/**
   * Render JavaScript within a templated sandbox and return possible output.
   * Will throw error if code does.
   *
   * @function runScript
   * @param {string} script - Code to run
   * @returns {string} Any output, if produced
   */
  runScript (script) {
    let result = '';

    try {
      // Send in pseudo-global properties
      result = ejs.render(script,
        {
          State: State,
          s: this.state,
          $: $,
          _: _,
          renderToSelector: this.renderToSelector,
          include: this.render
        },
        {
          outputFunctionName: 'print'
        }
      );
    } catch (e) {
      // Throw error if rendering fails.
      throw new Error(`Error compiling template code: ${e}`);
    }

    return result;
  }

With the usage of the new runScript() method, new global-like properties can be added to story format without also adding multiple properties to the window global. While more testing is definitely needed, I really prefer this approach to allowing authors to use functionality without also polluting global properties.

No more passage rendering

In previous versions of Snowman, the Passage class contained a render() method. This was called when the content (source) of the passage was needed and would run the content through a rendering process. Nearly always, this was in result from a call from the global object Story and its own render() method. From the point of view of a component-based system such as React, this makes perfect sense. Every component takes care of itself. It holds internal state data and possible interactions. However, in Snowman, this is not true. Passages only hold data. There are no direct interactions that do not pass through the story-interaction layer.

Part of this change came about when thinking through how to use the Story.runScript() method. The more I thought through how the previous Passage.render() method was supposed to work, the more I realized it was not needed. The processing of any data takes place as a result of an author interacting with the story, which means only the Story class should be processing passage data.